Skip to content

Site maintenance sweep: security headers, tests, and refactors - #16

Merged
TheCodeDestroyer merged 24 commits into
mainfrom
tcd-tweaks
Sep 7, 2026
Merged

Site maintenance sweep: security headers, tests, and refactors#16
TheCodeDestroyer merged 24 commits into
mainfrom
tcd-tweaks

Conversation

@TheCodeDestroyer

@TheCodeDestroyer TheCodeDestroyer commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • Hardens the site (CSP header, pnpm minimumReleaseAge, dependency sweep) and fixes a handful of UI, a11y and navigation bugs.
  • Adds a test safety net: a Playwright E2E harness plus a Node unit test runner, both wired into CI.
  • Refactors the component and constants layers so profile data, section metadata and button styles each live in one place.

Changes

Security & config

  • Enforce a Content-Security-Policy header; stop it blanking the page in development.
  • Pin a 7-day pnpm minimumReleaseAge and document the minutes trap.
  • Read the Node version from .nvmrc and cache the Next build in CI.
  • Keep Claude Code agent worktrees out of git and the tooling's way.

Testing

  • Playwright E2E specs: smoke, metadata routes, navbar highlight, profile consistency.
  • Node --test unit runner for src/**/*.test.ts, starting with pickCurrent.

Navigation & a11y

  • Decide the highlighted section in one place (src/lib/client/currentSection).
  • Announce the menu toggle's actual action.
  • Split NavLink and DisclosureAnchor out of NavBar.

UI

  • Let the contact section's entrance animation run at full speed.
  • Extract the decorative blur blob into an Ellipsis component.
  • Share one media query across every section.
  • Deduplicate button styles into buttonVariants.

Data & metadata

  • Render the profile from one list of facts; name the marker for the ongoing role.
  • Add Spartan Development role, close out Optiweb.
  • Split out llmsDocument, personJsonLd, technology.constants, section.constants.
  • Add a web manifest, icons and a not-found page.
  • Downsample profile.png to 1400px tall (4.2 MB to 774 KB).

Docs

  • Record the domain language in CONTEXT.md; note where the brand hexes are re-typed.

Test plan

  • pnpm lint and pnpm build
  • pnpm test (runs test:unit then test:e2e)
  • Manually: load the site, scroll through the sections and check the navbar highlight follows; open the mobile menu and confirm the toggle's label; check the browser console for CSP violations in both pnpm dev and pnpm build && pnpm start.

Devin Review

TheCodeDestroyer and others added 23 commits August 27, 2026 17:06
- Add Spartan Development d.o.o. (Full Stack Developer, Sep 2026) as the
  current entry at the top of the career carousel
- Set the Optiweb tenure end date to Aug 2026
- Update the llms.txt intro to name Spartan as the current employer; the
  experience listing is generated from the same constant

Reflects the employer change effective 1 Sep 2026. Spartan bullets are
written in present tense as the active role and intentionally omit the
CTO track, which is planned for a Q1 2027 update to this same card.
Closes 22 of the 23 children of thecodedestroyer_github_io-35m. Only the
test-harness issue (.23) is left open.

Security (P0)
- next + eslint-config-next 16.2.9 -> 16.3.3, clearing 4 high and 5 moderate
  advisories (middleware/proxy bypass, SSRF in Server Actions and rewrites,
  DoS in Server Actions and image optimization, cache confusion, unbounded
  Edge Server Action payload, Server Function endpoint disclosure).
- postcss 8.5.16 -> 8.5.26 as a direct bump rather than an override, which
  also lifts nanoid transitively.
- pnpm.overrides collapsed from 17 entries to 4. Every deleted entry was
  proven dead with `pnpm why`; the two security entries that remain are
  load-bearing (brace-expansion via rimraf>glob, minimatch via
  eslint-plugin-import-x). pnpm audit is now clean for both --prod and dev,
  down from 23 advisories.

Bugs (P1)
- scroll-smooth moved from <body> to the actual scroll container in page.tsx,
  so navbar anchors ease instead of hard-jumping.
- Dropped react-device-detect. It read navigator.userAgent at module-eval
  time, so isMobile was always false during SSR and every section hydrated
  with a mismatch. The mobile opt-out is now a CSS media query, which the
  server and client agree on by construction.
