mobile: iOS-style widget + animations, 2-click desktop icons, new wallpapers
Mobile: - iOS-style open/close animation for app views (grows from the tapped icon, always slides straight down out of view and shrinks on close) - digital 2x1 clock widget in an iOS-style frosted-glass panel - new phone wallpaper (phonewallpaper.jpg) - larger notch Desktop: - desktop icons and shortcuts now launch on the second click - open/close/launcher animations - windows keep their state when toggling Gruvbox/Desktop themes, and the Welcome window only appears on the first page load Other: - update wallpaper credits to wallpapercave - allow LAN hosts in dev (allowedDevOrigins) so phones can load the dev bundle instead of getting a 403 and an inert page
This commit is contained in:
parent
784334d4be
commit
6b645ac636
11 changed files with 397 additions and 159 deletions
|
|
@ -1,6 +1,10 @@
|
||||||
import { NextConfig } from "next";
|
import { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/i,
|
test: /\.svg$/i,
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,8 @@
|
||||||
"postcss": "^8.5.28",
|
"postcss": "^8.5.28",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"unrs-resolver@1.12.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
public/phonewallpaper.jpg
Normal file
BIN
public/phonewallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 783 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 1 MiB |
|
|
@ -1,9 +1,16 @@
|
||||||
"use client";
|
"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 { APPS, APP_BY_ID } from "./apps";
|
||||||
import Clock from "./Clock";
|
import Clock from "./Clock";
|
||||||
import { WALLPAPER } from "../data";
|
import { MOBILE_WALLPAPER } from "../data";
|
||||||
|
|
||||||
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
||||||
|
|
||||||
|
|
@ -12,34 +19,87 @@ function subscribe(callback: () => void) {
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Digital iOS-style clock widget: big time, small day/date underneath. */
|
||||||
function HomeWidget() {
|
function HomeWidget() {
|
||||||
const date = useSyncExternalStore(
|
const now = useSyncExternalStore(
|
||||||
subscribe,
|
subscribe,
|
||||||
() =>
|
() => {
|
||||||
new Date().toLocaleDateString([], {
|
const d = new Date();
|
||||||
weekday: "long",
|
return [
|
||||||
month: "short",
|
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
day: "numeric",
|
d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" }),
|
||||||
}),
|
].join("\n");
|
||||||
() => ""
|
},
|
||||||
|
() => "\n"
|
||||||
);
|
);
|
||||||
|
const [time, day] = now.split("\n");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mobile-widget">
|
<div className="mobile-widget">
|
||||||
<div className="mobile-widget-time">
|
<div className="mobile-widget-time" suppressHydrationWarning>
|
||||||
<Clock />
|
{time}
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-widget-date" suppressHydrationWarning>
|
<div className="mobile-widget-date" suppressHydrationWarning>
|
||||||
{date}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
<div className="mobile-widget-foot">dvorakmortonxd.dev</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LaunchOrigin {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileHome() {
|
export default function MobileHome() {
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [origin, setOrigin] = useState<LaunchOrigin | null>(null);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
const openApp = openId ? APP_BY_ID[openId] : null;
|
const openApp = openId ? APP_BY_ID[openId] : null;
|
||||||
|
const closeTimer = useRef<number | null>(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<HTMLButtonElement>) => {
|
||||||
|
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 renderIcon = (appId: string, inDock = false) => {
|
||||||
const app = APP_BY_ID[appId];
|
const app = APP_BY_ID[appId];
|
||||||
|
|
@ -49,7 +109,7 @@ export default function MobileHome() {
|
||||||
key={app.id}
|
key={app.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={"mobile-app" + (inDock ? " in-dock" : "")}
|
className={"mobile-app" + (inDock ? " in-dock" : "")}
|
||||||
onClick={() => setOpenId(app.id)}
|
onClick={(e) => launch(app.id, e)}
|
||||||
>
|
>
|
||||||
<span className="mobile-app-icon">
|
<span className="mobile-app-icon">
|
||||||
<span className="mobile-app-glyph">{app.icon}</span>
|
<span className="mobile-app-glyph">{app.icon}</span>
|
||||||
|
|
@ -59,25 +119,23 @@ 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 (
|
return (
|
||||||
<div className="mobile-root">
|
<div className="mobile-root">
|
||||||
{openApp ? (
|
<div
|
||||||
<div className="mobile-app-view">
|
className="mobile-home"
|
||||||
<header className="mobile-appbar">
|
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
|
||||||
<button
|
aria-hidden={openApp ? true : undefined}
|
||||||
type="button"
|
|
||||||
className="mobile-back"
|
|
||||||
onClick={() => setOpenId(null)}
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
>
|
||||||
←
|
|
||||||
</button>
|
|
||||||
<span className="mobile-appbar-title">{openApp.title}</span>
|
|
||||||
</header>
|
|
||||||
<div className="mobile-app-body app-surface">{openApp.content}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mobile-home" style={{ backgroundImage: `url('${WALLPAPER}')` }}>
|
|
||||||
<div className="mobile-notch" aria-hidden="true" />
|
<div className="mobile-notch" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="mobile-statusbar">
|
<div className="mobile-statusbar">
|
||||||
|
|
@ -126,6 +184,33 @@ export default function MobileHome() {
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{openApp && (
|
||||||
|
<div
|
||||||
|
className={"mobile-app-view" + (closing ? " closing" : " opening")}
|
||||||
|
style={viewStyle}
|
||||||
|
onAnimationEnd={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (closing) {
|
||||||
|
clearCloseTimer();
|
||||||
|
setOpenId(null);
|
||||||
|
setClosing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header className="mobile-appbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-back"
|
||||||
|
onClick={closeApp}
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span className="mobile-appbar-title">{openApp.title}</span>
|
||||||
|
</header>
|
||||||
|
<div className="mobile-app-body app-surface">{openApp.content}</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,26 @@ import MobileHome from "./MobileHome";
|
||||||
|
|
||||||
export default function ThemeRoot() {
|
export default function ThemeRoot() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const terminal = theme === "terminal";
|
||||||
|
|
||||||
if (theme === "terminal") {
|
// The desktop is kept mounted (just hidden) while the Gruvbox/terminal theme
|
||||||
return <TerminalLayout />;
|
// is active. That way the Welcome window is only created on a fresh page
|
||||||
}
|
// load and isn't reopened by toggling themes.
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="skeuo-desktop-root">
|
<div
|
||||||
|
className="skeuo-desktop-root"
|
||||||
|
style={terminal ? { display: "none" } : undefined}
|
||||||
|
>
|
||||||
<WindowsDesktop />
|
<WindowsDesktop />
|
||||||
</div>
|
</div>
|
||||||
<div className="skeuo-mobile-root">
|
<div
|
||||||
|
className="skeuo-mobile-root"
|
||||||
|
style={terminal ? { display: "none" } : undefined}
|
||||||
|
>
|
||||||
<MobileHome />
|
<MobileHome />
|
||||||
</div>
|
</div>
|
||||||
|
{terminal && <TerminalLayout />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@ interface Props {
|
||||||
win: WinState;
|
win: WinState;
|
||||||
app: AppDef;
|
app: AppDef;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
closing?: boolean;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onCloseAnimationEnd?: () => void;
|
||||||
onMinimize: () => void;
|
onMinimize: () => void;
|
||||||
onToggleMaximize: () => void;
|
onToggleMaximize: () => void;
|
||||||
onMove: (x: number, y: number) => void;
|
onMove: (x: number, y: number) => void;
|
||||||
|
|
@ -31,8 +33,10 @@ export default function WinWindow({
|
||||||
win,
|
win,
|
||||||
app,
|
app,
|
||||||
active,
|
active,
|
||||||
|
closing,
|
||||||
onFocus,
|
onFocus,
|
||||||
onClose,
|
onClose,
|
||||||
|
onCloseAnimationEnd,
|
||||||
onMinimize,
|
onMinimize,
|
||||||
onToggleMaximize,
|
onToggleMaximize,
|
||||||
onMove,
|
onMove,
|
||||||
|
|
@ -84,10 +88,15 @@ export default function WinWindow({
|
||||||
(active ? " active" : "") +
|
(active ? " active" : "") +
|
||||||
(centered ? " centered" : "") +
|
(centered ? " centered" : "") +
|
||||||
(win.minimized ? " minimized" : "") +
|
(win.minimized ? " minimized" : "") +
|
||||||
(win.maximized ? " maximized" : "")
|
(win.maximized ? " maximized" : "") +
|
||||||
|
(closing ? " closing" : "")
|
||||||
}
|
}
|
||||||
style={style}
|
style={style}
|
||||||
onPointerDown={onFocus}
|
onPointerDown={onFocus}
|
||||||
|
onAnimationEnd={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) return;
|
||||||
|
if (closing) onCloseAnimationEnd?.();
|
||||||
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={app.title}
|
aria-label={app.title}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export default function WindowsDesktop() {
|
||||||
const [activeId, setActiveId] = useState<number | null>(1);
|
const [activeId, setActiveId] = useState<number | null>(1);
|
||||||
const [startOpen, setStartOpen] = useState(false);
|
const [startOpen, setStartOpen] = useState(false);
|
||||||
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
||||||
|
const [closingIds, setClosingIds] = useState<number[]>([]);
|
||||||
|
|
||||||
const idRef = useRef(2);
|
const idRef = useRef(2);
|
||||||
const zRef = useRef(11);
|
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) => {
|
const openApp = (appId: string) => {
|
||||||
setStartOpen(false);
|
setStartOpen(false);
|
||||||
const existing = wins.find((w) => w.appId === appId);
|
const existing = wins.find((w) => w.appId === appId);
|
||||||
|
|
@ -105,8 +117,13 @@ export default function WindowsDesktop() {
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Animate the window out first, then drop it from state.
|
||||||
const closeWin = (id: number) =>
|
const closeWin = (id: number) =>
|
||||||
|
setClosingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||||
|
const finishClose = (id: number) => {
|
||||||
setWins((prev) => prev.filter((w) => w.id !== id));
|
setWins((prev) => prev.filter((w) => w.id !== id));
|
||||||
|
setClosingIds((prev) => prev.filter((x) => x !== id));
|
||||||
|
};
|
||||||
const minimizeWin = (id: number) =>
|
const minimizeWin = (id: number) =>
|
||||||
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
||||||
const toggleMax = (id: number) =>
|
const toggleMax = (id: number) =>
|
||||||
|
|
@ -165,10 +182,7 @@ export default function WindowsDesktop() {
|
||||||
key={app.id}
|
key={app.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={"win-icon" + (selectedIcon === app.id ? " selected" : "")}
|
className={"win-icon" + (selectedIcon === app.id ? " selected" : "")}
|
||||||
onClick={() => {
|
onClick={() => activateIcon(app.id, () => openApp(app.id))}
|
||||||
setSelectedIcon(app.id);
|
|
||||||
openApp(app.id);
|
|
||||||
}}
|
|
||||||
title={`Open ${app.title}`}
|
title={`Open ${app.title}`}
|
||||||
>
|
>
|
||||||
<span className="win-icon-glyph" style={{ background: app.color }}>
|
<span className="win-icon-glyph" style={{ background: app.color }}>
|
||||||
|
|
@ -184,32 +198,24 @@ export default function WindowsDesktop() {
|
||||||
className="win-icons win-icons-tr"
|
className="win-icons win-icons-tr"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{SERVICE_SHORTCUTS.map((s) =>
|
{SERVICE_SHORTCUTS.map((s) => (
|
||||||
s.appId ? (
|
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="win-icon"
|
className={"win-icon" + (selectedIcon === s.id ? " selected" : "")}
|
||||||
title={s.label}
|
title={s.label}
|
||||||
onClick={() => openApp(s.appId as string)}
|
onClick={() =>
|
||||||
|
activateIcon(s.id, () =>
|
||||||
|
s.appId
|
||||||
|
? openApp(s.appId)
|
||||||
|
: window.open(s.url, "_blank", "noopener,noreferrer")
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="win-icon-glyph">{s.icon}</span>
|
<span className="win-icon-glyph">{s.icon}</span>
|
||||||
<span className="win-icon-label">{s.label}</span>
|
<span className="win-icon-label">{s.label}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
))}
|
||||||
<a
|
|
||||||
key={s.id}
|
|
||||||
href={s.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="win-icon"
|
|
||||||
title={s.label}
|
|
||||||
>
|
|
||||||
<span className="win-icon-glyph">{s.icon}</span>
|
|
||||||
<span className="win-icon-label">{s.label}</span>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Friends - bottom right */}
|
{/* Friends - bottom right */}
|
||||||
|
|
@ -218,19 +224,22 @@ export default function WindowsDesktop() {
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{FRIEND_SHORTCUTS.map((f) => (
|
{FRIEND_SHORTCUTS.map((f) => (
|
||||||
<a
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
href={f.url}
|
type="button"
|
||||||
target="_blank"
|
className={"win-icon" + (selectedIcon === f.id ? " selected" : "")}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="win-icon"
|
|
||||||
title={f.label}
|
title={f.label}
|
||||||
|
onClick={() =>
|
||||||
|
activateIcon(f.id, () =>
|
||||||
|
window.open(f.url, "_blank", "noopener,noreferrer")
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="win-icon-glyph">
|
<span className="win-icon-glyph">
|
||||||
<Image src={f.img} alt={f.label} width={38} height={38} className="win-icon-img" />
|
<Image src={f.img} alt={f.label} width={38} height={38} className="win-icon-img" />
|
||||||
</span>
|
</span>
|
||||||
<span className="win-icon-label">{f.label}</span>
|
<span className="win-icon-label">{f.label}</span>
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -242,8 +251,10 @@ export default function WindowsDesktop() {
|
||||||
win={w}
|
win={w}
|
||||||
app={APP_BY_ID[w.appId]}
|
app={APP_BY_ID[w.appId]}
|
||||||
active={activeId === w.id}
|
active={activeId === w.id}
|
||||||
|
closing={closingIds.includes(w.id)}
|
||||||
onFocus={() => focus(w.id)}
|
onFocus={() => focus(w.id)}
|
||||||
onClose={() => closeWin(w.id)}
|
onClose={() => closeWin(w.id)}
|
||||||
|
onCloseAnimationEnd={() => finishClose(w.id)}
|
||||||
onMinimize={() => minimizeWin(w.id)}
|
onMinimize={() => minimizeWin(w.id)}
|
||||||
onToggleMaximize={() => toggleMax(w.id)}
|
onToggleMaximize={() => toggleMax(w.id)}
|
||||||
onMove={(x, y) => moveWin(w.id, x, y)}
|
onMove={(x, y) => moveWin(w.id, x, y)}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
techProjects,
|
techProjects,
|
||||||
friends,
|
friends,
|
||||||
WALLPAPER_CREDIT,
|
WALLPAPER_CREDIT,
|
||||||
|
MOBILE_WALLPAPER_CREDIT,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import ServiceButton from "./ServiceButton";
|
import ServiceButton from "./ServiceButton";
|
||||||
import ContributionHeatmap from "./ContributionHeatmap";
|
import ContributionHeatmap from "./ContributionHeatmap";
|
||||||
|
|
@ -59,10 +60,14 @@ function WelcomeApp() {
|
||||||
<p className="app-muted app-fineprint">
|
<p className="app-muted app-fineprint">
|
||||||
Wallpaper from{" "}
|
Wallpaper from{" "}
|
||||||
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||||
wallpaperaccess.com/open-source
|
wallpapercave.com/kde-plasma-wallpapers
|
||||||
</a>
|
</a>{" "}
|
||||||
. Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via Wikimedia
|
(desktop) and{" "}
|
||||||
Commons.
|
<a href={MOBILE_WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||||
|
wallpapercave.com/landscape-4k-mobile-wallpapers
|
||||||
|
</a>{" "}
|
||||||
|
(mobile). Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via
|
||||||
|
Wikimedia Commons.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -258,4 +258,7 @@ export const friends: Friend[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const WALLPAPER = "/wallpaper.jpg";
|
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";
|
||||||
|
|
|
||||||
|
|
@ -81,29 +81,35 @@ html[data-device="phone"] .skeuo-mobile-root { display: block; }
|
||||||
}
|
}
|
||||||
.win-icon {
|
.win-icon {
|
||||||
width: 92px;
|
width: 92px;
|
||||||
padding: 6px 4px 7px;
|
padding: 6px 4px 9px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
|
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:hover,
|
||||||
.win-icon.selected {
|
.win-icon.selected {
|
||||||
background: rgba(61, 174, 233, 0.16);
|
background: rgba(61, 174, 233, 0.16);
|
||||||
border-color: rgba(61, 174, 233, 0.4);
|
border-color: rgba(61, 174, 233, 0.4);
|
||||||
}
|
}
|
||||||
|
.win-icon:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
.win-icon-glyph {
|
.win-icon-glyph {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
|
line-height: 1;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 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);
|
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
|
animation: winOpen 0.16s ease-out;
|
||||||
}
|
}
|
||||||
.win-window.active {
|
.win-window.active {
|
||||||
border-color: #3a444d;
|
border-color: #3a444d;
|
||||||
|
|
@ -180,9 +187,36 @@ a.win-icon {
|
||||||
height: calc(100% - 54px) !important;
|
height: calc(100% - 54px) !important;
|
||||||
}
|
}
|
||||||
.win-window.centered {
|
.win-window.centered {
|
||||||
left: 50%;
|
left: 0;
|
||||||
top: calc(50% - 27px);
|
right: 0;
|
||||||
transform: translate(-50%, -50%);
|
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 {
|
.win-titlebar {
|
||||||
|
|
@ -979,6 +1013,18 @@ a.win-icon {
|
||||||
backdrop-filter: blur(28px) saturate(150%);
|
backdrop-filter: blur(28px) saturate(150%);
|
||||||
color: #eff0f1;
|
color: #eff0f1;
|
||||||
overflow: hidden;
|
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 {
|
.kickoff-content {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
|
|
@ -1072,20 +1118,20 @@ a.win-icon {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 132px;
|
width: 164px;
|
||||||
height: 27px;
|
height: 34px;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
background: #000;
|
background: #000;
|
||||||
border-radius: 0 0 18px 18px;
|
border-radius: 0 0 22px 22px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.mobile-notch::after {
|
.mobile-notch::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 22px;
|
right: 26px;
|
||||||
top: 9px;
|
top: 11px;
|
||||||
width: 7px;
|
width: 9px;
|
||||||
height: 7px;
|
height: 9px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle at 35% 30%, #2b3a55, #05070b 70%);
|
background: radial-gradient(circle at 35% 30%, #2b3a55, #05070b 70%);
|
||||||
}
|
}
|
||||||
|
|
@ -1117,49 +1163,47 @@ a.win-icon {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 2x1 iOS-style clock widget */
|
||||||
.mobile-widget {
|
.mobile-widget {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
grid-row: span 2;
|
grid-row: span 1;
|
||||||
border-radius: 26px;
|
border-radius: 22px;
|
||||||
padding: 14px 16px;
|
padding: 10px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
background-color: rgba(28, 30, 38, 0.42);
|
||||||
background-color: rgba(24, 26, 32, 0.62);
|
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
155deg,
|
160deg,
|
||||||
rgba(255, 255, 255, 0.24),
|
rgba(255, 255, 255, 0.22),
|
||||||
rgba(255, 255, 255, 0.05) 46%,
|
rgba(255, 255, 255, 0.06) 52%,
|
||||||
rgba(0, 0, 0, 0.3)
|
rgba(0, 0, 0, 0.18)
|
||||||
);
|
);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.45),
|
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||||
0 10px 26px rgba(0, 0, 0, 0.4);
|
0 6px 18px rgba(0, 0, 0, 0.32);
|
||||||
-webkit-backdrop-filter: blur(18px) saturate(150%);
|
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||||
backdrop-filter: blur(18px) saturate(150%);
|
backdrop-filter: blur(20px) saturate(150%);
|
||||||
}
|
}
|
||||||
.mobile-widget-time {
|
.mobile-widget-time {
|
||||||
font-size: 2.5rem;
|
font-size: 1.65rem;
|
||||||
font-weight: 300;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1.05;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
.mobile-widget-date {
|
.mobile-widget-date {
|
||||||
font-size: 0.78rem;
|
font-size: 0.66rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 0.95;
|
line-height: 1.1;
|
||||||
}
|
letter-spacing: 0.03em;
|
||||||
.mobile-widget-foot {
|
text-transform: uppercase;
|
||||||
margin-top: 4px;
|
opacity: 0.75;
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-grid {
|
.mobile-grid {
|
||||||
|
|
@ -1308,6 +1352,57 @@ a.win-icon {
|
||||||
background: #fef7ff;
|
background: #fef7ff;
|
||||||
color: #1d1b20;
|
color: #1d1b20;
|
||||||
font-family: Roboto, "Helvetica Neue", Arial, sans-serif;
|
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 {
|
.mobile-appbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
@ -1400,11 +1495,27 @@ a.win-icon {
|
||||||
.mobile-app { gap: 3px; }
|
.mobile-app { gap: 3px; }
|
||||||
.mobile-app-icon { width: 42px; height: 42px; border-radius: 15px; font-size: 20px; }
|
.mobile-app-icon { width: 42px; height: 42px; border-radius: 15px; font-size: 20px; }
|
||||||
.mobile-app-label { font-size: 0.58rem; }
|
.mobile-app-label { font-size: 0.58rem; }
|
||||||
.mobile-widget { border-radius: 20px; padding: 10px 12px; }
|
.mobile-widget { border-radius: 16px; padding: 6px 10px; }
|
||||||
.mobile-widget-time { font-size: 1.6rem; }
|
.mobile-widget-time { font-size: 1.1rem; }
|
||||||
.mobile-widget-date { font-size: 0.66rem; }
|
.mobile-widget-date { font-size: 0.55rem; }
|
||||||
.mobile-widget-foot { display: none; }
|
|
||||||
.mobile-dock { bottom: 14px; padding: 7px 12px; border-radius: 24px; }
|
.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-app.in-dock .mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
|
||||||
.mobile-pagedots { display: none; }
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue