# 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. --- ## Bump the version on EVERY prompt — also do this every time Another standing requirement: increment the **patch** version (`x.x.+1`, so `2.2.13` → `2.2.14` → …) as part of every task, even the small ones. Keep all of these in sync: - `package.json` → `"version"` - `package-lock.json` → the root `"version"` **and** `packages[""].version` - the `vX.Y.Z` label in `WebLayout.tsx`'s `.web-footer-label` - the version line in `README.md` and in this file --- ## 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.22` (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 web.css + skeuo.css layout.tsx → html shell, metadata, ThemeScript globals.css → base/reset styles + Tailwind import data.ts → socials, codingProjects, techProjects, friends 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 (try/catch → 502 on upstream failure; the widget degrades to a fallback) 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 AeroMusic.tsx → Frutiger Aero soundtrack (random SoundCloud playlist track) FeatureWindow.tsx → decorative CSS "windows" (game / browser / forge); used in the web feature panels and the desktop app windows 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 ``, defaulting to `web`; it also reads `localStorage.scheme` and sets `data-scheme` (`light` | `dark`), defaulting to `dark` (only an explicit stored `light` overrides it). There is no device sniff anymore. **Keep `layout.tsx`'s server-rendered `` defaults in sync** (`data-theme="web"`, `data-scheme="dark"`) with this script's defaults, or React 19 logs a hydration attribute mismatch ("A tree hydrated but some attributes… didn't match"). `` and `` both carry `suppressHydrationWarning` (the theme script mutates `` before hydration, and browser extensions mutate ``). 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 the feature panels (Revival Inc. / RustyWolf) — deliberately **above** services — then services, activity, projects, hardware and friends. Friend cards show a big `friend.emoji` glyph (sized ~the name + domain combined) beside the name + domain; the `.friends-grid` is **always exactly 2 columns** so it stays a clean rectangle/square and never ends in an orphan "L" row (a 4th friend made the old 3-column grid do that). The same `friend.emoji` drives the desktop friend icon (Josef is 🧌 goblin, the rest 🧙 — the old per-friend SVGs are gone). Projects/hardware render as a grouped repo-listing-style **index** (`ProjectIndex.tsx`, `.index-*` classes): group headers (Games & game jams / Linux & desktop / Bots, servers & plugins / Apps & experiments; Desktops & servers / Laptops / Phones / Consoles & handhelds), then rows: coding rows lead with a language-coloured dot, title and a status pill over the description, with the language/host tag right-aligned; hardware rows are plain (no dot) with the year right-aligned. Coding rows are external links with a hover highlight + arrow; hardware rows are static. The rows are **asset-free** (no emoji, no screenshots). `codingProjects` in `data.ts` carries `slug` / `group` / `status?` / `tag` / `accent`; `techProjects` carries `slug` / `group` / `year?` (no `img`/`image`/`emoji`/`accent`). Status pill colors come from the `--status-*` tokens in `web.css`, overridden for the dark desktop windows in `skeuo.css` along with the row hairlines (`--index-line`). Above the split, the full-width **Freezmo** launch panel (`.web-freezmo` — app-tinted glow, floating orbs, gradient wordmark + "new" badge, feature bullets, Try/Source CTAs) is the one place the **real app icon** is used (`public/freezmo.png`, the live study app), floated beside the copy with a soft glow. Clicking the Freezmo icon toggles **Frutiger Aero mode** (`data-aero="true"` on ``, web shell only; desktop mode is untouched): the icon swaps to `public/freezmo_aero.png`, the website background switches to `public/frutigerbackground.png`, floating glossy bubbles (`.web-aero-bubbles` / `.web-aero-bubble`) rise across the backdrop, and all `.glass` panels and interactive controls become crystal-clear 3D bevel-rimmed liquid glass (the `.web-ctl-scheme` light/dark switch is hidden in aero mode — it's a fixed bright-sky look). While aero is on, `AeroMusic.tsx` autoplays a random track from the *Frutiger Aero* SoundCloud playlist (a hidden SoundCloud iframe driven through their widget API: it picks a random index from `getSounds` and re-randomises on every `PLAY` event so the set never plays in order; the chip label is synced from `getCurrentSound` on PLAY, not from what we asked for). SoundCloud's widget is quirky and `AeroMusic.tsx` works around all of it: the Widget API script is loaded *before* the player iframe is mounted, because creating the iframe first lets the widget boot and fire READY before we can bind to it, which leaves the first switch silent (later switches are fine, as the script is cached by then); `playable` is only trusted once the whole set is described, because SoundCloud sends the set in stages and every entry claims `playable: true` until then (`playable: false` means the uploader left "Enable app playback" off, so a third-party app can't stream it at all) — and since even that flag still lies, playback is *verified*: a track that is still `paused` once we have started it is rejected and another is picked; and every widget call goes through an `alive()` check, because the API drives the iframe over `postMessage` and React detaches the iframe *before* the effect cleanup runs — an unguarded `pause()` there throws `Cannot read properties of null (reading 'postMessage')`, which unmounts the whole page. The two remaining feature panels (`.web-split`) stretch to equal height and share a fixed `16 / 10` `.fw` window (the `FeatureWindow` component) — decorative, **asset-free CSS mock-ups** rather than screenshots: Revival Inc. is a little necromancy game scene (night sky, moon, tombstones, blinking "PRESS START") and RustyWolf is a little browser window (tabs in the title bar, toolbar, address bar, start page). The browser window is `aria-hidden` and purely presentational; in the game window the decorative scene is `aria-hidden` but "PRESS START" is a **real link** to the game (`https://dvorakmortonxd.dev/revival-inc`). The real copy stays in the `.web-feature-text` paragraph below. `FeatureWindow` also has a **`forge`** variant (a little git-forge repo page) and all three are reused inside the desktop app windows (GitDXD → forge, Revival Inc. → game, RustyWolf → browser), so there are no app-preview screenshots. The old stacked TUI panel layout and the `SlBackground` ASCII train were removed earlier. - **No raster UI assets.** Every list, feature panel mock, app preview and the desktop wallpaper is drawn in CSS: project/hardware entries are `.index-row` list rows (accent dot, status pill, tag/year, hairline separators), the feature/app previews are `FeatureWindow` mock windows, and `.win-desktop` carries a layered gradient "wallpaper" (there is no `wallpaper.jpg`). Only the social SVGs, the site logo (`src/app/icon.png`), the Freezmo app icons (`public/freezmo.png`, `public/freezmo_aero.png`), the Frutiger Aero wallpaper (`public/frutigerbackground.png`), the Joker easter egg (`joker.webp`) and the Gentoo mark (`gentoo.svg`) remain as real image files. - **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`). - The **dark scheme is deliberately flatter** than light: much lower `--blob-*` alphas (~0.09 vs ~0.30) and a tighter `--glass-hi`/`--glass-lo` pair. White on near-black reads far stronger than white on a pale page, so copying the light alphas makes dark look far more gradient-heavy. Tune the two schemes separately, not by eye against each other. - Interactive surfaces (`.web-ctl`, `.service-button`, `.social-button`, `.index-list`, `a.index-row:hover`, `.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. - The sidebar foot (`.web-side-foot` — the live clock + contact email and its top divider) is hidden on phones under `max-width: 640px`. In the collapsed top panel it's just noise; the desktop/tablet sidebar keeps it. - On phones (`max-width: 640px`) the section nav (`.web-nav`) stops being the sideways-scrolling row it is on tablets and becomes a wrapping **grid** (3-up, 2-up below `375px`) styled to match the Light/Dark toggle (`.web-ctl`): same `--btn-*` surface / radius / shadow and `0.82rem` / `600` type. Every section fits with no horizontal scroll — the collapsed top panel must never scroll sideways. - The hero CTA pills (`.web-hero-actions` / `.web-pill` — "View projects", "Self-hosted services") were **removed from the markup**; don't reintroduce them (their CSS is gone too). On phones (`max-width: 640px`) the hero is centered instead — heading, the `.web-lead` block (`margin-inline: auto`, not just `text-align`) and the `.social-button` labels — and the gym tile's `.web-tile-note` is centered. Every other panel title is centered too: `.web-section-head` (the eyebrow + `.web-section-title`) and `.web-tile-title` ("Right now" / "Gym PRs"). Desktop keeps the left-aligned headings. - The gym PR icons (`.gym-icon--squat` / `--bench` / `--deadlift`) are **pure CSS pictograms**, not SVGs: each is drawn as layered solid gradients on the element's `background` in `currentColor` (one rectangle per background layer), so they adapt to light/dark and the desktop app surface. Squat = a squat rack with a racked barbell, bench = a bench press, deadlift = a loaded barbell. Don't swap them back to images. - The quick tiles (`.web-quick`) are **equal-width (`1fr 1fr`) and equal-height (`align-items: stretch`)**; the "Right now" paragraph sits directly under its title (no `margin-block: auto` — that left an awkward gap between the two). Every panel (`.web-hero` / `.web-section` / `.web-tile`) shares the same **horizontal padding** (`clamp(20px, 3vw, 30px)`) so their content lines up on one left edge; the hero keeps a roomier vertical padding only. - 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 `` can start the browser's **native image drag**, which fires `pointercancel` and kills the custom drag, while emoji-based icons are unaffected (looks like "only some icons don't drag"). No desktop icon uses an `` glyph now — the friends are 🧙 emoji like the rest — but the guard below is kept in case an image glyph is reintroduced. 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 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 ``, 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. - **Dependency security — keep `npm audit` at 0.** `next` must stay at a patched release (`>=16.3.6`; the `next/og` ImageResponse RCE is `>=16.2.0 <16.3.6`). `brace-expansion` is force-upgraded in `package.json` → `overrides` (`minimatch@^3` → `1.1.21`, `minimatch@^10` → `5.0.12`). `braces` has **no upstream fix** — advisory GHSA-vfj7-8cjw-p6xm (CVE-2026-93687) marks every published version, including the latest `3.0.3`, as affected — so a locally patched copy lives at `vendor/braces`: its `version` is bumped to `3.0.4` (out of the advisory's `<=3.0.3` range) and depth guards were added to the recursive AST walkers in `lib/compile.js` / `lib/expand.js` (mirrors micromatch/braces PR #75). It is wired in with `"braces": "file:vendor/braces"` in `overrides`. Because npm **symlinks** a `file:` directory override instead of installing it, braces' only runtime dep `fill-range` is declared directly in `devDependencies` so the symlinked copy can still resolve it. `eslint.config.mjs` ignores `vendor/**` (the vendored CommonJS uses `require()` and would otherwise fail `@typescript-eslint/no-require-imports`). Don't run `npm audit fix --force` here — its "fix" is a bogus downgrade to `eslint-config-next@14`. - **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`, `vlcak.me` and `k0t4tkos-portfolio.com` 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`. Friend icons come from the `friend.emoji` field (no per-friend images) and are used both in the web friends section and on the desktop. - `/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 `` 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 `