- rename the theme everywhere: id terminal -> skeuo, desktop sim skeuo -> desktop, skeleton terminal shell -> SkeuoLayout, page.css -> shared.css (shared components), old skeuo.css -> desktop.css, storage key theme -> dmx-theme (with migration) - rebuild the theme as a walnut-and-aluminium home-lab console: brass plates, patch bay, analogue gym gauges, clipboard, stitched leather logbook, service rack with pilot lamps, framed print, chart recorder, cartridge, software box, pegboard tags, card-index drawer - all materials are CSS + inline-SVG noise (no texture images); Barlow Semi Condensed + Courier Prime via next/font with fallbacks - desktop mode is now phone-only (<=768px): switch hidden above that, stored desktop preference overridden, sim never mounted on wide viewports - drop the ASCII train background and its gruvbox.webp asset
412 lines
24 KiB
Markdown
412 lines
24 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 a hand-built **skeuomorphic console** (`skeuo`, the default) plus a
|
||
KDE-style **desktop simulation** that is only offered on phone-width viewports.
|
||
|
||
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
|
||
- Styling: plain CSS files — `skeuo.css` (the console), `desktop.css` (KDE sim +
|
||
mobile home), `shared.css` (components both shells render) and `globals.css`
|
||
(base/reset). No CSS-in-JS, no Tailwind utilities in the components (Tailwind
|
||
is present via PostCSS but the look is hand-written CSS). **Materials are CSS
|
||
+ inline-SVG noise only — no texture images.**
|
||
- Fonts: Barlow Semi Condensed (metal labels, headings, controls) + Courier Prime
|
||
(paper, readouts) via `next/font/google` with explicit fallbacks; Monocraft
|
||
stays self-hosted in `public/` for the desktop's terminal windows.
|
||
- 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 <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 shared.css + skeuo.css + desktop.css
|
||
layout.tsx → html shell, metadata, fonts, ThemeScript
|
||
globals.css → base/reset styles + Tailwind import
|
||
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
||
skeuo.css → the skeuo console: materials, objects, depth
|
||
desktop.css → Windows/Plasma desktop + mobile home styles
|
||
shared.css → styles for components both shells render
|
||
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 show (the router of the UI)
|
||
SkeuoLayout.tsx → "skeuo" theme (the console): every section as an object
|
||
WindowsDesktop.tsx → "desktop" theme, desktop: icons, windows, taskbar
|
||
MobileHome.tsx → "desktop" 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
|
||
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['dmx-theme']`,
|
||
migrating the legacy `theme` key once (`terminal` → `skeuo`, `skeuo` →
|
||
`desktop`), then sets `data-theme` (`skeuo` | `desktop`) and `data-device`
|
||
(`phone` | `desktop`, from a user-agent sniff) on `<html>`. Viewports wider
|
||
than 768 px always get `skeuo`.
|
||
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via
|
||
`useSyncExternalStore` and dispatches a `theme-change` event on toggle.
|
||
`setTheme("desktop")` is refused when the viewport is wider than 768 px, so
|
||
nothing can leave a desktop visitor stuck in the sim.
|
||
3. `ThemeRoot` always renders `SkeuoLayout` (kept mounted, just hidden, while
|
||
the sim is up so its scroll position survives). On phone-width viewports it
|
||
additionally mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS
|
||
(`html[data-device="phone"]`) decide which is visible; they stay mounted while
|
||
hidden so the Welcome window isn't recreated. The sim is never mounted wider
|
||
than that.
|
||
|
||
Themes: `skeuo` = the site itself (walnut + aluminium console), `desktop` = the
|
||
phone-only KDE Plasma / Windows simulation. The physical switch between them sits
|
||
on the nameplate (`.sk-desktop-switch`, styled as `.sk-switch`) and is only
|
||
visible at ≤ 768 px.
|
||
|
||
---
|
||
|
||
## `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
|
||
`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. The skeuo
|
||
theme's own classes all start with `sk-` (`skeuo.css`) and that prefix is
|
||
reserved for it — `desktop.css` only ever uses `skeuo-*` for its two roots.
|
||
- **The skeuo theme is CSS-only.** No texture images, no `url()` beyond the
|
||
social SVG masks: wood, aluminium, brass, leather, bakelite, paper, glass and
|
||
rubber are gradients + `color-mix()` + an inline-SVG `feTurbulence` grain
|
||
(`--sk-noise`). Build new surfaces from those tokens (`--sk-raise-1/2`,
|
||
`--sk-sink-1`, `--sk-alu-*`, `--sk-brass-*`, `--sk-wood-*`, `--sk-paper-*`)
|
||
rather than inventing new colours, and keep the light coming from the
|
||
top-left. Simplify (drop noise, flatten shadows) at ≤ 768 px.
|
||
- **`shared.css` is shared.** Both shells render `ServiceButton`,
|
||
`ContributionHeatmap`, `JokerLogo`, the project cards and the social buttons.
|
||
Change their base styles only if you check the desktop (`.app-surface` /
|
||
`.win-body` in `desktop.css` layers its own overrides on top). The skeuo theme
|
||
restyles them from `skeuo.css`, scoped to `html[data-theme="skeuo"]`, so the
|
||
desktop keeps its look. The `:root` block in `shared.css` is only the fallback
|
||
palette those components inherit.
|
||
- **Motion stays cheap.** Transitions are 100–150 ms and only ever animate
|
||
`transform` / `opacity`; pressed states move down and swap their shadow
|
||
without animating it. Anything animated respects
|
||
`@media (prefers-reduced-motion: reduce)`.
|
||
- **The desktop sim is phone-only.** `.sk-switch` is hidden above 768 px and
|
||
`setTheme("desktop")` refuses to apply there; `ThemeRoot` also force-switches
|
||
back to `skeuo` when the viewport grows. Don't add another way into the sim
|
||
without keeping those three guards in place.
|
||
- 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 `<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`.
|
||
- 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.
|