Personal site of Milos Cvetkovic, Senior Full-Stack Engineer. An interactive, animated portfolio built as a small Turborepo monorepo and used as a working example of how I ship software: written plans, tests before code, every quality gate enforced locally and in CI, and a documented deployment.
https://miloscvetkovic.dev, deployed on Vercel from main and live since 2026-09-09. The exact steps, DNS records and verification checks are in docs/runbooks/deploy.md.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, server components by default), React 19, TypeScript 5 |
| Styling | Tailwind CSS 4 (CSS-first config), Geist fonts |
| Motion | GSAP 3 + ScrollTrigger for the scroll story, SMIL and CSS for decorative details |
| Monorepo | Turborepo 2, pnpm 10 workspaces, Node 22 |
| Tests | Vitest 5 + Testing Library (unit), Playwright (end-to-end) |
| Quality | Prettier 3 with Tailwind class sorting, ESLint 9 (eslint-config-next), Husky, lint-staged, commitlint |
| Delivery | GitHub Actions (SHA-pinned), Dependabot, Vercel |
apps/
web/ Next.js site (src/app routes, src/components, src/data, e2e/)
packages/
prettier-config/ shared Prettier config, referenced by prettier.config.mjs files
scripts/ @repo/scripts package: CI gates, Vercel build step, flake hunt, tests
docs/
adr/ architecture decision records
plans/ design documents and step-by-step implementation plans with progress
runbooks/ operational procedures (deployment)
.claude/ project-level Claude Code configuration (hooks, agents, path-scoped rules)
.github/ CI and commitlint workflows, nightly flake hunt, Dependabot, pull request template
Requirements: Node 22 and pnpm 10.34. .nvmrc and package.json#packageManager pin both, so nvm use and Corepack pick the right versions. engines.node is ^22.22.2 || ^24.15.0 || >=26.0.0, derived from the lockfile; an older Node makes pnpm warn rather than stop.
nvm use
corepack enable
pnpm install
pnpm dev:webThe site runs on http://localhost:3000.
pnpm install runs no dependency build scripts: the packages pnpm 10 would ask about ship prebuilt binaries and are denied in pnpm-workspace.yaml (ADR 0013). Each denial records the version whose script was read, and pnpm check:allowbuilds fails CI when that drifts from the lockfile. A checkout from before the setting keeps printing the warning until pnpm clean && pnpm install.
CI runs on every pull request and every push to main (.github/workflows/ci.yml limits its push trigger to main). Of the rows marked CI below, commitlint runs in a workflow of its own, .github/workflows/commitlint.yml, over the pull request title (as written, and with the (#NN) suffix the squash commit gets) and every commit on the branch, or over every commit a push to main lands. The title and the push to main are linted without commitlint's default ignores, so a title such as revert everything or Merge branch x into y fails there although the local hook would accept it as a commit. Of the rest, the quality job runs the first ten in the order listed, dependency review only on pull requests because a push has no base to compare with, and the e2e job runs the last two. On each commit, Husky runs Prettier and ESLint over the staged files and commitlint over the commit message.
| Command | What it checks | Pre-commit | CI |
|---|---|---|---|
| dependency review | Lockfile dependencies a pull request adds with a known advisory | - | yes |
pnpm check:allowbuilds |
allowBuilds entries against the versions the lockfile resolves |
- | yes |
pnpm check:adrs |
Each ADR's status, title, date and link against its row in docs/adr/README.md, and ADR 0012's status and pointer rules |
- | yes |
pnpm test:scripts |
node:test suites in scripts/: allowBuilds drift, AI refusals, Vercel build step, commitlint configs, web server log, flake hunt |
- | yes |
pnpm format:check |
Prettier, shared config, Tailwind class order | yes (staged files) | yes |
pnpm lint |
ESLint with --max-warnings 0 in apps/web |
yes (staged files) | yes |
| commitlint | Conventional Commits (feat, fix, chore, docs, test, ...); in CI, the PR title and every commit |
yes | yes |
pnpm typecheck |
next typegen && tsc --noEmit (web), strict checkJs over scripts/ |
- | yes |
pnpm test |
Vitest unit tests | - | yes |
pnpm build |
Production build of apps/web |
- | yes |
pnpm check:build-output |
Every route in the web build prerendered, with its body file, and no server function but /mcp, the allowlist's one entry |
- | yes |
pnpm --filter web test:e2e |
Playwright against the production build; a test that passes only on a retry fails the run | - | yes |
scripts/check-webserver-log.mjs |
Anything the web server wrote to stderr during the e2e run, beyond ADR 0015's NoFallbackError block |
- | yes |
CodeQL default setup is on as well. GitHub manages it outside .github/workflows, and it reports on pull requests beside these checks.
.github/workflows/flake-hunt.yml is not a check: every night it runs the e2e suite 30 times with scripts/flake-hunt.sh and opens an issue for flaky tests no open flake-hunt issue tracks yet.
.github/workflows/live-check.yml is not a check either: after each production deployment and every morning it loads every page of the live site with apps/web/playwright.live.config.ts, fails when a page does not load the Web Analytics tracker, logs an error or warning to the console, or stores a cookie or a storage entry, or when a page's URL, asked in both orders, serves a browser Markdown or an agent that asks for Markdown the HTML page, and opens an issue when it fails. It cannot see a page view reach Vercel: the tracker sends none to an automated browser.
Useful extras: pnpm lint:fix, pnpm format, pnpm clean.
- Unit tests live next to the code in
__tests__folders. jsdom is the default environment; a file with no DOM in it opts out with an@vitest-environment nodedocblock at the top, as the threeapps/web/src/data/__tests__files do, because building a jsdom window costs about two seconds in every worker (see .claude/rules/unit-tests.md). Components that readmatchMediaorIntersectionObserverstub them explicitly (seeapps/web/src/components/__tests__/featured-work.test.tsx); there is no global mock, so a component that forgets to guard those APIs fails loudly.pnpm --filter web test:coverageprints a v8 coverage summary of the same suite; it is report-only, with no threshold, and CI does not run it. - End-to-end tests live in
apps/web/e2e. Playwright always starts the server it tests, on port 3210 by default and 3000 in the CI job, which setsPLAYWRIGHT_PORT, so a port that is already taken aborts the run instead of testing whatever is answering on it; underCI=trueit serves the production build with one worker and two retries. The console and accessibility gates opt out of those retries, because a retry turns an intermittent failure into a green run. Interactions must wait for hydration, because event listeners only exist after React mounts: the root layout renders a hidden#hydration-markeron every route that readsfalsein the served HTML andtrueonce React has hydrated, andapps/web/e2e/support/hydration.tswaits on it. - Visual checks are done with Playwright screenshots of the affected section in light, dark and mobile viewports before a UI pull request is opened.
- Conventional Commits, enforced by commitlint. Squash merges into
main; branch namesfeat/,fix/,chore/,docs/,test/,ci/. - Formatting comes from
packages/prettier-config(single quotes, 100 columns, trailing commas, Tailwind class sorting). Do not add editor-specific formatting rules;.editorconfigand.vscode/settings.jsonpoint editors at the same config. - Lint warnings are errors. Fix the pattern instead of adding
eslint-disable; the React Hooks rules in particular point at real hydration and cascading-render bugs (see ADR 0006). - Data lives in
apps/web/src/data, not in components. Case studies are the single source of truth for project copy and metrics. - Every pull request fills in the template: what changed, the commands run with their results, and who reviewed it besides the author.
This repository is developed with Claude Code and keeps its configuration in the repo:
.claude/settings.jsonwires twoPreToolUseguards, and they do not cover the same ground. The file-tool guard blocksEditandWriteon the absolute paths Claude Code sends for.envand.env.*files (except.env.example),pnpm-lock.yaml,node_modules,.nextanddist. The shell guard only reads the command's text: it blocks commands that look like writes to a name containing.envor topnpm-lock.yaml(after dropping every.env.examplefrom the text, so.env.example.localgets through), never looks atnode_modules,.nextordist, and misses writes it does not recognise. It also blocks some commands that write nothing, as known false positives: any mention of.env(even inprocess.env) or of the lockfile that follows a word such asrmormvon the same line, even when that word is an argument or sits in a commit message.scripts/claude-guards.test.mjspins what both guards do, row by row. Treat the full list as the rule and the hooks as a partial backstop; .claude/rules/claude-code-config.md has the details. APostToolUsehook formats every written file with Prettier..claude/agents/ui-reviewer.mdreviews the UI files or diff it is handed against the project's colour, motion, hydration and accessibility rules, citing the ADR, rule file or WCAG criterion behind each finding. Reviews by an agent other than the author are part of the definition of done.next devwrites noAGENTS.mdorCLAUDE.mdintoapps/web, although Next.js generates that pair by default when an AI agent runs it:agentRules: falseinapps/web/next.config.tsturns it off, so the root CLAUDE.md and the scoped rules in.claude/rules/stay the only instruction files (ADR 0019). The version-matched Next.js documentation the pair pointed at is inapps/web/node_modules/next/dist/docs.- Work is planned before it is built: design documents and step-by-step plans with checkboxes live in docs/plans, and decisions that outlive a pull request are recorded in docs/adr.
| Where | What |
|---|---|
| docs/plans/README.md | Index of design documents and implementation plans with status |
| docs/adr/README.md | Architecture decision records |
| docs/runbooks/deploy.md | Deploying to Vercel, DNS, verification, rollback |
| CLAUDE.md | Instructions for AI-assisted development in this repository |
| .claude/rules/ | Instructions Claude Code loads only with the files they cover |
The web app deploys to Vercel from main (project portfolio) with preview deployments for pull requests, except that Dependabot branches create no deployment and a later push that changes no build input has its preview build cancelled (ADR 0016). Root directory apps/web, Node 22, one environment variable (NEXT_PUBLIC_SITE_URL), and .vercelignore keeps caches and local files out of what Vercel receives, from Git-triggered builds and CLI uploads alike. Full procedure: docs/runbooks/deploy.md.