23 KiB
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 skeuomorphic desk: a walnut surface with a monitor, keyboard, rack, drive drawer, pegboard, dot-matrix report, business cards, clipboard and patch panel resting on it. A physical power switch flips to a KDE Plasma desktop simulation. Phones only ever get the desk.
- Framework: Next.js 16 (App Router, Turbopack), React 19, TypeScript.
- Styling: Tailwind v4 for layout plus
web.css, the material system (walnut desk, graphite metal, cream paper, brass, OLED glass, LEDs, screws, rack rails, rocker switch) over five CC0 textures inpublic/textures(credited on/credits).globals.cssholds the tokens, base styles and the reveals;skeuo.cssandpage.cssare the desktop and the shared desktop-app styles. - Path alias:
@/*→src/*. - Version:
3.2.0(seepackage.json).
Scripts
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 <files>,
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 <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell, metadata, next/font, ThemeScript
globals.css → palette tokens, base, @theme/@utility, scroll reveals
web.css → skeuomorphic material system + object layouts
page.css → shared component styles used by the desktop apps
skeuo.css → KDE Plasma desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers
credits/page.tsx → credits for the CC0 textures and fonts
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 → the web desk by default, desktop when theme === "skeuo"
WebSite.tsx → the desk: rack hero, drive drawer, pegboard, patch panel
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
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
ThemeScriptruns before paint and setsdata-device(phone|desktop) from a user-agent sniff. It does not read a stored theme, so the page always lands onweb. CSS hides every.desktop-toggleon phones.useTheme()(inuseTheme.ts) readsdata-themeviauseSyncExternalStore;setThemesets the attribute and dispatchestheme-change. Nothing is persisted, so a reload returns toweb.ThemeRootrendersWebSiteunlesstheme === "skeuo", in which case it mountsWindowsDesktopinstead. The desk stays mounted (hidden) while the desktop is up so its scroll position survives; the desktop mounts on demand (phones can never reachskeuo).
Modes: web = the skeuomorphic desk (default/landing), skeuo = KDE Plasma
desktop.
The desk uses five CC0 textures from ambientCG (public/textures: desk
walnut, metal, paper, carbon, plastic), downscaled to 512 px and credited on
/credits. The social SVGs are used as monochrome CSS masks (.social-mask);
linkedin.svg is a filled badge that masks to a plain square, so web-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 classwin-measurepurely 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 computescols/rowsfor 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 thepositionsmap ({ [iconId]: {x, y} }). - Dragging:
onPointerDownbuilds a ghost (a clone of the button appended todocument.body, moved withtranslate()under agrabbingcursor). 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
- One icon per cell. All position mutations must run through
normalize(). - The grid must not change shape on first drag. Default cells come from measuring the real layout; don't hardcode cell sizes.
- The init effect must run exactly once. It depends only on
iconKeys, which is the module-level constantICON_KEYSinWindowsDesktop.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 <img> (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<Image>inrenderIcon.- CSS
-webkit-user-drag: noneon.win-iconand its children. - CSS
pointer-events: noneon.win-icon-glyph img/.win-icon-labelsoe.targetis 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)
- Free placement. First version let icons go anywhere. Worked, but icons could overlap and the layout jumped into a grid on first drag.
- Grid snapping. Dropped icons snap to a grid cell; dropping onto an occupied cell bumps the occupant.
- 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(). - Layout no longer jumps. Grid measured from the real stacks and applied before paint, so load layout == drag layout.
- Infinite loop fix.
iconKeyswas an inline array literal in the effect deps → the effect re-ran every render →setState→ loop. Hoisted to the module constantICON_KEYSand dropped the unstable callbacks from deps. - Friend icons couldn't be dragged. Native image drag on the
next/imageglyph firedpointercancel. Fixed withdraggable={false}+-webkit-user-drag: none. - 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-/kickoffetc. (desktop windows), andw-(the web desk's materials and objects, inweb.css). Never name aw-*class after a Tailwind utility —.w-screencollided with Tailwind'sw-screen(width: 100vw) and silently stretched every panel to the viewport. Also rememberweb.cssis unlayered, so its properties beat Tailwind utilities on the same element; use a dedicated class (e.g..w-navlink,.w-corner-tr) or!when a utility must win. (.w-screwsetsposition: relativefor its slot, which is why screws are placed with.w-corner-*instead of Tailwind'sabsolute.) - Angles and offsets that hover states need to override live in CSS custom
properties (e.g.
--tilton.w-drive), never in inlinestyletransforms — an inline transform wins over:hover. - The desk reveals blocks with
.reveal(seeRevealinweb-ui.tsx): hidden by default,.is-inshows. 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<noscript>rule inlayout.tsxkeeps everything visible without JS. - Several places deliberately use imperative DOM work (e.g. the drag ghost). This is intentional for performance; prefer matching the existing pattern over "Reactifying" it.
setStateinside an effect is blocked by the ESLint config (react-hooks/set-state-in-effect). The one intentional exception is the grid init inuseDesktopIcons.ts, which carries a scopedeslint-disable-next-linewith a comment. Don't add more without a reason.- Watch for
pointercancel: any custom pointer gesture over an<img>, link, or text selection needsdraggable=false/user-select: none. - Windows must stay on-screen:
openAppDef/resizeWinclampw/h,moveWinclampsx/y(dragging can't push a window off any edge or under the panel), aresizeeffect re-clamps on browser resize, and.win-window.centeredcarriesmax-width/max-height. Keep these in sync if you add a new window path, or windows will escape the viewport. The bottom limit isTASKBAR_HEIGHT = 59inWindowsDesktop.tsx(panel top at 54px + a 5px gap), so dragged and maximized windows stop 5px above the taskbar. That59is duplicated inskeuo.css(.win-window.maximizedheight,.win-window.centeredbottom / max-height) — keep them in sync. NoteuseDesktopIcons.tshas its ownTASKBAR_HEIGHT = 48for the icon grid. - Serving the dev server through the tunnel: read this.
npm run devdoes not runnext devdirectly — it runsnode scripts/dev-server.mjs, a custom server that wraps Next's request handler. It keeps the live-update path (/_next/hmr, Fast Refresh) but returns404for Next's dev-only debug endpoints (/__nextjs*: launch-editor, original stack frames, source maps, node-inspector attach, server restart, …) and for*.mapwhenever the request did not arrive over loopback without proxy headers. Those endpoints are unauthenticated, and Next wires them in unconditionally, sonext.config.tscannot disable them — that is what makes a publicly tunnelled dev server unsafe. Net effect:localhost:3000keeps the full dev experience; the LAN/tunnel get a plain, live-reloading site. Usenpm run dev:nextonly if you explicitly want the raw, unprotected server (never point the tunnel at it). - The dev server runs on this host as a system systemd unit
(
/etc/systemd/system/dvorakmortonxd-dev.service, kept in-repo atdeploy/dvorakmortonxd-dev.system.service, installed bydeploy/install-system-service.sh). It is enabled atmulti-user.target, so it starts at boot with no login/linger.systemctl restart dvorakmortonxd-devreloads it; logs withjournalctl -u dvorakmortonxd-dev -f. The unit pinsHOSTNAME=0.0.0.0(the script reads that env var) so the tunnel can reach :3000, clears a stale.next/dev/lockon start, and setsHOMEbecause system units don't inherit a login environment. A per-user twin (deploy/dvorakmortonxd-dev.service) exists for rootless installs; it needsloginctl enable-lingerand is driven viasystemctl --user. Only one of the two may run at a time — they contend for :3000 and.next/dev/lock. 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.devwildcard would let any subdomain drive the dev server. Changingnext.config.tsneeds a dev-server restart.devIndicators: falseinnext.config.tshides 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(inapps.tsx, registered inAPP_BY_IDonly, likeALACRITTY_APP) — a Minecraft-style join menu that reads live data from/api/ping?url=minecraft://…. /api/pingis a host-allowlisted SSRF guard (public + unauthenticated): it only contacts*.dvorakmortonxd.devplus the friends' domains, rejects other hosts/schemes, validates theminecraft://address, and never follows redirects. Adding a service or friend host means adding it toALLOWED_HOSTSinapi/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'sembedflag), but the browser refuses to frame a site that sendsX-Frame-Options/frame-ancestors, leaving a blank window.embedis 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 viaND_HTTPHEADERS_FRAMEOPTIONS=ALLOWALL(it mapsHTTPHeaders.FrameOptionsto that env var and it overrides the built-inFrameDeny), 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 addContent-Security-Policy: frame-ancestors. Discord keeps a link card because it sendsDENYand can't be configured. Always verify headers before enablingembedon a new service. - Friend shortcuts reuse the same embed path, driven by
data.ts.friend.iframeis theembedflag;friend.urlis what the frame loads andfriend.realUrlis what the www button opens —createWebsiteApp'sembedUrloption lets the two differ.bukovinafilip.comandvlcak.mesend no framing headers, so they frame the live site.skopec.devsendsX-Frame-Options: SAMEORIGINon every path, sofriend.proxyroutes it through/api/frame. /api/frameis a host-allowlisted header-stripping proxy for sites that refuse framing. It fetches the HTML server-side (so the upstreamX-Frame-Optionsnever reaches the browser), injects<base href="origin/">so relative assets still resolve to the real site, and injects a click shim so same-host navigation is proxied too. It only proxiesALLOWED_HOSTS(skopec.dev), never follows redirects, rejects credentials and non-http(s), and returns HTML only — it is public, so keep it that narrow. The framed page executes under our origin, so only allow-list hosts you trust.- Embedded iframes swallow the pointer. While dragging/resizing a window,
the moment the cursor passes over a service
<iframe>the site's own document receives thepointermove/pointerup, so a gesture that listens onwindow(seeWinWindow.tsx) freezes mid-drag.WinWindowtherefore flags the windowinteractingfor the duration of the gesture and CSS setspointer-events: noneon.website-embed-frameuntil it ends. Keep that rule if you add another gestural interaction that can pass over an embed. EmbeddedSite(inapps.tsx) shows a spinner until the iframe firesload, because Navidrome/Forgejo are SPAs that render blank for a moment while they boot.- Reduced motion is handled with a near-zero
animation-duration(notanimation: none) soanimationendstill fires and windows unmount.
Testing notes
- There is no test suite. Verification is
tsc --noEmit,eslint, andnext build. - A dev server may already be running on port 3000 — on this host it is normally
the system
dvorakmortonxd-devservice, so checksystemctl status dvorakmortonxd-devfirst. Two dev servers on this repo collide on.next/dev/lock; stop the service (or the old process) before starting the custom server by hand. - Dev-server guard check:
curl -s -o /dev/null -w '%{http_code}' \ http://localhost:3000/__nextjs_server_statusmust be200(localhost keeps debug), while the same path through the LAN IP orbeta.dvorakmortonxd.devmust be404. Note a request from this host to its own LAN IP is routed over loopback, solocalAddressreads127.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/hmrWebSocket upgrade returns101and then sends a frame (the HMR client appends?id=self.__next_rfrom 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 <service>(you want noX-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--screenshotit. Its system libs can be fetched without root viaapt-get download+dpkg-deb -xinto a local dir andLD_LIBRARY_PATH. Frame the test from anhttp(s)origin (e.g. a file dropped inpublic/served atlocalhost:3000); afile://top-level blocks the iframe's storage and makes working sites look blank. For a site that refuses framing,/api/frame?url=<site>should return200with noX-Frame-Optionsandframe-ancestors 'self', while?url=for any other host returns403.
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):
git add .
git commit -m "<WHAT CHANGED>"
git push origin main
Replace <WHAT CHANGED> with a short, specific summary of the change.