docs: rename opencode.md to agents.md and refresh it (Forgejo, embed/SSRF notes)
This commit is contained in:
parent
02c516af74
commit
e740a0061b
1 changed files with 19 additions and 10 deletions
323
opencode.md
323
opencode.md
|
|
@ -1,323 +0,0 @@
|
|||
# opencode.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 opencode.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).
|
||||
|
||||
**`opencode.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
|
||||
`opencode.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** opencode.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 fully interactive desktop/OS simulations** plus a terminal, and
|
||||
picks which one to show based on theme and device.
|
||||
|
||||
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
|
||||
- Styling: plain CSS files (`page.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.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 Gitea 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 page.css + skeuo.css
|
||||
layout.tsx → html shell, metadata, ThemeScript
|
||||
globals.css → base/reset styles + Tailwind import
|
||||
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
||||
page.css → base / terminal theme styles
|
||||
skeuo.css → Windows/Plasma desktop + mobile home styles
|
||||
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
|
||||
components/
|
||||
ThemeRoot.tsx → picks which shell to show (the router of the UI)
|
||||
TerminalLayout.tsx → "terminal" theme (Gruvbox)
|
||||
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
|
||||
MobileHome.tsx → "skeuo" 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
|
||||
SlBackground.tsx → animated ASCII train background (terminal theme)
|
||||
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` (`skeuo` | `terminal`) and `data-device` (`phone` | `desktop`)
|
||||
on `<html>` from a user-agent sniff.
|
||||
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via
|
||||
`useSyncExternalStore` and dispatches a `theme-change` event on toggle.
|
||||
3. `ThemeRoot` shows the terminal when `theme === "terminal"`; otherwise it
|
||||
mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS
|
||||
(`html[data-device="phone"]`) decide which is visible. The desktop stays
|
||||
mounted while hidden so the Welcome window isn't recreated.
|
||||
|
||||
Themes: `skeuo` = KDE Plasma / Windows desktop, `terminal` = Gruvbox terminal.
|
||||
|
||||
---
|
||||
|
||||
## `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.
|
||||
- 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).
|
||||
- `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.
|
||||
- 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; check before starting one.
|
||||
Two dev servers on this repo collide on `.next/dev/lock`, so stop the old one
|
||||
(and delete the stale lock) before starting the custom one.
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## After finishing — push to git
|
||||
|
||||
First make sure `README.md` **and** `opencode.md` are up to date (see "Keep
|
||||
README.md AND opencode.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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue