# agents.md — cold-boot notes Quick context so a fresh session (no memory of this project) can be productive in a minute. Read this first, then skim the files it points at. --- ## Keep README.md AND agents.md in sync — do this on EVERY prompt **Before you finish any task, update the docs so they still match the code.** This is a standing requirement, not a one-off, so treat it as part of "done": **`README.md`** is the user/contributor-facing doc. If a change adds/removes/renames a feature, script, route, app, theme, file or dependency, reflect it in `README.md` (Features, Tech Stack, API Routes, Project Structure, Scripts, running/deploying sections — whichever apply). **`agents.md`** (this file) is the cold-boot doc for the next session. Update it in the same task whenever a change touches anything it describes — architecture, the key files and their invariants, conventions/gotchas, history, or testing notes. A stale `agents.md` is worse than none: it actively misleads the next session. For both docs: - Re-read the doc and the files you touched; fix anything the change made stale. - Keep the author's voice and existing formatting; don't rewrite the whole thing when a small edit will do. - If a task genuinely changes nothing that either doc describes, it's fine to leave that doc untouched — use judgement, don't churn it for nothing. When in doubt, prefer updating. A stale README **or** agents.md is treated as a bug. --- ## What this project is Personal portfolio site for **dvorakmortonxd.dev**. It's not a normal website — it ships **two fully interactive desktop/OS simulations** plus a terminal, and picks which one to show based on theme and device. - Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript. - Styling: plain CSS files (`page.css`, `skeuo.css`), no CSS-in-JS, no Tailwind utilities in the components (Tailwind is present via PostCSS but the look is hand-written CSS). - Path alias: `@/*` → `src/*`. - Version: `2.1.0` (see `package.json`). ### Scripts ```bash npm run dev # node scripts/dev-server.mjs (dev + HMR; strips dev-only debug for non-local access) npm run dev:next # next dev --turbopack (raw, UNPROTECTED escape hatch — don't use behind the tunnel) npm run build # next build --turbopack npm run start # next start npm run lint # eslint ``` Useful checks before handing work back: `npx tsc --noEmit`, `npx eslint `, `npm run build`. ### Git Branch is **main**. Remote is a self-hosted **Forgejo** instance (the "GitDXD" app) at `git.dvorakmortonxd.dev`. **The remote URL contains a plaintext access token.** Do not print it, copy it into new files, or push it anywhere else. --- ## Network resources - **SearXNG** is running on the LAN at `http://192.168.0.7:8888`. Feel free to use it for web searches whenever you need current information. --- ## Architecture ``` src/app/ page.tsx → renders , imports page.css + skeuo.css layout.tsx → html shell, metadata, ThemeScript globals.css → base/reset styles + Tailwind import data.ts → socials, codingProjects, techProjects, friends, wallpapers page.css → base / terminal theme styles skeuo.css → Windows/Plasma desktop + mobile home styles revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html api/ heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap activities/route.ts → proxy for the user's GitDXD activity feed ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist components/ ThemeRoot.tsx → picks which shell to show (the router of the UI) TerminalLayout.tsx → "terminal" theme (Gruvbox) WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar MobileHome.tsx → "skeuo" theme, phone: app grid + dock WinWindow.tsx → a single draggable/resizable window apps.tsx → APPS registry + the content of each app + in-app terminal + embedded-site windows + the Minecraft-style SMP menu useDesktopIcons.ts → desktop icon grid + drag/drop (THE KEY FILE, see below) useDragOrder.ts → simple list reordering (mobile home screen only) useTheme.ts → theme state via useSyncExternalStore ThemeScript.tsx → inline pre-hydration script: theme + device detection ServiceButton.tsx → service link + live status dot (pings /api/ping) ContributionHeatmap.tsx → heatmap + recent-commit widget (uses the two proxies) Clock.tsx → live clock (useSyncExternalStore + interval) JokerLogo.tsx → site logo with a click easter egg SlBackground.tsx → animated ASCII train background (terminal theme) scripts/ dev-server.mjs → custom dev server: live updates minus the dev-only debug surface ``` ### How UI selection works 1. `ThemeScript` runs before paint. It reads `localStorage.theme` and sets `data-theme` (`skeuo` | `terminal`) and `data-device` (`phone` | `desktop`) on `` from a user-agent sniff. 2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore` and dispatches a `theme-change` event on toggle. 3. `ThemeRoot` shows the terminal when `theme === "terminal"`; otherwise it mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS (`html[data-device="phone"]`) decide which is visible. The desktop stays mounted while hidden so the Welcome window isn't recreated. Themes: `skeuo` = KDE Plasma / Windows desktop, `terminal` = Gruvbox terminal. --- ## `useDesktopIcons.ts` — the desktop icon grid (read this carefully) This is the most edited and most subtle file in the repo. It owns desktop icon placement: both the default layout and drag-and-drop. If something is wrong with icons, it's almost certainly here. ### Design - The three icon groups (apps on the left, services top-right, friends bottom-right) are **real flex stacks** in the DOM at `.win-icons` (plus `.win-icons-tr` / `.win-icons-br`). They are rendered **hidden** with class `win-measure` purely so the grid can be measured from them. - On mount (a `useLayoutEffect`), `measure()` reads the real rendered boxes of those stacks: cell size = icon width/height, and origin = the left stack's offset. The row pitch is cell height **+ the CSS flex gap** (`GRID_GAP`, currently 4px). The column pitch starts at cell width + gap but is **stretched a hair** so the grid spans from the left margin to the right margin — a fixed pitch left up to a full cell of slack on the right, which pulled the right-anchored services/friends stacks away from the border. From that it computes `cols` / `rows` for the whole desktop. - `defaultCells()` assigns each icon the cell its stack already occupies. So **the grid you see on load is the grid dragging uses** — no re-layout jump. - On cramped viewports, CSS media queries shrink `.win-icon` (glyph, label and all). Because the grid is measured from the rendered icons, it adapts automatically and there is always a cell per icon — that is what keeps icons from colliding on small/narrow screens. Don't hardcode icon sizes in the grid maths; shrink them in CSS instead. - Icons are then rendered absolutely positioned in `.win-icon-layer`, driven by the `positions` map (`{ [iconId]: {x, y} }`). - Dragging: `onPointerDown` builds a **ghost** (a clone of the button appended to `document.body`, moved with `translate()` under a `grabbing` cursor). The ghost is built imperatively on purpose — moving it must not re-render React. - Drops snap to a cell. `normalize()` is the single guard that enforces **one icon per cell**: it assigns icons in a stable order and bumps any collision to the nearest free cell. Every mutation path (initial layout, drop, window resize) goes through it. ### Invariants — do not break these 1. **One icon per cell.** All position mutations must run through `normalize()`. 2. **The grid must not change shape on first drag.** Default cells come from measuring the real layout; don't hardcode cell sizes. 3. **The init effect must run exactly once.** It depends only on `iconKeys`, which is the module-level constant `ICON_KEYS` in `WindowsDesktop.tsx`. If you pass a fresh array literal here you WILL get "Maximum update depth exceeded" (this bug has already happened once — see history below). ### Gotcha: native drag hijacks the pointer gesture Icons whose glyph is an `` (the friend shortcuts, which use `next/image` with `/skopec.svg`, `/bukovina.svg`, `/vlcak.svg`) can start the browser's **native image drag**, which fires `pointercancel` and kills the custom drag. Emoji-based icons are unaffected, which makes this look like "only some icons don't drag". Guard (already in place, keep it that way): - `draggable={false}` on the `` in `renderIcon`. - CSS `-webkit-user-drag: none` on `.win-icon` and its children. - CSS `pointer-events: none` on `.win-icon-glyph img` / `.win-icon-label` so `e.target` is always the button. ### Related `useDragOrder.ts` is a **different, simpler** hook used only by `MobileHome` for reordering the phone app grid/dock (drag one item over another to swap). It is list reordering, not free placement. Don't confuse the two. --- ## History of the desktop-icon work (why the code looks like this) 1. **Free placement.** First version let icons go anywhere. Worked, but icons could overlap and the layout jumped into a grid on first drag. 2. **Grid snapping.** Dropped icons snap to a grid cell; dropping onto an occupied cell bumps the occupant. 3. **Grid bug fixes.** The cell pitch originally omitted the 4px flex gap, and the drop calculation rounded differently from the occupancy check, so two icons could land in the same cell. Fixed by deriving pitch from measured boxes **plus gap**, and by routing everything through `normalize()`. 4. **Layout no longer jumps.** Grid measured from the real stacks and applied before paint, so load layout == drag layout. 5. **Infinite loop fix.** `iconKeys` was an inline array literal in the effect deps → the effect re-ran every render → `setState` → loop. Hoisted to the module constant `ICON_KEYS` and dropped the unstable callbacks from deps. 6. **Friend icons couldn't be dragged.** Native image drag on the `next/image` glyph fired `pointercancel`. Fixed with `draggable={false}` + `-webkit-user-drag: none`. 7. **Right-edge gap.** Columns tiled from the left with a fixed pitch, so the right-anchored stacks (services/friends) stopped up to a full cell short of the right border (e.g. ~90px on a 1920px screen), and any remount/resize re-snapped them. Fixed by stretching the column pitch so the grid spans both margins; the row pitch is unchanged. --- ## Conventions & things that will bite you - Components are client components (`"use client"` at the top) — they use pointer events and window APIs freely. - CSS classes are prefixed by area: `win-` (desktop), `mobile-` (phone), `app-` / `kickoff` / `terminal` etc. Grep by prefix to find styles. - Several places deliberately use **imperative DOM work** (drag ghost, transform-origin for the mobile launch animation). This is intentional for performance; prefer matching the existing pattern over "Reactifying" it. - `setState` inside an effect is blocked by the ESLint config (`react-hooks/set-state-in-effect`). The one intentional exception is the grid init in `useDesktopIcons.ts`, which carries a scoped `eslint-disable-next-line` with a comment. Don't add more without a reason. - Watch for `pointercancel`: any custom pointer gesture over an ``, link, or text selection needs `draggable=false` / `user-select: none`. - Windows must stay on-screen: `openAppDef` / `resizeWin` clamp `w`/`h`, `moveWin` clamps `x`/`y` (dragging can't push a window off any edge or under the panel), a `resize` effect re-clamps on browser resize, and `.win-window.centered` carries `max-width`/`max-height`. Keep these in sync if you add a new window path, or windows will escape the viewport. The bottom limit is `TASKBAR_HEIGHT = 59` in `WindowsDesktop.tsx` (panel top at 54px + a 5px gap), so dragged and maximized windows stop 5px above the taskbar. That `59` is duplicated in `skeuo.css` (`.win-window.maximized` height, `.win-window.centered` bottom / max-height) — keep them in sync. Note `useDesktopIcons.ts` has its **own** `TASKBAR_HEIGHT = 48` for the icon grid. - **Serving the dev server through the tunnel: read this.** `npm run dev` does **not** run `next dev` directly — it runs `node scripts/dev-server.mjs`, a custom server that wraps Next's request handler. It keeps the live-update path (`/_next/hmr`, Fast Refresh) but returns `404` for Next's dev-only debug endpoints (`/__nextjs*`: launch-editor, original stack frames, source maps, node-inspector attach, server restart, …) and for `*.map` whenever the request did **not** arrive over loopback without proxy headers. Those endpoints are unauthenticated, and Next wires them in unconditionally, so `next.config.ts` cannot disable them — that is what makes a publicly tunnelled dev server unsafe. Net effect: `localhost:3000` keeps the full dev experience; the LAN/tunnel get a plain, live-reloading site. Use `npm run dev:next` only if you explicitly want the raw, unprotected server (never point the tunnel at it). - `allowedDevOrigins` (`next.config.ts`) still matters: the tunnel host and any LAN origin must be listed or Next rejects the HMR WebSocket with a 403 (the client then never hydrates — SSR only, icons stacked at `(0,0)`, dead buttons). Keep it tight (private ranges + the one exact tunnel host); a `**.dvorakmortonxd.dev` wildcard would let any subdomain drive the dev server. Changing `next.config.ts` needs a dev-server restart. - `devIndicators: false` in `next.config.ts` hides Next's on-screen dev badge. - For a fully public URL a production build (`npm run build && npm run start`) is still the gold standard: no dev server, no debug endpoints, no HMR socket. - The SMP service shortcut opens `SMP_APP` (in `apps.tsx`, registered in `APP_BY_ID` only, like `ALACRITTY_APP`) — a Minecraft-style join menu that reads live data from `/api/ping?url=minecraft://…`. - `/api/ping` is a **host-allowlisted** SSRF guard (public + unauthenticated): it only contacts `*.dvorakmortonxd.dev` plus the friends' domains, rejects other hosts/schemes, validates the `minecraft://` address, and never follows redirects. **Adding a service or friend host means adding it to `ALLOWED_HOSTS` in `api/ping/route.ts`**, otherwise its status dot reads "Dead". Do not turn this route back into an open fetch proxy. - Service shortcuts can show the real site in an iframe via `createWebsiteApp({ embed: true })` (the shortcut's `embed` flag), but the browser refuses to frame a site that sends `X-Frame-Options` / `frame-ancestors`, leaving a blank window. `embed` is set on Llama.cpp (Open WebUI), Jellyfin, Navidrome and GitDXD. Llama.cpp/Jellyfin allow framing out of the box; the other two need their host configured so the header is an unrecognised value (browsers ignore unknown X-Frame-Options values): Navidrome via `ND_HTTPHEADERS_FRAMEOPTIONS=ALLOWALL` (it maps `HTTPHeaders.FrameOptions` to that env var and it overrides the built-in `FrameDeny`), and Forgejo via `[cors] X_FRAME_OPTIONS = unset` (Forgejo reads this from the `[cors]` section, **not** `[security]`) followed by a Forgejo restart. For a scoped, clickjacking-safe alternative, remove the header at the edge (Cloudflare "Modify Response Header" / nginx) and add `Content-Security-Policy: frame-ancestors`. Discord keeps a link card because it sends `DENY` and can't be configured. Always verify headers before enabling `embed` on a new service. - Reduced motion is handled with a near-zero `animation-duration` (not `animation: none`) so `animationend` still fires and windows unmount. ## Testing notes - There is **no test suite**. Verification is `tsc --noEmit`, `eslint`, and `next build`. - A dev server may already be running on port 3000; check before starting one. Two dev servers on this repo collide on `.next/dev/lock`, so stop the old one (and delete the stale lock) before starting the custom one. - Dev-server guard check: `curl -s -o /dev/null -w '%{http_code}' \ http://localhost:3000/__nextjs_server_status` must be `200` (localhost keeps debug), while the same path through the LAN IP or `beta.dvorakmortonxd.dev` must be `404`. Note a request from this host to its **own** LAN IP is routed over loopback, so `localAddress` reads `127.0.0.1` — a same-host curl to the LAN IP does NOT exercise the non-loopback branch. Test that branch through the tunnel or from another machine. HMR is fine as long as a real `/_next/hmr` WebSocket upgrade returns `101` and then sends a frame (the HMR client appends `?id=self.__next_r` from the served HTML). - The desktop layout is geometry-heavy. When changing grid maths, a small standalone script mirroring `normalize()` / `posToCell()` is a fast way to prove one-icon-per-cell holds for edge cases (offscreen drops, tiny viewports, repeated drops on one cell). - To verify a service iframe embed without a GUI browser: check the header with `curl -sI ` (you want no `X-Frame-Options`, or a value a browser doesn't recognise), then drive Playwright's cached Chromium (`~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome`) headlessly at a small HTML page that iframes the service and `--screenshot` it. Its system libs can be fetched without root via `apt-get download` + `dpkg-deb -x` into a local dir and `LD_LIBRARY_PATH`. Frame the test from an `http(s)` origin (e.g. a file dropped in `public/` served at `localhost:3000`); a `file://` top-level blocks the iframe's storage and makes working sites look blank. --- ## After finishing — push to git First make sure `README.md` **and** `agents.md` are up to date (see "Keep README.md AND agents.md in sync" at the top), then run **only** these three commands (no extra flags, no rebasing, no force-push): ```bash git add . git commit -m "" git push origin main ``` Replace `` with a short, specific summary of the change.