Add graphify knowledge graph and agent rules; use freezmo.png; bump to 2.2.15
This commit is contained in:
parent
222c62ca06
commit
6702ca8d6f
13 changed files with 1186 additions and 16 deletions
553
agents.md
553
agents.md
|
|
@ -1,553 +0,0 @@
|
|||
# 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** launch panel (`.web-freezmo` — app-tinted glow, floating orbs,
|
||||
gradient wordmark + "new" badge, feature bullets, Try/Source CTAs) 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue