309 lines
16 KiB
Markdown
309 lines
16 KiB
Markdown
# 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)
|
|
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://…`.
|
|
- Service shortcuts show the real site in an iframe via
|
|
`createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only
|
|
works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP.
|
|
`embed` is set on Llama.cpp (Open WebUI), Jellyfin, Navidrome and GitDXD; SMP
|
|
(Discord) is excluded because Discord sends `DENY`. Navidrome (`DENY`) and
|
|
GitDXD (`SAMEORIGIN`) still need those headers stripped at the edge (a
|
|
Cloudflare "Modify Response Header" rule or nginx) before their frames render.
|
|
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.
|