Vendor-neutral rules for any AI coding agent working in this repo (Claude Code, Cursor, Codex, Copilot, Amp, Continue, Aider, etc.). Claude-specific and Copilot-specific pointer files redirect here — keep this as the single source of truth (the always-loaded hub). Task-specific playbooks live in .claude/skills/ and load on demand — see Skills below.
Personal portfolio site for Denis Stritar (Berlin-based product designer). A rich, interactive experience: a time-of-day WebGL hero over a four-mood design system.
public/CNAME)stritar.github.io, default branch masteroutput: "export")"use client" islands: the WebGL hero (components/webgl/), the theme toggle (components/theme/theme-toggle.tsx), cloud mode (components/cloud/), and the article layer (components/article/) — add further client islands only with explicit approval.The site is a single page. The case-study route system (/work/<slug>, MDX, content-collections) was removed; don’t reintroduce it without asking. Case studies instead render in-page behind #work/<id> hashes — see Case-study article layer below. Still one route.
| Path | Source | Purpose |
|---|---|---|
/ |
app/page.tsx | The whole site: header + tagline over the WebGL hero |
/sitemap.xml |
app/sitemap.ts | One entry |
/404 |
app/not-found.tsx | Custom branded 404 |
/robots.txt, /llms.txt, /humans.txt, /site.webmanifest |
public/ | Static |
Trailing slashes are enabled in next.config.ts for reliable GitHub Pages routing.
components/site/header.tsx — Figma nodes 81:303 / 79:253. A 48px row on the content column: 24px logo left, three 48×48 Button variant="ghost" size="icon" right (LinkedIn, theme toggle, cloud mode — node 81:287). --page-inset (16 / 24 / 32px) is still the distance from the top.
pt 32 + row 48 + the page’s pt-2 puts the <h1> at exactly 88px, matching node 81:306.--content-max: 1200px and the page-measure utility (padding-inline: max(--page-inset, (100% − 1200px) / 2)), on <header> and <main>. The comp puts the logo glyph, the tagline, the button cluster’s right edge and the tile row on this one measure — x=264 → 1464 at 1728 — so --tile-rail-w is --content-max and the chrome lines up with the tiles by construction. The max() floors it on --page-inset below 1264px with no breakpoint to keep in sync; there the rail keeps its own 8px gutter and the two edges drift by up to 24px, chrome floors first on purpose. Not grid-page, which pads 96px inside a centred 1440 and lands content 240px from the edge at 1728 — a third measure agreeing with nothing (it stays the /404 grid)..reveal (globals.css). It uses animation-fill-mode: backwards, never both — both retains the final keyframe forever, and animation declarations outrank inline styles, so a retained transform: none would permanently override any transform set later.components/cloud/, node 87:276) strips the page back to the model: CloudModeProvider mirrors the state to [data-cloud] on <html>, and everything tagged .cloud-hide fades out on the 600ms clock (globals.css §4b) while the model eases to the frame centre and up to CLOUD.SCALE. Put .cloud-hide on leaves only — a container below opacity 1 forms a backdrop root, and hiding the header cluster’s wrapper would slide the cloud button out of its slot. The tile rail is the one .cloud-hide element with a backdrop-filter beneath it (each tile’s hover scrim). It survives the fade because it samples its own sibling <img> — content inside the fading subtree — not the page behind it; that does not generalise to glass. See globals.css §4b.components/article/ — Figma node 97:313. Clicking a work tile morphs the rail into a small left-hand stack (full-screen sheet below 769px) and opens that tile’s case study in an opaque --panel surface. The cloud-mode architecture, applied again:
ArticleProvider mirrors the open study to [data-article="<tile id>"] on <html>; all the visuals are CSS attribute selectors in globals.css §4c–d, so the rail, the panel and all three articles stay Server Components. Only TileLink (the tile <a>) and CloseButton ship JavaScript.#work/<id> is owned by lib/article-hash.ts. One pushState per open, replaceState per swap; the ×, Escape and the back button all converge on the popstate handler (single writer). The pre-hydration ArticleScript paints [data-article] before first paint on a direct hash load — the ThemeScript pattern, since a static export can’t know the hash server-side.view-transition-names; the root is deliberately uncaptured (:root { view-transition-name: none }) so the WebGL hero keeps rendering live, and ::view-transition { pointer-events: none } keeps the page interactive mid-tween. Duplicate names silently abort the whole transition. No support / reduced motion ⇒ the same state flip with no tween.display: none. The tile ids appear literally in globals.css §4c (display rules, selected-tile dim, transition names) — renaming one in lib/tiles.ts without renaming it there means that article stops opening.:root[data-article] { --content-inset: var(--page-inset) } (globals.css §4c). Open, the left stack and the panel are full-bleed at --page-inset per node 97:274, so chrome on the 1200 column would put the logo 232px inside the rail it sits above. The variable is re-pointed rather than restated on .page-measure, so no rule has to out-specify the utilities layer; the slide rides .page-measure’s own 600ms..cloud-hide (cloud mode clears it too), so nothing inside the panel may use glass/backdrop-filter — the backdrop-root rule above.delete the dataset key, never set "" — [data-article] matches an empty-valued attribute.Tokens are defined once in app/globals.css and documented in docs/tokens.md. The Figma “Portfolio” library mirrors them.
morning/noon/evening/night) selected by [data-theme] on <html>. No separate light/dark — the moods are the theme. Utilities resolve through CSS variables that [data-theme] re-points.text-fg, bg-base, bg-btn/text-btn-fg, bg-glass, border-hairline, font-heading/font-body/font-reading/font-mono, and the glass / grid-page utilities. Do not hard-code hex colours in components.ThemeScript must stay synchronous and dependency-free — it paints the mood before hydration; the provider refines system via geolocation after. Keep the script’s bucket hours in sync with lib/theme/constants.ts.Lives entirely in components/webgl/, mounted via HeroMount with dynamic(..., { ssr: false }) from a client wrapper (calling that from a Server Component errors in Next 16). Nothing 3D runs at build time.
/assets/models/model-<mood>.glb + /assets/hdr/<mood>.hdr (drei <Environment> + useGLTF). Tuning constants (EASE, MAX_YAW, …) live in webgl-assets.ts.prefers-reduced-motion (static), drifts on touch devices, and pauses the render loop when the tab is hidden.work-on-the-webgl-scene skill before editing.Binary assets (3D models, HDR maps, favicons, resume) live in public/ and are served from the domain root (no basePath). Text faces are self-hosted by next/font at build time, not from public/. HDR maps light the scene only. public/assets/hdr/night.hdr is oversized (~4.3 MB) — compress to match the others (~140 KB) before launch.
Icons. public/icon.svg is the source of truth for the brand mark on disk — hand-authored, transparent, and it flips #2f2f2f ↔ #f7f5f4 via an embedded prefers-color-scheme rule (a favicon renders outside the document, so currentColor can’t work). Its geometry must stay in sync with LogoMark in components/icons/index.tsx. Everything else — favicon.ico (16/32/48), apple-touch-icon.png, icon-192.png, icon-512.png, icon-maskable-512.png — is generated by npm run icons (scripts/generate-icons.mjs) and committed; it is deliberately not wired into build. Rasters can’t adapt, so they ship the light treatment, opaque where the platform demands it. Icons are declared in lib/seo.ts + public/site.webmanifest — don’t also add app/icon.* file-convention files, as the two mechanisms would compete.
Site-wide metadata: lib/seo.ts via app/layout.tsx.
title and description. One <h1> per page — on / that is the tagline in site.tagline; content headings start at <h2>.Person JSON-LD.<img> needs a meaningful alt (or alt="" for decorative). Images use images.unoptimized: true — optimize before committing (GitHub Pages has no optimizer).Load the matching playbook in .claude/skills/ before the task:
out/ → preview → shipglobals.cssbasePath to next.config.ts — the site serves from the domain root."use client" island beyond the four sanctioned ones (WebGL hero, theme toggle, cloud mode, article layer) without approval.npm run typecheck
npm run build
Report exact output if either fails. The build fetches Outfit/Roboto Mono from Google Fonts via next/font — it needs network (CI has it; local builds need connectivity). out/ must contain index.html, 404.html, CNAME, llms.txt, humans.txt, robots.txt, sitemap.xml, .nojekyll, _next/, and assets/. Preview with npm run preview and smoke-test the WebGL, the header entrance, and the theme toggle in a browser.
GitHub Actions: .github/workflows/deploy.yml. Push to master → Node 24 → npm run build → uploads ./out to the github-pages environment.