552 lines
33 KiB
Markdown
552 lines
33 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.
|
|
|
|
---
|
|
|
|
## 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.14` (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 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
|
|
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 `<html>`, 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 `<html>` 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"). `<html>`
|
|
and `<body>` both carry `suppressHydrationWarning` (the theme script mutates `<html>`
|
|
before hydration, and browser extensions mutate `<body>`).
|
|
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** feature panel (`.web-feature` + `.web-feature-shot`) is the one
|
|
place a **real screenshot** is used (`public/freezmo-app.webp`, the live
|
|
study app). 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 screenshot (`public/freezmo-app.webp`), 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 `<img>` 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 `<img>` 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 `<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
|
|
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 `<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.
|
|
- **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 `<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`.
|
|
- The default shell is the web theme: `curl -s http://localhost:3000/` should
|
|
contain `data-theme="web"` / `data-scheme="dark"` and the `.web-root` markup.
|
|
Theme + scheme are read pre-paint from `localStorage.theme` / `.scheme`, so a
|
|
quick check is to set those in devtools and reload.
|
|
- There are almost no raster images left (only the logo, the social SVGs, the
|
|
Joker easter egg and the Gentoo mark), so a headless full-page screenshot no
|
|
longer shows blank lazy-image boxes below the fold. Headless
|
|
Chromium lives at `~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome`
|
|
(drive it with `--headless=new --remote-debugging-port=…` and CDP).
|
|
- 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.
|