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:
Martin Dvořák 2026-09-22 21:02:49 +02:00
commit 6b645ac636
11 changed files with 397 additions and 159 deletions

View file

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

View file

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

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

Before After
Before After

View file

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

View file

@ -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 />}
</> </>
); );
} }

View file

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

View file

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

View file

@ -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 &apos;Peach&apos; 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 &apos;Peach&apos; Pescarin (CC BY-SA 2.5), via
Wikimedia Commons.
</p> </p>
</div> </div>
); );

View file

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

View file

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