responsive desktop: shrink icons on cramped viewports so they never collide; clamp windows to the viewport

This commit is contained in:
Martin Dvořák 2026-09-24 12:48:01 +00:00
commit 194501e467
4 changed files with 136 additions and 9 deletions

View file

@ -24,11 +24,13 @@ heatmap**, a gym SBD panel and links to friends' sites.
- **Phone vs. desktop detection** — a user-agent sniff on `<html data-device>` 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

View file

@ -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 `<img>`, 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

View file

@ -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) =>

View file

@ -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;