dvorakmortonxd.dev/agents.md

436 lines
26 KiB
Markdown

# 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 shells** and renders whichever the saved theme selects: a default
frosted-glass **web** site, and an alternate KDE/Plasma **desktop simulation**
(desktop-only). Phones always get the web shell.
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
- Styling: plain CSS files (`web.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.2.3` (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 <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 web.css + skeuo.css
layout.tsx → html shell, metadata, ThemeScript
globals.css → base/reset styles + Tailwind import
data.ts → socials, codingProjects, techProjects, friends, wallpapers
web.css → web theme: frosted glass, light + dark tokens
skeuo.css → Windows/Plasma desktop styles (desktop theme only)
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 → picks which shell to mount (the router of the UI)
WebLayout.tsx → "web" theme (default): sticky sidebar + glass content
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)
useTheme.ts → theme state (web | skeuo) via useSyncExternalStore
useColorScheme.ts → light/dark scheme state via useSyncExternalStore
ThemeScript.tsx → inline pre-hydration script: theme + scheme
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
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` (`web` | `skeuo`) on `<html>`, defaulting to `web`; it also reads
`localStorage.scheme` and sets `data-scheme` (`light` | `dark`), defaulting to
`light`. There is no device sniff anymore.
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`
and dispatches a `theme-change` event on toggle. `useColorScheme()` does the
same for `data-scheme` via a `scheme-change` event.
3. `ThemeRoot` mounts **one** shell at a time: `WebLayout` unless the theme is
`skeuo`, in which case it mounts `WindowsDesktop`. It is deliberately *not*
kept mounted while hidden — the desktop icon grid is measured from real
rendered boxes, so a `display:none` desktop would measure zero-sized cells and
break its grid when shown again.
Themes: `web` = default frosted-glass website (sidebar + content, light/dark),
`skeuo` = KDE Plasma desktop simulation (desktop-only; the web sidebar's
"Desktop" control switches to it, and it has a "Web" control in its tray).
### Web theme + light/dark (v2.2 rework)
- `WebLayout.tsx` is the default shell. Layout is a **sticky sidebar** (brand,
section nav, light/dark switch, desktop switch, contact/clock) beside a single
content column of `.glass` panels: hero (with a compact "Find me" socials row),
a `.web-quick` 2-up ("Right now" + "Gym PRs"), then services, activity,
projects, hardware, features (Revival/RustyWolf) and friends. Projects/hardware
use a CSS multi-column **masonry** gallery (`.project-grid`) so short cards
don't leave half-empty grid slots. The two feature panels (`.web-split`) stretch
to equal height and share a fixed `16 / 10` `.web-shot` frame with
`object-fit: cover` (+ `object-position: center top`), so source images with
different aspect ratios still line up. The old stacked TUI panel layout and the
`SlBackground` ASCII train were removed with it.
- **Not a terminal.** The web theme deliberately has no TUI chrome: the hero is
a name + blurb (no `$ whoami` prompt, no blinking cursor), section eyebrows are
plain words (not `README.MD` / `TECH_LIST.JSON` filenames), and the activity
panel renders without the `martin@git…:~$ echo …` / `git log -n 3` command
lines. `ContributionHeatmap` takes `variant?: "terminal" | "web"` (default
`terminal`); `WebLayout` passes `variant="web"`, the desktop app keeps the
terminal look. Don't reintroduce command prompts in the web variant.
- **Grid gotcha:** the mobile column is `minmax(0, 1fr)`, never bare `1fr`. A
`1fr` track has an automatic `min-content` floor, and with a wide child that
blew the single column out to ~2x the viewport (horizontal scroll + clipped
content on phones). Keep the `minmax(0, …)`.
- Glassmorphism lives in one utility class, `.glass`, in `web.css`: a subtle
`linear-gradient` over `--glass-hi`/`--glass-lo` with
`backdrop-filter: blur(28px) saturate(180%)`. The page blobs behind the panels
are `.web-bg` (a fixed radial-gradient layer at `z-index: 0`; `.web-root` sits
at `z-index: 1`).
- Interactive surfaces (`.web-ctl`, pills, `.service-button`, `.social-button`,
`.project-card`, `.gym-item`, `.contribution-entry`, `.heatmap-container`) read
the `--btn-bg` / `--btn-bg-hi` / `--btn-border` / `--btn-shadow` tokens with
the glass tokens as **fallbacks** (`var(--btn-bg, var(--glass-lo))`). The
tokens are only defined under `html[data-theme="web"]`, so the desktop theme
falls back to the old values and is unaffected. Light-mode buttons need the
extra opacity + a visible (dark, low-alpha) outline or they melt into the pale
background.
- Colours are tokenised in `web.css` under `:root` (light) and
`html[data-theme="web"][data-scheme="dark"]` (dark). The shared token names
(`--fg`, `--gray`, `--accent`, `--green`, …) are reused by `ServiceButton` and
`ContributionHeatmap`. `skeuo.css` overrides the same names while the desktop
theme is active, so the two themes don't bleed into each other.
- The desktop switch (`.web-ctl-desktop`) is hidden on phones: under the
`max-width: 960px` breakpoint and under `(hover: none) and (pointer: coarse)`.
There is no device sniff anymore — phones just get the responsive web shell.
- Under `max-width: 640px` the year-grid heatmap (`.heatmap-container`) and its
legend/hover row (`.heatmap-status-row`) are `display: none` — the grid is
unreadable at phone widths. The contribution total and the commit history
still render, so the activity section keeps its substance on phones.
---
## `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 `<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>` 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
Nothing else consumes the desktop grid. `apps.tsx` is the only other place that
knows about icon ids, via `APP_BY_ID`.
---
## 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: `web-` (web theme), `glass` (the shared glass
panel utility), `win-` (desktop), `app-` / `kickoff` etc. Grep by prefix to
find styles.
- Several places deliberately use **imperative DOM work** (the desktop drag
ghost). 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 `<img>`, 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).
- The dev server runs on this host as a **system** systemd unit
(`/etc/systemd/system/dvorakmortonxd-dev.service`, kept in-repo at
`deploy/dvorakmortonxd-dev.system.service`, installed by
`deploy/install-system-service.sh`). It is enabled at `multi-user.target`, so
it starts at boot with no login/linger. `systemctl restart dvorakmortonxd-dev`
reloads it; logs with `journalctl -u dvorakmortonxd-dev -f`. The unit pins
`HOSTNAME=0.0.0.0` (the script reads that env var) so the tunnel can reach
:3000, clears a stale `.next/dev/lock` on start, and sets `HOME` because system
units don't inherit a login environment. A per-user twin
(`deploy/dvorakmortonxd-dev.service`) exists for rootless installs; it needs
`loginctl enable-linger` and is driven via `systemctl --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.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.
- Friend shortcuts reuse the same embed path, driven by `data.ts`. `friend.iframe`
is the `embed` flag; `friend.url` is what the frame loads and `friend.realUrl`
is what the www button opens — `createWebsiteApp`'s `embedUrl` option lets the
two differ. `bukovinafilip.com` and `vlcak.me` send no framing headers, so they
frame the live site. `skopec.dev` sends `X-Frame-Options: SAMEORIGIN` on every
path, so `friend.proxy` routes it through `/api/frame`.
- `/api/frame` is a **host-allowlisted** header-stripping proxy for sites that
refuse framing. It fetches the HTML server-side (so the upstream
`X-Frame-Options` never 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 proxies `ALLOWED_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 the `pointermove`/`pointerup`, so a gesture that listens on `window`
(see `WinWindow.tsx`) freezes mid-drag. `WinWindow` therefore flags the window
`interacting` for the duration of the gesture and CSS sets
`pointer-events: none` on `.website-embed-frame` until it ends. Keep that rule
if you add another gestural interaction that can pass over an embed.
- `EmbeddedSite` (in `apps.tsx`) shows a spinner until the iframe fires `load`,
because Navidrome/Forgejo are SPAs that render blank for a moment while they
boot.
- 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`.
- The default shell is the web theme: `curl -s http://localhost:3000/` should
contain `data-theme="web"` / `data-scheme="light"` and the `.web-root` markup.
Theme + scheme are read pre-paint from `localStorage.theme` / `.scheme`, so a
quick check is to set those in devtools and reload.
- `next/image` is lazy by default, so a headless full-page screenshot shows the
below-the-fold feature shots and friend avatars as blank boxes. Scroll the page
slowly first and they load; this is a capture artifact, not a bug. Headless
Chromium lives at `~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome`
(drive it with `--headless=new --remote-debugging-port=…` and CDP).
- A dev server may already be running on port 3000 — on this host it is normally
the **system** `dvorakmortonxd-dev` service, so check
`systemctl status dvorakmortonxd-dev` first. 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_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 <service>` (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.
For a site that refuses framing, `/api/frame?url=<site>` should return `200`
with no `X-Frame-Options` and `frame-ancestors 'self'`, while `?url=` for any
other host returns `403`.
---
## 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 "<WHAT CHANGED>"
git push origin main
```
Replace `<WHAT CHANGED>` with a short, specific summary of the change.