responsive desktop: shrink icons on cramped viewports so they never collide; clamp windows to the viewport
This commit is contained in:
parent
9695452974
commit
194501e467
4 changed files with 136 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
opencode.md
10
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 `<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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue