From 194501e467b2260498182e29b5c6c3a113d54981 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Thu, 24 Sep 2026 12:48:01 +0000 Subject: [PATCH] responsive desktop: shrink icons on cramped viewports so they never collide; clamp windows to the viewport --- README.md | 6 +- opencode.md | 10 ++++ src/app/components/WindowsDesktop.tsx | 50 ++++++++++++++--- src/app/skeuo.css | 79 +++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ec87222..556629f 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ heatmap**, a gym SBD panel and links to friends' sites. - **Phone vs. desktop detection** — a user-agent sniff on `` decides whether the desktop or the mobile home screen is shown. - **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per - cell, with a follow-the-cursor drag ghost. + cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never + collide. - **Multi-window manager** — draggable, resizable, minimizable, maximizable, focus/z-order and animated open/close (with reduced-motion support). The taskbar shrinks its window buttons as more apps open and scrolls when they still don't fit, so every open app stays - reachable. + reachable. Windows are clamped to the viewport so they never open off-screen on a small + monitor. - **Working terminal app** — command input with history (`↑`/`↓`, `Ctrl+L` to clear) and a hidden `ssh` easter egg. - **Live service status** — each self-hosted service is health-checked through the site's diff --git a/opencode.md b/opencode.md index 3af8c41..982a92e 100644 --- a/opencode.md +++ b/opencode.md @@ -138,6 +138,11 @@ with icons, it's almost certainly here. 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 @@ -218,6 +223,11 @@ list reordering, not free placement. Don't confuse the two. `eslint-disable-next-line` with a comment. Don't add more without a reason. - Watch for `pointercancel`: any custom pointer gesture over an ``, link, or text selection needs `draggable=false` / `user-select: none`. +- Windows must fit the viewport: `openAppDef` / `resizeWin` clamp `w`/`h` in + `WindowsDesktop.tsx`, 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-open path, or windows will open off-screen on a small + monitor. - Exposing the **dev** server over a tunnel needs the tunnel host in `allowedDevOrigins` (`next.config.ts`). Without it Next dev rejects the cross-origin HMR WebSocket, so the client never hydrates — the page is SSR diff --git a/src/app/components/WindowsDesktop.tsx b/src/app/components/WindowsDesktop.tsx index 335e279..d3c3339 100644 --- a/src/app/components/WindowsDesktop.tsx +++ b/src/app/components/WindowsDesktop.tsx @@ -149,11 +149,15 @@ export default function WindowsDesktop() { const id = idRef.current++; const vw = window.innerWidth; const vh = window.innerHeight - TASKBAR_HEIGHT; + // Fit the window to the viewport so it never opens off-screen or under the + // panel on a narrow/short monitor. + const w = Math.min(app.width, Math.max(260, vw - 20)); + const h = Math.min(app.height, Math.max(150, vh - 20)); const cascade = Math.min(wins.length, 8) * 30; - let x = Math.round((vw - app.width) / 2) + cascade; - let y = Math.round((vh - app.height) / 2) + cascade; - x = Math.max(10, Math.min(x, vw - app.width - 10)); - y = Math.max(10, Math.min(y, vh - app.height - 10)); + let x = Math.round((vw - w) / 2) + cascade; + let y = Math.round((vh - h) / 2) + cascade; + x = Math.max(10, Math.min(x, vw - w - 10)); + y = Math.max(10, Math.min(y, vh - h - 10)); setWins((prev) => [ ...prev, { @@ -161,8 +165,8 @@ export default function WindowsDesktop() { appId: app.id, x, y, - w: app.width, - h: app.height, + w, + h, z: ++zRef.current, minimized: false, maximized: false, @@ -252,11 +256,43 @@ export default function WindowsDesktop() { ); }; const resizeWin = (id: number, x: number, y: number, w: number, h: number) => { + const vw = typeof window !== "undefined" ? window.innerWidth : 1280; + const vh = (typeof window !== "undefined" ? window.innerHeight : 800) - TASKBAR_HEIGHT; + const nw = Math.min(w, Math.max(260, vw - 20)); + const nh = Math.min(h, Math.max(150, vh - 20)); setWins((prev) => - prev.map((win) => (win.id === id ? { ...win, centered: false, x, y, w, h } : win)) + prev.map((win) => + win.id === id ? { ...win, centered: false, x, y, w: nw, h: nh } : win + ) ); }; + // Keep windows on-screen when the viewport shrinks (narrow monitor, or the + // user resizing the browser): clamp size to the viewport and pull anything + // that would fall off the edge back into view. + useEffect(() => { + const clampToViewport = () => { + const vw = window.innerWidth; + const vh = window.innerHeight - TASKBAR_HEIGHT; + setWins((prev) => + prev.map((w) => { + if (w.maximized) return w; + const nw = Math.min(w.w, Math.max(260, vw - 20)); + const nh = Math.min(w.h, Math.max(150, vh - 20)); + if (w.centered) { + return nw === w.w && nh === w.h ? w : { ...w, w: nw, h: nh }; + } + const nx = Math.max(10, Math.min(w.x, vw - nw - 10)); + const ny = Math.max(10, Math.min(w.y, vh - nh - 10)); + if (nw === w.w && nh === w.h && nx === w.x && ny === w.y) return w; + return { ...w, x: nx, y: ny, w: nw, h: nh }; + }) + ); + }; + window.addEventListener("resize", clampToViewport); + return () => window.removeEventListener("resize", clampToViewport); + }, []); + const taskbarClick = (w: WinState) => { if (w.minimized) { setWins((prev) => diff --git a/src/app/skeuo.css b/src/app/skeuo.css index 1a5ff61..22eccd9 100644 --- a/src/app/skeuo.css +++ b/src/app/skeuo.css @@ -163,6 +163,75 @@ a.win-icon { opacity: 0.92; } +/* Cramped desktops: shrink the icons so the grid always has a cell for every + icon (so they can never collide) and the launcher stacks stay on screen. The + grid is measured from the rendered icons, so it adapts automatically. */ +@media (max-width: 900px), (max-height: 600px) { + .win-icon { + width: 80px; + padding: 5px 4px 7px; + gap: 6px; + } + .win-icon-glyph { + width: 40px; + height: 40px; + font-size: 32px; + } + .win-icon-label { + font-size: 0.68rem; + } + .win-icon-img { + width: 32px; + height: 32px; + } +} +@media (max-width: 720px), (max-height: 440px) { + .win-icon { + width: 66px; + padding: 4px 3px 5px; + gap: 4px; + } + .win-icon-glyph { + width: 32px; + height: 32px; + font-size: 26px; + } + .win-icon-label { + font-size: 0.6rem; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .win-icon-img { + width: 26px; + height: 26px; + } +} +@media (max-height: 380px) { + .win-icon { + width: 56px; + padding: 3px 3px 4px; + gap: 3px; + } + .win-icon-glyph { + width: 28px; + height: 28px; + font-size: 22px; + } + .win-icon-label { + font-size: 0.54rem; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .win-icon-img { + width: 22px; + height: 22px; + } +} + /* The three default stacks are laid out once so the grid columns can be read off them. They stay in the DOM (keeping the same metrics) but must never be seen or clicked - the visible icons all come from .win-icon-layer. */ @@ -246,6 +315,16 @@ a.win-icon { top: 0; bottom: 54px; margin: auto; + /* Never let a centred window (e.g. Welcome) overflow a small viewport. */ + max-width: calc(100vw - 20px); + max-height: calc(100vh - 74px); +} +/* On very short viewports the default min-height would win over max-height and + push the window off-screen; let it shrink further so it still fits. */ +@media (max-height: 320px) { + .win-window { + min-height: 110px; + } } .win-window.closing { animation: winClose 0.14s ease-in forwards;