# 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**. The default view is a **gruvbox editorial page** (a technical-print layout, no images); a "Desktop mode" button swaps to a **KDE Plasma desktop simulation**. Phones only ever get the editorial page. - Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript. - Styling: **Tailwind v4** for the editorial page — `globals.css` is the design system (CSS custom properties, `@theme` tokens, `@utility` classes) — plus plain CSS for the desktop (`skeuo.css`) and the shared component styles the desktop apps still use (`page.css`). - Path alias: `@/*` → `src/*`. - Version: `3.0.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 the theme stylesheets layout.tsx → html shell, metadata, next/font, ThemeScript globals.css → gruvbox editorial design system (tokens, @utility, base, reveals) editorial.css → editorial page helpers (social masks, heatmap) page.css → shared component styles used by the desktop apps skeuo.css → KDE Plasma desktop styles data.ts → socials, codingProjects, techProjects, friends, wallpapers 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 frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist components/ ThemeRoot.tsx → editorial page by default, desktop when theme === "skeuo" EditorialSite.tsx → the gruvbox editorial page (nav, hero, sections) editorial-ui.tsx → editorial primitives: Reveal, Section, ServiceRow, Contributions, … WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar 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 (used inside desktop apps) useTheme.ts → theme state via useSyncExternalStore ThemeScript.tsx → inline pre-hydration script: theme + device detection ServiceButton.tsx → desktop service link + live status dot (pings /api/ping) ContributionHeatmap.tsx → desktop heatmap + recent-commit widget (uses the two proxies) Clock.tsx → live clock (useSyncExternalStore + interval) JokerLogo.tsx → site logo with a click easter egg 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` (`gruvbox` | `skeuo`) and `data-device` (`phone` | `desktop`) on `` from a user-agent sniff. On a phone it forces `gruvbox` and CSS hides every `.desktop-toggle`, so the desktop is unreachable there. 2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`; `setTheme` writes the attribute, persists to `localStorage`, and dispatches `theme-change`. 3. `ThemeRoot` renders `EditorialSite` unless `theme === "skeuo"`, in which case it mounts `WindowsDesktop` instead. The editorial page stays mounted (hidden) while the desktop is up so its scroll position survives; the desktop mounts on demand (phones can never reach `skeuo`). Modes: `gruvbox` = the default editorial site, `skeuo` = KDE Plasma desktop. The editorial page is **asset-free** apart from the social SVGs, which are used as monochrome CSS masks (`.social-mask` in `editorial.css`). `linkedin.svg` is a filled badge that masks to a plain square, so `editorial-ui.tsx` renders an inline mark for it instead. The desktop still uses the image assets. --- ## `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 inside the desktop apps for reordering lists (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), `app-` / `kickoff` etc. (desktop windows), plus the editorial design-system classes (`shell`, `display`, `label`, `rule*`, `glass*`). Grep by prefix to find styles. - The editorial page reveals blocks with `.reveal` (see `Reveal` in `editorial-ui.tsx`): hidden by default, `.is-in` shows. On load the first screenful cascades top-to-bottom (90ms per block, in DOM order); the rest reveal when they scroll in. **Wrap every content block** — anything left unwrapped paints before the top and breaks the top-to-bottom order. The `