- useLayoutEffect -> useEffect in SectionWrapper (SSR'd client component).
- "Contact me" was a <button> assigning window.location.href. It is now a real
  anchor at all three call sites, so it is crawlable, announced as a link, and
  supports middle-click/cmd-click/copy-link. contact.util.ts deleted.

CI (P1)
- checkout@v4->v7, setup-node@v4->v7, pnpm/action-setup@v2->v6; the hand-rolled
  store cache replaced with setup-node's native cache: 'pnpm'.
- Added --frozen-lockfile, a build step, a concurrency group, and a
  pull_request trigger; push now scoped to main. Node 22 -> 24.

Deprecations and a11y (P2)
- Disclosure.Button/.Panel -> DisclosureButton/DisclosurePanel.
- framer-motion 12 -> motion 13 (the package was renamed).
- prefers-reduced-motion is now respected across the section entrance
  animation, the border-spin ring, the halo, and anchor scrolling.
- Security response headers added. The CSP ships report-only on purpose; see
  -35m.26 for what must be verified on a preview before enforcing it.

Cleanup and additions (P3)
- Custom 404 page, web manifest, icon set, explicit robots, sitemap lastmod.
- Static imports replace five no-op next/dynamic wrappers in a Server
  Component, removing a request round-trip.
- Removed zod, stale tsconfig path aliases, dead CSS tokens, a no-op clsx, a
  redundant zustand spread, and an unused image quality.

Notes
- pnpm 10.25 defaults minimumReleaseAge to 1209600, which it reads as MINUTES
  (~840 days), blocking all resolution. Worked around per-invocation with
  --config.minimumReleaseAge=0; no config file was changed. See -35m.25.
- pnpm-lock.yaml is Prettier-formatted and lint-checked. Both conventions are
  now documented in the README.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes thecodedestroyer_github_io-35m.23, the last child of the maintenance
sweep epic. The repo had zero tests; CI ran eslint, prettier and tsc only.

Five tests, ~0.8s, always against the production build rather than `next dev` —
the section ids, metadata routes and prerendered markup are all build-time
output, so the dev server would be testing a different artifact.

- smoke: `/` returns 200, the h1 renders, and every value of the `Sections`
  enum is attached as an element id. Sourced from the enum, not a literal
  list, so a renamed section fails here instead of silently breaking deep
  links.
- metadata routes: /llms.txt, /robots.txt and /sitemap.xml return 200 with the
  expected content type AND a per-entry body marker. Status and content type
  alone are not enough — an empty sitemap generator still emits a well-formed
  `<urlset></urlset>`, which is the regression most worth catching.
- navbar highlight: asserts `aria-current` before and after scrolling to
  #technologies. Deliberately not asserting the `text-accent` class: both come
  from the same `isCurrent` ternary, so the class adds no signal and would go
  red on any Tailwind token rename.

Playwright over Cypress, despite Cypress being on the site's own skill list:
the ESLint preset sets `no-unused-expressions: error`, which bans the Chai
property assertions Cypress leans on, and Cypress's injected globals need a
second tsconfig. Playwright needed no eslint.config.mjs override at all, has
`webServer` built in (Cypress would need start-server-and-test as a second
dependency), and exposes `reducedMotion` as a first-class context option.

Each test was verified to fail when its subject breaks, not just to pass:
pinning the navbar highlight to the first section fails the post-scroll
assertion while the load-state one still passes, which is what proves the
scroll half is not a no-op.

CI: the tests run in the existing job after the build, since `next start`
needs that artifact. Splitting would mean uploading .next plus a second
checkout and install for no parallelism. Job renamed lint -> ci to match what
it now does. The report artifact upload is scoped to the E2E step so it does
not warn on lint or build failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rasableSyntaxOnly

'export enum Sections' was the only thing tripping TS1294 under the base config's 'erasableSyntaxOnly: true', so tsconfig.json had been overriding it to false. Replace the enum with an 'as const' object plus a derived 'Section' union type, switch the four type-position consumers (SectionWrapper, navbar.types, common.store, the navbar-highlight e2e spec) over to it, and drop the override. Value consumers ('Sections.X', 'Object.values(Sections)') are unchanged.

Closes thecodedestroyer_github_io-35m.24

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
public/profile.png was 2850x3491 and 4.0 MB for an image next/image renders
at 531x650 CSS px. With a fixed height and no `sizes`, next/image only ever
requests the 640w (1x) and 1080w (2x) variants, so the source needed ~1100px
of width, not 2850.

- Resize with sharp (Lanczos3, premultiplied alpha, sRGB) to 1143x1400 and
  re-encode as RGBA PNG at zlib level 9 with adaptive filtering
- 4,166,539 -> 774,142 bytes (-81%) in every clone, build and deploy, and
  ~6x fewer pixels to decode on the first optimization request per variant
- Same aspect ratio, so the prerendered <img> width/height are unchanged

Verified against `next build` + `next start`: the 640w/1080w AVIF variants
serve at 640x784 / 1080x1323 with alpha, a DPR-2 browser picks the 1080w
variant at native scale, and the Playwright suite passes.

Closes thecodedestroyer_github_io-35m.27

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename Content-Security-Policy-Report-Only to Content-Security-Policy now that the policy has been verified. Verified against the production build (next start) in headless Chrome, desktop and mobile viewports: zero violations on first paint, while scrolling every section, during the motion entrance animations, hovering the halo cards (--mouse-x/--mouse-y inline writes) and toggling the mobile nav; JSON-LD parses as a Person; /_next/image negotiates avif and webp; manifest.webmanifest and all icon routes load. Playwright suite passes against the enforcing build.

Also add upgrade-insecure-requests, which browsers ignore in report-only mode, and keep X-Frame-Options: DENY deliberately as zero-cost belt-and-braces now that frame-ancestors 'none' is enforced.

next dev under the enforcing header logs one React-dev-only eval() console.error but hydrates, connects HMR and blocks nothing, so no dev gating and no 'unsafe-eval'. No report-to sink, on purpose: re-verify by hand on a Vercel preview after any policy change.

Closes thecodedestroyer_github_io-35m.26

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Button styling: move `inline-flex items-center justify-center` and
`border-none` into the cva base, so the two consumers stop hand-repeating
the layout classes and a bare `buttonVariants()` really is styled like
`<ButtonLink />`, as its comment already claimed. Drop the `disabled:*`
classes left over from the deleted Button component — both surviving
consumers render anchors, where `:disabled` never matches.

Metadata: apply AUTHOR_HEADLINE to the on-page profile alt, interpolate
META_DESCRIPTION from AUTHOR_NAME/SITE_NAME/JOB_TITLE, and use JOB_TITLE
and SITE_NAME in llms.txt. Each was a literal copy sitting outside the
constant that exists to prevent it drifting. The metadata-routes spec now
asserts AUTHOR_NAME instead of the literal, matching the smoke spec.

Entrance animation: delete ENTRANCE_ANIMATION_QUERY and its useCallback.
The media query was a hand-maintained exact complement of the
`static-entrance` CSS variant, in a second language, with nothing
enforcing the invariant; the `!important` utilities are what pin the
resting state, so globals.css is now the single definition. Likewise drop
the useReducedMotion guard in WorkCard — `motion-reduce:hover:before:
opacity-0!` already hides the halo, so the hook only bought a
post-hydration re-render to skip an invisible layout read.

Also flatten the robots metadata (max-image-preview, max-snippet and
max-video-preview are valid top-level RobotsInfo keys, so the googleBot
nesting only duplicated index/follow into a subset meta tag) and point
--background-image-contact at the theme colour variables rather than
re-typing both brand hexes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every run was a cold `next build`: a full bundler compile, a fresh
next/font fetch of both families, and a full sharp re-optimisation of the
profile image. Cache `.next/cache`, keyed on the lockfile plus the source
hash with prefix fallbacks, so an unchanged dependency set reuses it.

Give the Playwright cache `restore-keys` as well — the key is the whole
lockfile hash, so any unrelated dependency bump was re-downloading the
Chromium bundle for a browser that had not changed. On a cache hit the
browser binaries are already present but the runner's system libraries
are not, so `--with-deps` is replaced by a plain `install-deps` there
instead of running unconditionally.

Finally, read the Node version from .nvmrc rather than hardcoding 24,
which was a third copy alongside .nvmrc and the README — the pnpm version
was already de-duplicated this way via packageManager.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Split `test` into `test:unit` (node --test over src/**/*.test.ts) and
  `test:e2e` (playwright), with `test` running both in that order
- Allow `allowImportingTsExtensions` so the explicit-extension imports that
  `node --test` needs typecheck; nothing is emitted, so it costs nothing
- Turn off `no-floating-promises` for test files, where the runner awaits the
  `node:test` suites itself
- Run the unit tests in CI ahead of the build: no bundler and no browser, so a
  broken rule fails in seconds rather than after a build and a boot
- Ignore `.env*`

Pure logic (the navbar highlight rule, next) had nowhere to be tested except
through a browser, which made the fast feedback loop as slow as the slow one.
Six sections each wrote the highlight from their own `useInView` effect, so
whenever two were in view the winner was whichever effect happened to run last,
and `SectionInterests` — the one section taller than the viewport — needed an
`amount={0.007}` fudge to reach the threshold at all.

- Add `@client/currentSection`: one module-scope IntersectionObserver over a
  thin probe band a quarter down the viewport, with `pickCurrent` (topmost
  section covering the band wins) extracted as a pure, unit-tested rule
- Give sections `useSectionSpy` and the navbar `useCurrentSection`; drop the
  zustand `common.store` and `CurrentSectionState`
- Extract `NavLink` and `DisclosureAnchor`, so a link reads the highlight
  itself and scrolling re-renders three links instead of the whole navbar
- Move the nav's section list and labels into `section.constants`, replacing
  the `NabBarNavigation` array duplicated across the desktop and mobile
  renderers; the e2e spec now looks links up by that list
- Skip the observer and the animation loop in `SectionWrapper` below 48rem and
  under reduced motion, where `static-entrance` discards every frame anyway
- Restore `SectionInterests` to a real `amount`, now that it is only an
  entrance-animation knob

Adds `link.constants` as the home for `links`; the old `link.types` name goes
once its remaining importers move.
The three views over what Nace works with disagreed: JSON-LD `knowsAbout` named
5 technologies, the llms.txt skills section about 40, and the icon grid 12 —
Convex, Supabase, Claude Code and Docker were missing from the structured data
entirely. Each view held its own hand-written copy.

- Add `technology.constants` as the single list, with a `Technology` type
  carrying a canonical name, a prose-only detail and a category; the featured
  set is type-level, so a featured name with no icon (or an icon with no name)
  is a compile error
- Move the JSON-LD document and the llms.txt body out of `layout.tsx` and the
  route handler into `@shared/profile`, where both read the list
- Render the icon grid from `featuredTechnologyOrder` instead of twelve literal
  elements
- Pull `Slovenia` and the remaining literal name and job title in the sections
  up into `meta.constants` as `AUTHOR_COUNTRY` and friends
- Finish the `link.types` -> `link.constants` move and drop the old file
- Add an e2e spec asserting the three renderers agree with the list

Also drops three now-needless `use client` directives from sections that only
render constants.
Names the vocabulary the code now uses for itself — Section, Nav section,
Current section, Probe band, Entrance animation, Profile, Renderer, Technology,
Featured technology — and says where each one lives.

Written after the navbar-highlight and profile refactors, which introduced most
of these terms; it exists so the distinctions they turn on (Current section vs.
Entrance animation, list vs. renderer) stay legible.
- Add `Ellipsis`, holding the contract every blob shares: out of the flow,
  behind the content
- Replace the eight hand-rolled `absolute -z-10 block ellipsis` divs across
  the sections and the 404 page with it
- Split the responsibility explicitly — the wrapper owns stacking, the
  `ellipsis` utility owns blur, radius and opacity, and the call site keeps
  position, size, rotation and colour
- Record the term and that split in CONTEXT.md

Changing how the blobs stack was an eight-file edit with no single place to
make it; it is now one.
- Expand the palette comment in `globals.css` to name both copies CSS cannot
  reach: THEME_COLOR in `@shared/constants/meta.constants.ts`, and the fill
  and stroke in `icon.svg`
- Head `icon.svg` with what it is — the source the apple-icon and manifest
  PNGs are exported from, by hand — and which tokens its hexes mirror

Nothing enforces these copies, so a palette change silently left the theme
colour and the favicon on the old brand. The comments make the blast radius
visible from either end.
- Hold a single lazy `MediaQueryList` for the entrance-animation query
  instead of minting one per subscribe and per `getSnapshot`
- Keep it lazy so the module still imports on the server, where `window`
  does not exist

`getSnapshot` runs on every render of every section, so each one was
allocating a fresh `MediaQueryList` per render for an answer that is global
to the page. This mirrors how `@client/currentSection` keeps one observer.
- Export `ONGOING` from `work.constants` and use it for the current entry's
  `to` value
- Match against it in `llmsDocument` instead of re-typing the `'Present'`
  literal in a second file

The string is both prose the work card prints and the flag llms.txt finds
the current job by. Sharing it means a reworded marker cannot quietly leave
llms.txt naming the previous employer.
- Derive the e2e port from a hash of the checkout path, so `reuseExistingServer`
  reuses *this* checkout's server rather than whichever one started first;
  `E2E_PORT` still pins a port, and the base stays off 3000
- Ignore `.claude/` in ESLint and Prettier — it holds agent worktrees, whose
  files are full checkouts of other branches and were reported as this
  branch's errors, and none of it is in tsconfig anyway
- Update the README's test section to describe the derived port

A second `pnpm test` in a parallel worktree found the first one's `next start`
on the fixed 3111, reused it, and passed green against somebody else's build.
- Make the screen-reader-only label track the open state, so the
  button reads as "Close main menu" once the menu is expanded

The icon already swapped between the hamburger and the X, but the
visually hidden label stayed fixed at "Open main menu". Screen reader
users were told to open a menu that was already open.
The enforcing Content-Security-Policy sent two directives that only make
sense against a deployed HTTPS origin. Both are now gated on a production
build; the shipped policy is byte-for-byte unchanged.

- `upgrade-insecure-requests` rewrote every `http://` subresource URL to
  `https://`. `next dev` speaks plain HTTP, so every chunk, stylesheet and
  font was requested on a port with no TLS listener and failed. Chrome
  exempts `localhost` and hid this locally, but it does not exempt the LAN
  "Network:" URL and WebKit exempts nothing — Safari rendered a bare,
  scriptless page, and Chrome on the LAN URL dropped 12 requests.
- `script-src` now carries `'unsafe-eval'` in development. React's dev
  bundle calls `eval()` to reconstruct call stacks across the server/client
  boundary; blocking it logged a console.error on every load and stripped
  those frames from the error overlay. The production bundle never calls
  `eval()`, so this is a dev-tooling allowance, not a looser shipped policy.

Verified in Chromium, Firefox and WebKit: zero failed requests and a clean
console on `next dev`, and `next build` + `next start` still emits
`upgrade-insecure-requests` with no `'unsafe-eval'`.

Closes thecodedestroyer_github_io-blt
The contact section was the only one carrying Tailwind's `transition`
class, which puts a 150ms CSS transition on `transform` and `opacity` —
the two properties motion writes inline on every frame. The browser then
interpolated toward each frame instead of painting it, so the rendered
scale chased the real one: 0.66 painted where motion had already reached
0.91, and the entrance dragged to ~470ms against the 350ms every other
section takes. The result read as a rubbery zoom-out into zoom-in.

The class was vestigial — the section element has no hover or state change
to transition — so removing it is the whole fix. Re-measured: rendered
scale now tracks the inline value to three decimals and lands on time.
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
thecodedestroyer Ready Ready Preview Sep 7, 2026 9:47am UTC

devin-ai-integration[bot]

This comment was marked as resolved.

…be band

The README's test guide still said end-to-end only, but `pnpm test` runs the
Node unit suite first. Name both layers and both scripts.

The probe band's percentage `rootMargin` was flagged as aspect-ratio
dependent. It is not: Chromium, Gecko and WebKit all resolve the vertical
percentages against viewport height, whatever the spec's prose says. An e2e
case on a wide, short viewport now holds that down, and the comment says why
the reading is safe.

Addresses: ANALYSIS_pr-review-job-a8753ad504e044f890892602a45609df_0001
Declines: BUG_pr-review-job-a8753ad504e044f890892602a45609df_0001

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TheCodeDestroyer
TheCodeDestroyer merged commit a896c80 into main Sep 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant