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 ? (
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {APPS.map((app) => renderIcon(app.id))}
+
+
+
{DOCK_IDS.map((id) => renderIcon(id, true))}
+
+
+
+
+
+
+
+
+ {openApp && (
+
{
+ if (e.target !== e.currentTarget) return;
+ if (closing) {
+ clearCloseTimer();
+ setOpenId(null);
+ setClosing(false);
+ }
+ }}
+ >
setOpenId(null)}
+ onClick={closeApp}
aria-label="Back"
>
←
@@ -76,56 +211,6 @@ export default function MobileHome() {
{openApp.content}
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {APPS.map((app) => renderIcon(app.id))}
-
-
-
{DOCK_IDS.map((id) => renderIcon(id, true))}
-
-
-
-
-
-
-
)}
);
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 ? (
- openApp(s.appId as string)}
- >
- {s.icon}
- {s.label}
-
- ) : (
-
- {s.icon}
- {s.label}
-
- )
- )}
+ {SERVICE_SHORTCUTS.map((s) => (
+
+ activateIcon(s.id, () =>
+ s.appId
+ ? openApp(s.appId)
+ : window.open(s.url, "_blank", "noopener,noreferrer")
+ )
+ }
+ >
+ {s.icon}
+ {s.label}
+
+ ))}
{/* 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}
-
+
))}
@@ -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;
+ }
+}