dvorakmortonxd.dev/opencode.md

239 lines
12 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 in sync — do this on EVERY prompt
**Before you finish any task, update `README.md` so it still matches the code.**
This is a standing requirement, not a one-off. The README is meant to describe the
project as it is right now, so treat it as part of "done":
- 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).
- Re-read `README.md` and the files you touched; fix anything the change made stale.
- Keep the author's voice and existing emoji-section formatting; don't rewrite the
whole thing when a small edit will do.
- If a task genuinely changes nothing user- or contributor-visible, it's fine to
leave the README untouched — use judgement, don't churn it for nothing.
When in doubt, prefer updating it. A stale README 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 # next dev --turbopack (a server may already be running on :3000)
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
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)
```
### 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.
- 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`.
- 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.
- 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` is up to date (see "Keep README.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.