The page was a row of sections to scroll past, and a film in one of them did not change that. It now follows the questions a visitor has, in the order they come up: who is this, can he do what I need, what does it look like, what else, how does it go, has he done it, what is open, how do I start. Each chapter ends by asking the next question as a large link that leads on, and the next chapter answers it. A rail at the edge shows every chapter as a station with a dot travelling along it (on phones: a pill with the current chapter and one tap to the next), and the header names the current question. Where the visitor is, is pure logic in src/domain/chapters.ts. The scroll film section and the bridge sentences are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7.5 KiB
7.5 KiB
mintel.me
Personal website of a senior freelance engineer. Next.js 16 (App Router), pnpm monorepo.
App lives in apps/web. Social media videos live in apps/video (Remotion, standalone, own CLAUDE.md). Concept and scope: apps/web/plans/freelance-relaunch.md (read it first).
Commands (run from repo root)
pnpm --filter @mintel/web typecheck— must pass with zero errorspnpm --filter @mintel/web test— unit specs (Vitest); 0 tests run counts as a failurepnpm --filter @mintel/web test:coverage—src/domainmust stay at 100%pnpm --filter @mintel/web test:mutation— Stryker onsrc/domain, ~2 min, must score 100pnpm --filter @mintel/web lint
How to work here (spec first)
- Write the behavior as concrete cases (inputs, outputs, edge cases, errors) as a
*.spec.ts. - Run it and confirm it fails for the right reason (missing behavior, not an import error).
- Implement the simplest clean solution, run again.
- Run typecheck, coverage and mutation. Survivors mean weak specs or dead code: fix one or the other.
- Report the exact commands and results. "Done" means they were run on the current code. Never weaken, skip or delete a test to make it pass.
Copy rules (client-facing pages)
- Never mention AI as the owner's tool, and no internal method vocabulary (specs, TDD, mutation, coverage, pull requests). The automation service may name AI as part of what the client gets. Plain language.
- Modest and factual: no promises the owner did not confirm, no superlatives. See the concept, section 1.
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
working together goes, results, questions, contact); each section opens with a
bridgesentence fromstoryin the content files that leads over from the one before. The technical sections (project fit, tools, how a change is secured) live on/freelance(/en/freelance), for people who develop themselves or lead a team. Do not move technical detail back onto the homepage. - The header has the link to the freelance page, the language switch and "Get in touch", which jumps to the
contact section (
#contact). Contact is one email address: no calendar integration, no form. The only other pages are the imprint and the privacy notice. URLs of the previous site redirect to/(seenext.config.mjs). - Title, description and link previews come from
siteinsrc/content/de.tsanden.ts. The share image is a screenshot of the real hero, one per language: after changing the hero headline or its design, runpnpm --filter @mintel/web share-imageand commit the PNGs insrc/assets/share/. - The email address is kept in parts (
src/content/site.ts) and assembled in the browser byEmailLink. Never write the full address into markup, metadata or copy. - The hero design (binary-digit headline) is approved: do not change its design.
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
Two languages (German is the main one)
- German lives at the root (
/,/freelance,/impressum,/datenschutz), English under/en(/en/freelance,/en/imprint,/en/privacy). The app directory renders both underapp/[locale];proxy.tsmaps the public addresses onto it. All routing and detection rules are pure functions insrc/domain/routes.tsandlocale.ts. - Detection: an address without a language follows the
langcookie (set only by the language switch), then the browser'sAccept-Language, then German./en/...is never redirected. - Every visible text is data in
src/content/de.tsanden.ts(typeContentintypes.ts). Components hold no texts: server components take them fromcontentFor(locale), client components fromuseContent(). A new text goes into both files; German uses the formal "Sie". - Never name a file
src/i18n.tsori18n/request.ts:@mintel/next-configwould switch on next-intl. - The privacy notice describes the language cookie; keep it true when the detection changes.
Motion (binary micro-interactions)
- Building blocks live in
apps/web/src/components/bits/:DecodeText(mono labels resolving from digits),BitWipe(headline revealed behind a column of digits),BitReveal(picture assembling from bits),BinaryTicker,ScrollBits,BitBurst,BitCurtain(the language switch transition),BitAura(light along the edge of an element, digits drifting off it; glow only on dark surfaces, on light ones just a few blue digits),BitSeam(lit top edge of a dark section),BitGlow(light following the pointer ondata-glowsurfaces). Reuse them instead of inventing new effects. - The homepage is a guided story (
apps/web/src/components/story/): it follows the questions a visitor has (story.chaptersin the content files). Each chapter ends withNextChapter, the next question as a link that leads on;StoryGuideis the rail with one station per chapter and a travelling dot (a pill on phones), and the header names the current question. Sections become chapters throughdata-chapter(Blockpropchapter). Where the visitor is, is computed insrc/domain/chapters.ts. A new section on the homepage needs a chapter and a question, or it breaks the thread. DecodeTextis for monospaced text only; in proportional type the changing glyph widths make text jump.- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
never counts as visible. Observe an unclipped wrapper (see
BitWipe). - Every effect must respect
prefers-reduced-motionand leave content readable without JavaScript.
Architecture rules
- Functional core, imperative shell. Pure logic goes in
apps/web/src/domain/(no I/O, no clock, no randomness, no framework imports; passtodayetc. in as arguments). Everything there is covered 100% and mutation-tested. Pages, routes and adapters stay thin and call into the core. - Parse at the boundary into precise types (
Result, discriminated unions); no nullable state bags. - No
any,@ts-ignore,!non-null assertions or eslint-disable to silence errors. Fix the cause.
Do not
- Read or edit
.env*(only.env.example). Never print or log secrets. - Commit scratch files (
tmp*,*.log,*.tmp, debug HTML, build output). They are gitignored; keep it so. - Use
--no-verify, force-push, push tomain, or runreset --hard,clean,rm -rfwithout asking. - Edit
.gitea/workflows/*or deploy config without asking. - Upgrade
vitestpast 4.1.x:@stryker-mutator/vitest-runner10.0.0 runs zero tests per mutant under vitest 5 and reports false survivors. Re-verify mutation results after any tooling bump. - Add dependencies for trivial things, or touch unrelated code (no drive-by refactors).
Gotchas
tsconfigincludes.next/types: after deleting a route, stale generated files there breaktsc. Runnext typegenand remove the stale file.- Run only one Stryker process at a time; parallel runs share
.stryker-tmpand hang. - Stryker needs
pluginslisted explicitly instryker.config.json(pnpm layout) and ignores the large asset folders viaignorePatterns; keep them in sync when adding top-level folders. @mintel/*packages arelink:dependencies on a siblingat-mintelcheckout. The app itself now only uses@mintel/next-config; the other entries inpackage.jsonare leftovers from the old site.- Pushing
maindeploys to the testing environment; production is deployed from av*tag. - Git branches: work on
feat/*, never directly onmain.