diff --git a/next.config.ts b/next.config.ts index c38a51c..ecc62e0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,10 @@ import { NextConfig } from "next"; const nextConfig: NextConfig = { + // Allow testing the dev server from other devices on the LAN (e.g. a phone). + // Without this Next.js blocks cross-origin requests to /_next/* dev assets + // with a 403, which stops the JS from loading and makes the page inert. + allowedDevOrigins: ["192.168.10.124", "192.168.*.*", "10.*.*.*"], webpack(config) { config.module.rules.push({ test: /\.svg$/i, diff --git a/package.json b/package.json index 342b42c..a890f6c 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,8 @@ "postcss": "^8.5.28", "tailwindcss": "^4.3.3", "typescript": "^5.9.3" + }, + "allowScripts": { + "unrs-resolver@1.12.2": true } } diff --git a/public/phonewallpaper.jpg b/public/phonewallpaper.jpg new file mode 100644 index 0000000..6ef7252 Binary files /dev/null and b/public/phonewallpaper.jpg differ diff --git a/public/wallpaper.jpg b/public/wallpaper.jpg index 88e7a3a..a26e84b 100644 Binary files a/public/wallpaper.jpg and b/public/wallpaper.jpg differ diff --git a/src/app/components/MobileHome.tsx b/src/app/components/MobileHome.tsx index 757e009..33d1f88 100644 --- a/src/app/components/MobileHome.tsx +++ b/src/app/components/MobileHome.tsx @@ -1,9 +1,16 @@ "use client"; -import { useState, useSyncExternalStore } from "react"; +import { + useEffect, + useRef, + useState, + useSyncExternalStore, + type CSSProperties, + type MouseEvent, +} from "react"; import { APPS, APP_BY_ID } from "./apps"; import Clock from "./Clock"; -import { WALLPAPER } from "../data"; +import { MOBILE_WALLPAPER } from "../data"; const DOCK_IDS = ["projects", "services", "socials", "contributions"]; @@ -12,34 +19,87 @@ function subscribe(callback: () => void) { return () => window.clearInterval(id); } +/** Digital iOS-style clock widget: big time, small day/date underneath. */ function HomeWidget() { - const date = useSyncExternalStore( + const now = useSyncExternalStore( subscribe, - () => - new Date().toLocaleDateString([], { - weekday: "long", - month: "short", - day: "numeric", - }), - () => "" + () => { + const d = new Date(); + return [ + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), + d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" }), + ].join("\n"); + }, + () => "\n" ); + const [time, day] = now.split("\n"); return (
-
- +
+ {time}
- {date} + {day}
-
dvorakmortonxd.dev
); } +interface LaunchOrigin { + x: number; + y: number; + scale: number; +} + export default function MobileHome() { const [openId, setOpenId] = useState(null); + const [origin, setOrigin] = useState(null); + const [closing, setClosing] = useState(false); const openApp = openId ? APP_BY_ID[openId] : null; + const closeTimer = useRef(null); + + const clearCloseTimer = () => { + if (closeTimer.current !== null) { + window.clearTimeout(closeTimer.current); + closeTimer.current = null; + } + }; + + // Fallback for when the close animation never reports completion (e.g. + // reduced-motion, throttled tabs or a missed animationend). Without this the + // full-screen app view can linger and swallow every tap on the home screen. + const closeApp = () => { + setClosing(true); + clearCloseTimer(); + closeTimer.current = window.setTimeout(() => { + setOpenId(null); + setClosing(false); + closeTimer.current = null; + }, 600); + }; + + useEffect(() => () => clearCloseTimer(), []); + + // Compute a transform-origin so the scaled-down app view sits exactly on top + // of the tapped icon, then let it grow to fill the screen (iOS style). + const launch = (appId: string, e: MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const icon = rect.width > 0 ? rect.width : rect.height; + // Guard against a zero/degenerate measured icon (e.g. hidden layout) so the + // scale tween never collapses to 0 and the open animation can always run. + const scale = icon > 0 ? Math.min(icon / window.innerWidth, 0.95) : 0.2; + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const denom = 1 - scale || 1; + setOrigin({ + x: (cx - (scale * window.innerWidth) / 2) / denom, + y: (cy - (scale * window.innerHeight) / 2) / denom, + scale, + }); + setClosing(false); + setOpenId(appId); + }; const renderIcon = (appId: string, inDock = false) => { const app = APP_BY_ID[appId]; @@ -49,7 +109,7 @@ export default function MobileHome() { key={app.id} type="button" className={"mobile-app" + (inDock ? " in-dock" : "")} - onClick={() => setOpenId(app.id)} + onClick={(e) => launch(app.id, e)} > {app.icon} @@ -59,15 +119,90 @@ export default function MobileHome() { ); }; + // The transform origin is exposed as a CSS variable (not an inline + // transform-origin) so only the opening keyframe uses the tapped icon as its + // pivot. The closing animation keeps a centred origin and always slides down. + const viewStyle = origin + ? ({ + "--app-origin": `${origin.x}px ${origin.y}px`, + "--app-scale": String(origin.scale), + } as CSSProperties) + : undefined; + return (
- {openApp ? ( -
+
+ + + {openApp && ( +
{ + if (e.target !== e.currentTarget) return; + if (closing) { + clearCloseTimer(); + setOpenId(null); + setClosing(false); + } + }} + >
{openApp.content}
- ) : ( -
- )}
); diff --git a/src/app/components/ThemeRoot.tsx b/src/app/components/ThemeRoot.tsx index c1df1d5..8b49383 100644 --- a/src/app/components/ThemeRoot.tsx +++ b/src/app/components/ThemeRoot.tsx @@ -7,19 +7,26 @@ import MobileHome from "./MobileHome"; export default function ThemeRoot() { const { theme } = useTheme(); + const terminal = theme === "terminal"; - if (theme === "terminal") { - return ; - } - + // The desktop is kept mounted (just hidden) while the Gruvbox/terminal theme + // is active. That way the Welcome window is only created on a fresh page + // load and isn't reopened by toggling themes. return ( <> -
+
-
+
+ {terminal && } ); } diff --git a/src/app/components/WinWindow.tsx b/src/app/components/WinWindow.tsx index d3bc6c8..b4461a3 100644 --- a/src/app/components/WinWindow.tsx +++ b/src/app/components/WinWindow.tsx @@ -20,8 +20,10 @@ interface Props { win: WinState; app: AppDef; active: boolean; + closing?: boolean; onFocus: () => void; onClose: () => void; + onCloseAnimationEnd?: () => void; onMinimize: () => void; onToggleMaximize: () => void; onMove: (x: number, y: number) => void; @@ -31,8 +33,10 @@ export default function WinWindow({ win, app, active, + closing, onFocus, onClose, + onCloseAnimationEnd, onMinimize, onToggleMaximize, onMove, @@ -84,10 +88,15 @@ export default function WinWindow({ (active ? " active" : "") + (centered ? " centered" : "") + (win.minimized ? " minimized" : "") + - (win.maximized ? " maximized" : "") + (win.maximized ? " maximized" : "") + + (closing ? " closing" : "") } style={style} onPointerDown={onFocus} + onAnimationEnd={(e) => { + if (e.target !== e.currentTarget) return; + if (closing) onCloseAnimationEnd?.(); + }} role="dialog" aria-label={app.title} > diff --git a/src/app/components/WindowsDesktop.tsx b/src/app/components/WindowsDesktop.tsx index 5f63c01..a627b63 100644 --- a/src/app/components/WindowsDesktop.tsx +++ b/src/app/components/WindowsDesktop.tsx @@ -54,6 +54,7 @@ export default function WindowsDesktop() { const [activeId, setActiveId] = useState(1); const [startOpen, setStartOpen] = useState(false); const [selectedIcon, setSelectedIcon] = useState(null); + const [closingIds, setClosingIds] = useState([]); const idRef = useRef(2); const zRef = useRef(11); @@ -65,6 +66,17 @@ export default function WindowsDesktop() { ); }; + // Desktop icons behave like a real desktop: the first click selects the + // icon, a second click on the same icon launches it. + const activateIcon = (id: string, action: () => void) => { + if (selectedIcon === id) { + setSelectedIcon(null); + action(); + } else { + setSelectedIcon(id); + } + }; + const openApp = (appId: string) => { setStartOpen(false); const existing = wins.find((w) => w.appId === appId); @@ -105,8 +117,13 @@ export default function WindowsDesktop() { setActiveId(id); }; + // Animate the window out first, then drop it from state. const closeWin = (id: number) => + setClosingIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + const finishClose = (id: number) => { setWins((prev) => prev.filter((w) => w.id !== id)); + setClosingIds((prev) => prev.filter((x) => x !== id)); + }; const minimizeWin = (id: number) => setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w))); const toggleMax = (id: number) => @@ -165,10 +182,7 @@ export default function WindowsDesktop() { key={app.id} type="button" className={"win-icon" + (selectedIcon === app.id ? " selected" : "")} - onClick={() => { - setSelectedIcon(app.id); - openApp(app.id); - }} + onClick={() => activateIcon(app.id, () => openApp(app.id))} title={`Open ${app.title}`} > @@ -184,32 +198,24 @@ export default function WindowsDesktop() { className="win-icons win-icons-tr" onPointerDown={(e) => e.stopPropagation()} > - {SERVICE_SHORTCUTS.map((s) => - s.appId ? ( - - ) : ( - - {s.icon} - {s.label} - - ) - )} + {SERVICE_SHORTCUTS.map((s) => ( + + ))}
{/* Friends - bottom right */} @@ -218,19 +224,22 @@ export default function WindowsDesktop() { onPointerDown={(e) => e.stopPropagation()} > {FRIEND_SHORTCUTS.map((f) => ( - + activateIcon(f.id, () => + window.open(f.url, "_blank", "noopener,noreferrer") + ) + } > {f.label} {f.label} - + ))}
@@ -242,8 +251,10 @@ export default function WindowsDesktop() { win={w} app={APP_BY_ID[w.appId]} active={activeId === w.id} + closing={closingIds.includes(w.id)} onFocus={() => focus(w.id)} onClose={() => closeWin(w.id)} + onCloseAnimationEnd={() => finishClose(w.id)} onMinimize={() => minimizeWin(w.id)} onToggleMaximize={() => toggleMax(w.id)} onMove={(x, y) => moveWin(w.id, x, y)} diff --git a/src/app/components/apps.tsx b/src/app/components/apps.tsx index a44b4ed..fe76fe7 100644 --- a/src/app/components/apps.tsx +++ b/src/app/components/apps.tsx @@ -8,6 +8,7 @@ import { techProjects, friends, WALLPAPER_CREDIT, + MOBILE_WALLPAPER_CREDIT, } from "../data"; import ServiceButton from "./ServiceButton"; import ContributionHeatmap from "./ContributionHeatmap"; @@ -59,10 +60,14 @@ function WelcomeApp() {

Wallpaper from{" "} - wallpaperaccess.com/open-source - - . Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via Wikimedia - Commons. + wallpapercave.com/kde-plasma-wallpapers + {" "} + (desktop) and{" "} + + wallpapercave.com/landscape-4k-mobile-wallpapers + {" "} + (mobile). Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via + Wikimedia Commons.

); diff --git a/src/app/data.ts b/src/app/data.ts index 48925ff..8456d02 100644 --- a/src/app/data.ts +++ b/src/app/data.ts @@ -258,4 +258,7 @@ export const friends: Friend[] = [ ]; export const WALLPAPER = "/wallpaper.jpg"; -export const WALLPAPER_CREDIT = "https://wallpaperaccess.com/open-source"; +export const WALLPAPER_CREDIT = "https://wallpapercave.com/kde-plasma-wallpapers"; +export const MOBILE_WALLPAPER = "/phonewallpaper.jpg"; +export const MOBILE_WALLPAPER_CREDIT = + "https://wallpapercave.com/landscape-4k-mobile-wallpapers"; diff --git a/src/app/skeuo.css b/src/app/skeuo.css index f2a99a4..244d60e 100644 --- a/src/app/skeuo.css +++ b/src/app/skeuo.css @@ -81,29 +81,35 @@ html[data-device="phone"] .skeuo-mobile-root { display: block; } } .win-icon { width: 92px; - padding: 6px 4px 7px; + padding: 6px 4px 9px; display: flex; flex-direction: column; align-items: center; - gap: 4px; + gap: 8px; border: 1px solid transparent; border-radius: 4px; background: transparent; color: #ffffff; cursor: default; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95); + transition: transform 0.12s ease, background-color 0.12s ease, + border-color 0.12s ease; } .win-icon:hover, .win-icon.selected { background: rgba(61, 174, 233, 0.16); border-color: rgba(61, 174, 233, 0.4); } +.win-icon:active { + transform: scale(0.9); +} .win-icon-glyph { width: 48px; height: 48px; display: grid; place-items: center; font-size: 40px; + line-height: 1; background: transparent !important; border: none; box-shadow: none; @@ -167,6 +173,7 @@ a.win-icon { box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04); overflow: hidden; user-select: text; + animation: winOpen 0.16s ease-out; } .win-window.active { border-color: #3a444d; @@ -180,9 +187,36 @@ a.win-icon { height: calc(100% - 54px) !important; } .win-window.centered { - left: 50%; - top: calc(50% - 27px); - transform: translate(-50%, -50%); + left: 0; + right: 0; + top: 0; + bottom: 54px; + margin: auto; +} +.win-window.closing { + animation: winClose 0.14s ease-in forwards; + pointer-events: none; +} + +@keyframes winOpen { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes winClose { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.95); + } } .win-titlebar { @@ -979,6 +1013,18 @@ a.win-icon { backdrop-filter: blur(28px) saturate(150%); color: #eff0f1; overflow: hidden; + transform-origin: bottom left; + animation: kickoffIn 0.16s ease-out; +} +@keyframes kickoffIn { + from { + opacity: 0; + transform: translateY(10px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .kickoff-content { max-height: 60vh; @@ -1072,20 +1118,20 @@ a.win-icon { top: 0; left: 50%; transform: translateX(-50%); - width: 132px; - height: 27px; + width: 164px; + height: 34px; z-index: 60; background: #000; - border-radius: 0 0 18px 18px; + border-radius: 0 0 22px 22px; pointer-events: none; } .mobile-notch::after { content: ""; position: absolute; - right: 22px; - top: 9px; - width: 7px; - height: 7px; + right: 26px; + top: 11px; + width: 9px; + height: 9px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #2b3a55, #05070b 70%); } @@ -1117,49 +1163,47 @@ a.win-icon { display: block; } +/* 2x1 iOS-style clock widget */ .mobile-widget { grid-column: span 2; - grid-row: span 2; - border-radius: 26px; - padding: 14px 16px; + grid-row: span 1; + border-radius: 22px; + padding: 10px 16px; display: flex; flex-direction: column; justify-content: center; - gap: 2px; + gap: 1px; overflow: hidden; color: #ffffff; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45); - border: 1px solid rgba(255, 255, 255, 0.28); - background-color: rgba(24, 26, 32, 0.62); + border: 1px solid rgba(255, 255, 255, 0.22); + background-color: rgba(28, 30, 38, 0.42); background-image: linear-gradient( - 155deg, - rgba(255, 255, 255, 0.24), - rgba(255, 255, 255, 0.05) 46%, - rgba(0, 0, 0, 0.3) + 160deg, + rgba(255, 255, 255, 0.22), + rgba(255, 255, 255, 0.06) 52%, + rgba(0, 0, 0, 0.18) ); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.45), - 0 10px 26px rgba(0, 0, 0, 0.4); - -webkit-backdrop-filter: blur(18px) saturate(150%); - backdrop-filter: blur(18px) saturate(150%); + inset 0 1px 0 rgba(255, 255, 255, 0.35), + 0 6px 18px rgba(0, 0, 0, 0.32); + -webkit-backdrop-filter: blur(20px) saturate(150%); + backdrop-filter: blur(20px) saturate(150%); } .mobile-widget-time { - font-size: 2.5rem; - font-weight: 300; - line-height: 1; + font-size: 1.65rem; + font-weight: 600; + line-height: 1.05; letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); } .mobile-widget-date { - font-size: 0.78rem; + font-size: 0.66rem; font-weight: 500; - opacity: 0.95; -} -.mobile-widget-foot { - margin-top: 4px; - font-size: 0.62rem; - font-weight: 400; - letter-spacing: 0.02em; - opacity: 0.72; + line-height: 1.1; + letter-spacing: 0.03em; + text-transform: uppercase; + opacity: 0.75; } .mobile-grid { @@ -1308,6 +1352,57 @@ a.win-icon { background: #fef7ff; color: #1d1b20; font-family: Roboto, "Helvetica Neue", Arial, sans-serif; + will-change: transform, opacity, border-radius; + backface-visibility: hidden; +} +.mobile-app-view.opening { + animation: mobileAppOpen 0.42s cubic-bezier(0.22, 1, 0.36, 1) both; + /* Grow out of the tapped icon. */ + transform-origin: var(--app-origin, 50% 50%); +} +.mobile-app-view.closing { + animation: mobileAppClose 0.36s cubic-bezier(0.4, 0, 0.7, 0.2) forwards; + pointer-events: none; + /* Always shrink straight down from the centre, never toward the icon. */ + transform-origin: 50% 50%; +} +.mobile-app-view .mobile-appbar, +.mobile-app-view .mobile-app-body { + animation: mobileAppContentIn 0.3s ease-out both; +} + +@keyframes mobileAppOpen { + from { + transform: scale(var(--app-scale, 0.2)); + border-radius: 27px; + } + to { + transform: scale(1); + border-radius: 0; + } +} +@keyframes mobileAppClose { + from { + translate: 0 0; + scale: 1; + border-radius: 0; + opacity: 1; + } + to { + /* One full viewport height guarantees it clears the bottom edge entirely. */ + translate: 0 100vh; + scale: var(--app-scale, 0.2); + border-radius: 27px; + opacity: 0; + } +} +@keyframes mobileAppContentIn { + from { + opacity: 0; + } + to { + opacity: 1; + } } .mobile-appbar { flex: 0 0 auto; @@ -1400,11 +1495,27 @@ a.win-icon { .mobile-app { gap: 3px; } .mobile-app-icon { width: 42px; height: 42px; border-radius: 15px; font-size: 20px; } .mobile-app-label { font-size: 0.58rem; } - .mobile-widget { border-radius: 20px; padding: 10px 12px; } - .mobile-widget-time { font-size: 1.6rem; } - .mobile-widget-date { font-size: 0.66rem; } - .mobile-widget-foot { display: none; } + .mobile-widget { border-radius: 16px; padding: 6px 10px; } + .mobile-widget-time { font-size: 1.1rem; } + .mobile-widget-date { font-size: 0.55rem; } .mobile-dock { bottom: 14px; padding: 7px 12px; border-radius: 24px; } .mobile-app.in-dock .mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; } .mobile-pagedots { display: none; } } + +/* Respect reduced-motion preferences without breaking animationend callbacks + (a near-zero duration still fires them so closing windows/apps unmount). */ +@media (prefers-reduced-motion: reduce) { + .win-window, + .win-window.closing, + .kickoff, + .mobile-app-view.opening, + .mobile-app-view.closing, + .mobile-app-view .mobile-appbar, + .mobile-app-view .mobile-app-body { + animation-duration: 0.01ms !important; + } + .win-icon { + transition: none; + } +}