dvorakmortonxd.dev/agents.md

29 KiB

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.5 → 2.2.6 → …) 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.5 (see package.json).

Scripts

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, wallpapers
  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
    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
    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.
  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 services, activity, projects, hardware, features (Revival/RustyWolf) 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 use a CSS multi-column masonry gallery (.project-grid) so short cards don't leave half-empty grid slots; on phones it drops to 2 columns with a shorter card image. The two feature panels (.web-split) stretch to equal height and share a fixed 16 / 10 .web-shot frame with object-fit: cover (+ object-position: center top), so source images with different aspect ratios still line up. The old stacked TUI panel layout and the SlBackground ASCII train were removed with it.
  • 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, .project-card, .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.

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.
  • 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="light" 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.
  • next/image is lazy by default, so a headless full-page screenshot shows the below-the-fold feature shots as blank boxes. Scroll the page slowly first and they load; this is a capture artifact, not a bug. 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):

git add .
git commit -m "<WHAT CHANGED>"
git push origin main

Replace <WHAT CHANGED> with a short, specific summary of the change.