revert to the state before the editorial rework (8669263): restore the terminal layout, phone home screen and pre-rework assets

This undoes 8669263..1880a5b as a single revert commit; the reverted work
remains reachable in history at 1880a5b.
This commit is contained in:
Martin Dvořák 2026-09-30 10:02:47 +00:00
commit b2244da743
24 changed files with 1693 additions and 3108 deletions

View file

@ -2,38 +2,27 @@
> The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**. > The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**.
It's a **cartoon-skeuomorphic personal site**: a warm cream desk with chunky, brightly coloured This is not a normal portfolio page. It ships **two fully interactive shells** and picks
hardware resting on it, drawn entirely in CSS — no textures, no images — with the whole KDE which one to show based on the saved theme and the device, all from the same content:
desktop simulation kept behind a physical power switch. Two modes, one content set:
- **Web** (default) — the desk. A monitor carries the headline with a candy-keycap keyboard in - **`skeuo`** (default) — a KDE Plasma / Windows-style **desktop simulation**: draggable
front of it and a mini rack beside it; services are 1U rack units with coloured strips and snap-to-grid icons, resizable/floating windows, a Start menu, taskbar and live clock.
health LEDs, the 18 coding projects are labelled drives in a drawer, the machines are cards On phones it becomes an **iOS-style home screen** with a widget grid, dock, drag-to-reorder
pinned to a cork board, the contribution heatmap is printed on a receipt, friends are business and an iOS launch animation.
cards, the gym numbers are a scoreboard and the socials are a patch panel with coloured ports. - **`terminal`** — a **Gruvbox TUI** version of the site: TUI panels, an animated ASCII
[`/credits`](https://dvorakmortonxd.dev/credits) lists the type and nothing else — there are no train background, a clickable easter-egg logo, and a working in-browser terminal (Alacritty)
asset licences because there are no assets. with commands like `help`, `ls`, `cat`, `neofetch`, `emerge`, `ssh` and more.
- **Desktop mode** — the KDE Plasma / Windows-style **desktop simulation**: draggable
snap-to-grid icons, resizable/floating windows, a Start menu, taskbar, live clock and a
working in-browser terminal (Alacritty). Reached from the power switch and hidden on phones.
Both modes surface the same personal content: socials, self-hosted services with **live Both shells surface the same personal content: socials, self-hosted services with **live
up/down health checks**, a hardware collection, coding projects, a **GitDXD contribution up/down health checks**, a hardware collection, coding projects, a **GitDXD contribution
heatmap**, gym SBD and links to friends' sites. heatmap**, a gym SBD panel and links to friends' sites.
## ✨ Features ## ✨ Features
- **Web theme is the landing view** — the site always opens on the desk; the theme is - **Two themes / shells** — KDE-style desktop and Gruvbox terminal, toggled from either UI
deliberately not persisted, so a reload never drops you into the desktop. and persisted in `localStorage` (applied pre-paint to avoid a flash).
- **CSS-only skeuomorphism** — every surface is a flat gradient with a thick bottom edge, a soft - **Phone vs. desktop detection** — a user-agent sniff on `<html data-device>` decides whether
cast shadow and `color-mix()` tints: slate chassis, cream plastic, paper, cork, black glass, the desktop or the mobile home screen is shown.
LEDs, screws, pushpins. The web theme ships no images at all.
- **Not afraid of colour** — a hue per item (orange / teal / blue / lilac / mint / coral /
yellow) cycles through every list, so the desk reads as separate pieces of hardware instead of
one grey wall.
- **Desktop mode (desktop-only)** — a physical power switch in the top rail (and in the footer)
flips to the KDE-style desktop for the session. Phones never get it: the switch is hidden, so
the old iOS-style phone home screen is gone.
- **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per - **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
collide. collide.
@ -69,17 +58,13 @@ heatmap**, gym SBD and links to friends' sites.
- **Framework:** Next.js 16 (App Router, Turbopack) - **Framework:** Next.js 16 (App Router, Turbopack)
- **Language:** TypeScript, React 19 (client components everywhere interactivity lives) - **Language:** TypeScript, React 19 (client components everywhere interactivity lives)
- **Styling:** `src/app/globals.css` holds the palette tokens, base styles, the Tailwind v4 - **Styling:** hand-written CSS — `src/app/page.css` (terminal theme), `src/app/skeuo.css`
`@theme`/`@utility` classes and the scroll reveals. `src/app/web.css` is the whole desk: the (desktop + mobile), `src/app/globals.css` (base/reset). Tailwind v4 is imported for the
materials (slate chassis, cream plastic, paper, cork, black glass), the details (screws, LEDs, base layer but the UI is custom CSS.
pushpins, rack rails, barcode, torn receipt edge) and every object layout, all CSS with - **Fonts:** Monocraft (self-hosted in `public/`, used by the terminal theme; the desktop
`color-mix()` for tints and shades. `page.css` keeps the shared component styles the desktop theme uses the system sans stack)
apps still use, and `skeuo.css` is the KDE desktop. - **Images:** `next/image`
- **Fonts:** Nunito for hardware labels and headings, Newsreader for anything printed on paper, - **Version:** `2.1.0` (see `package.json`)
IBM Plex Sans/Mono for body copy and terminal text — all via `next/font/google`. The desktop
keeps its system sans stack.
- **Assets:** the web theme ships **no images**. The desktop keeps its own wallpaper and icons.
- **Version:** `4.0.0` (see `package.json`)
## 🚀 Running Locally ## 🚀 Running Locally
@ -179,35 +164,33 @@ they contend for `:3000` and `.next/dev/lock`.
``` ```
src/app/ src/app/
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets page.tsx → renders <ThemeRoot/>, imports the two theme stylesheets
layout.tsx → html shell + metadata; loads the fonts; injects ThemeScript layout.tsx → html shell + metadata, injects ThemeScript
globals.css → palette tokens, base, Tailwind @theme/@utility, scroll reveals globals.css → base/reset styles + Tailwind import
web.css → skeuomorphic material system + object layouts page.css → terminal theme styles
page.css → shared component styles still used by the desktop apps skeuo.css → desktop + mobile home styles
skeuo.css → KDE desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers data.ts → socials, codingProjects, techProjects, friends, wallpapers
credits/page.tsx → credit page (type only — the desk ships no assets)
revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html
api/ api/
heatmap/route.ts → contribution heatmap proxy heatmap/route.ts → contribution heatmap proxy
activities/route.ts → GitDXD activity feed proxy activities/route.ts → GitDXD activity feed proxy
frame/route.ts → header-stripping proxy for unframeable friend sites
ping/route.ts → service health check (HTTP + Minecraft) ping/route.ts → service health check (HTTP + Minecraft)
components/ components/
ThemeRoot.tsx → web desk vs. Desktop mode ThemeRoot.tsx → picks which shell(s) to mount
ThemeScript.tsx → pre-paint script: device detection ThemeScript.tsx → pre-paint script: theme + device detection
useTheme.ts → theme state via useSyncExternalStore useTheme.ts → theme state via useSyncExternalStore
WebSite.tsx → the desk: rack hero, drawer, pegboard, patch panel TerminalLayout.tsx → the Gruvbox terminal shell
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu
MobileHome.tsx → phone shell: iOS home screen + app view
WinWindow.tsx → one draggable/resizable window WinWindow.tsx → one draggable/resizable window
apps.tsx → APPS registry, window contents, in-app terminal apps.tsx → APPS registry, window contents, in-app terminal
useDesktopIcons.ts → desktop icon grid + drag/drop logic useDesktopIcons.ts → desktop icon grid + drag/drop logic
useDragOrder.ts → list reordering (used inside apps) useDragOrder.ts → list reordering (mobile grid/dock)
ServiceButton.tsx → desktop service link + live status dot ServiceButton.tsx → service link + live status dot
ContributionHeatmap.tsx → desktop heatmap + recent commits widget ContributionHeatmap.tsx → heatmap + recent commits widget
Clock.tsx → live clock Clock.tsx → live clock
JokerLogo.tsx → logo with click easter egg JokerLogo.tsx → logo with click easter egg
SlBackground.tsx → animated ASCII train background
scripts/ scripts/
dev-server.mjs → custom dev server: live updates without the dev-only debug surface dev-server.mjs → custom dev server: live updates without the dev-only debug surface
deploy/ deploy/

114
agents.md
View file

@ -33,25 +33,16 @@ When in doubt, prefer updating. A stale README **or** agents.md is treated as a
## What this project is ## What this project is
Personal portfolio site for **dvorakmortonxd.dev**. The default view is a Personal portfolio site for **dvorakmortonxd.dev**. It's not a normal website —
**cartoon-skeuomorphic desk**: a warm cream surface with a monitor, candy-keycap it ships **two fully interactive desktop/OS simulations** plus a terminal, and
keyboard, mini rack, drive drawer, cork board of pinned cards, printed receipt, picks which one to show based on theme and device.
business cards, scoreboard and patch panel resting on it — all drawn in CSS, with
no image assets. A physical power switch flips to a **KDE Plasma desktop
simulation**. Phones only ever get the desk.
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript. - Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
- Styling: **Tailwind v4** for layout plus **`web.css`**, which is the whole desk - Styling: plain CSS files (`page.css`, `skeuo.css`), no CSS-in-JS, no Tailwind
— materials (slate chassis, cream plastic, paper, cork, black glass), details utilities in the components (Tailwind is present via PostCSS but the look is
(screws, LEDs, pushpins, rails, barcode, torn receipt edge) and object layouts, hand-written CSS).
all CSS with `color-mix()` tints. `globals.css` holds the tokens, base styles
and the reveals; `skeuo.css` and `page.css` are the desktop and the shared
desktop-app styles. **The web theme ships no images.**
- Hue system: list items take a `--tone` custom property from a shared palette
(orange, teal, blue, lilac, mint, coral, yellow), set inline, so objects are
colourful without per-item CSS.
- Path alias: `@/*` → `src/*`. - Path alias: `@/*` → `src/*`.
- Version: `4.0.0` (see `package.json`). - Version: `2.1.0` (see `package.json`).
### Scripts ### Scripts
```bash ```bash
@ -82,14 +73,12 @@ token.** Do not print it, copy it into new files, or push it anywhere else.
``` ```
src/app/ src/app/
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets page.tsx → renders <ThemeRoot/>, imports page.css + skeuo.css
layout.tsx → html shell, metadata, next/font, ThemeScript layout.tsx → html shell, metadata, ThemeScript
globals.css → palette tokens, base, @theme/@utility, scroll reveals globals.css → base/reset styles + Tailwind import
web.css → skeuomorphic material system + object layouts
page.css → shared component styles used by the desktop apps
skeuo.css → KDE Plasma desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers data.ts → socials, codingProjects, techProjects, friends, wallpapers
credits/page.tsx → credits (type only — the desk ships no assets) page.css → base / terminal theme styles
skeuo.css → Windows/Plasma desktop + mobile home styles
revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html
api/ api/
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
@ -97,45 +86,38 @@ src/app/
ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist
components/ components/
ThemeRoot.tsx → the web desk by default, desktop when theme === "skeuo" ThemeRoot.tsx → picks which shell to show (the router of the UI)
WebSite.tsx → the desk: rack hero, drive drawer, pegboard, patch panel TerminalLayout.tsx → "terminal" theme (Gruvbox)
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
MobileHome.tsx → "skeuo" theme, phone: app grid + dock
WinWindow.tsx → a single draggable/resizable window WinWindow.tsx → a single draggable/resizable window
apps.tsx → APPS registry + the content of each app + in-app terminal apps.tsx → APPS registry + the content of each app + in-app terminal
+ embedded-site windows + the Minecraft-style SMP menu + embedded-site windows + the Minecraft-style SMP menu
useDesktopIcons.ts → desktop icon grid + drag/drop (THE KEY FILE, see below) useDesktopIcons.ts → desktop icon grid + drag/drop (THE KEY FILE, see below)
useDragOrder.ts → simple list reordering (used inside desktop apps) useDragOrder.ts → simple list reordering (mobile home screen only)
useTheme.ts → theme state via useSyncExternalStore useTheme.ts → theme state via useSyncExternalStore
ThemeScript.tsx → inline pre-hydration script: theme + device detection ThemeScript.tsx → inline pre-hydration script: theme + device detection
ServiceButton.tsx → desktop service link + live status dot (pings /api/ping) ServiceButton.tsx → service link + live status dot (pings /api/ping)
ContributionHeatmap.tsx → desktop heatmap + recent-commit widget (uses the two proxies) ContributionHeatmap.tsx → heatmap + recent-commit widget (uses the two proxies)
Clock.tsx → live clock (useSyncExternalStore + interval) Clock.tsx → live clock (useSyncExternalStore + interval)
JokerLogo.tsx → site logo with a click easter egg JokerLogo.tsx → site logo with a click easter egg
SlBackground.tsx → animated ASCII train background (terminal theme)
scripts/ scripts/
dev-server.mjs → custom dev server: live updates minus the dev-only debug surface dev-server.mjs → custom dev server: live updates minus the dev-only debug surface
``` ```
### How UI selection works ### How UI selection works
1. `ThemeScript` runs before paint and sets `data-device` (`phone` | `desktop`) 1. `ThemeScript` runs before paint. It reads `localStorage.theme` and sets
from a user-agent sniff. It does **not** read a stored theme, so the page always `data-theme` (`skeuo` | `terminal`) and `data-device` (`phone` | `desktop`)
lands on `web`. CSS hides every `.desktop-toggle` on phones. on `<html>` from a user-agent sniff.
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`; 2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via
`setTheme` sets the attribute and dispatches `theme-change`. Nothing is `useSyncExternalStore` and dispatches a `theme-change` event on toggle.
persisted, so a reload returns to `web`. 3. `ThemeRoot` shows the terminal when `theme === "terminal"`; otherwise it
3. `ThemeRoot` renders `WebSite` unless `theme === "skeuo"`, in which case it mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS
mounts `WindowsDesktop` instead. The desk stays mounted (hidden) while the (`html[data-device="phone"]`) decide which is visible. The desktop stays
desktop is up so its scroll position survives; the desktop mounts on demand mounted while hidden so the Welcome window isn't recreated.
(phones can never reach `skeuo`).
Modes: `web` = the cartoon-skeuomorphic desk (default/landing), `skeuo` = KDE Themes: `skeuo` = KDE Plasma / Windows desktop, `terminal` = Gruvbox terminal.
Plasma desktop.
The desk ships **no image assets** — every surface is CSS. The only external
visual files are the social SVGs, used as monochrome CSS masks (`.social-mask`);
`linkedin.svg` is a filled badge that masks to a plain square, so `web-ui.tsx`
renders an inline mark for it instead. The desktop still uses its own wallpaper
and icons.
--- ---
@ -198,9 +180,9 @@ Guard (already in place, keep it that way):
`e.target` is always the button. `e.target` is always the button.
### Related ### Related
`useDragOrder.ts` is a **different, simpler** hook used inside the desktop apps `useDragOrder.ts` is a **different, simpler** hook used only by `MobileHome` for
for reordering lists (drag one item over another to swap). It is list reordering, reordering the phone app grid/dock (drag one item over another to swap). It is
not free placement. Don't confuse the two. list reordering, not free placement. Don't confuse the two.
--- ---
@ -234,33 +216,11 @@ not free placement. Don't confuse the two.
- Components are client components (`"use client"` at the top) — they use - Components are client components (`"use client"` at the top) — they use
pointer events and window APIs freely. pointer events and window APIs freely.
- CSS classes are prefixed by area: `win-` (desktop), `app-` / `kickoff` etc. - CSS classes are prefixed by area: `win-` (desktop), `mobile-` (phone),
(desktop windows), and `w-` (the web desk's materials and objects, in `app-` / `kickoff` / `terminal` etc. Grep by prefix to find styles.
`web.css`). **Never name a `w-*` class after a Tailwind utility** — `.w-screen` - Several places deliberately use **imperative DOM work** (drag ghost,
collided with Tailwind's `w-screen` (`width: 100vw`) and silently stretched transform-origin for the mobile launch animation). This is intentional for
every panel to the viewport. Also remember `web.css` is unlayered, so its performance; prefer matching the existing pattern over "Reactifying" it.
properties beat Tailwind utilities on the same element; use a dedicated class
(e.g. `.w-navlink`, `.w-corner-tr`) or `!` when a utility must win. (`.w-screw`
sets `position: relative` for its slot, which is why screws are placed with
`.w-corner-*` instead of Tailwind's `absolute`.)
- Angles and offsets that hover states need to override live in CSS custom
properties (e.g. `--tilt` on `.w-drive`), never in inline `style` transforms —
an inline transform wins over `:hover`.
- **The web desk is CSS-only by design.** No textures, no images, no `url()`
beyond the social masks. Build a surface from a flat gradient plus a thick
bottom edge (`0 5px 0 <edge-colour>`) and a soft cast shadow, and shade with
`color-mix(in srgb, var(--tone) N%, …)` instead of adding new hues to the
stylesheet. New list items take a `--tone` from the shared `TONES` palette in
`WebSite.tsx` / `web-ui.tsx` rather than hard-coding a colour.
- The desk reveals blocks with `.reveal` (see `Reveal` in `web-ui.tsx`): hidden by
default, `.is-in` shows. On load the first screenful cascades top-to-bottom
(90ms per block, in DOM order); the rest reveal when they scroll in. **Wrap
every content block** — anything left unwrapped paints before the top and breaks
the top-to-bottom order. The `<noscript>` rule in `layout.tsx` keeps everything
visible without JS.
- Several places deliberately use **imperative DOM work** (e.g. the drag ghost).
This is intentional for performance; prefer matching the existing pattern over
"Reactifying" it.
- `setState` inside an effect is blocked by the ESLint config - `setState` inside an effect is blocked by the ESLint config
(`react-hooks/set-state-in-effect`). The one intentional exception is the (`react-hooks/set-state-in-effect`). The one intentional exception is the
grid init in `useDesktopIcons.ts`, which carries a scoped grid init in `useDesktopIcons.ts`, which carries a scoped

View file

@ -1,6 +1,6 @@
{ {
"name": "dvorakmortonxd", "name": "dvorakmortonxd",
"version": "4.0.0", "version": "2.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node scripts/dev-server.mjs", "dev": "node scripts/dev-server.mjs",

BIN
public/gruvbox.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

BIN
public/phonewallpaper.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 KiB

View file

@ -8,8 +8,8 @@ import { NextResponse } from 'next/server';
// //
// Host matching is exact or by subdomain (e.g. anything under // Host matching is exact or by subdomain (e.g. anything under
// dvorakmortonxd.dev). Keep this in sync with the ServiceButton `url`/`checkUrl` // dvorakmortonxd.dev). Keep this in sync with the ServiceButton `url`/`checkUrl`
// values in components/apps.tsx and the services list in // values in components/apps.tsx and components/TerminalLayout.tsx, and with the
// components/WebSite.tsx, and with the friend list in data.ts. // friend list in data.ts.
const ALLOWED_HOSTS = [ const ALLOWED_HOSTS = [
'dvorakmortonxd.dev', // ai / jelly / navidrome / git / proxmox / mc (SMP) 'dvorakmortonxd.dev', // ai / jelly / navidrome / git / proxmox / mc (SMP)
'skopec.dev', 'skopec.dev',

View file

@ -0,0 +1,232 @@
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type MouseEvent,
} from "react";
import { APPS, APP_BY_ID } from "./apps";
import Clock from "./Clock";
import { useDragOrder } from "./useDragOrder";
import { MOBILE_WALLPAPER } from "../data";
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
function subscribe(callback: () => void) {
const id = window.setInterval(callback, 1000);
return () => window.clearInterval(id);
}
/** Digital iOS-style clock widget: big time, small day/date underneath. */
function HomeWidget() {
const now = useSyncExternalStore(
subscribe,
() => {
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 (
<div className="mobile-widget">
<div className="mobile-widget-time" suppressHydrationWarning>
{time}
</div>
<div className="mobile-widget-date" suppressHydrationWarning>
{day}
</div>
</div>
);
}
interface LaunchOrigin {
x: number;
y: number;
scale: number;
}
export default function MobileHome() {
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 closeTimer = useRef<number | null>(null);
const [gridApps, setGridApps] = useState(APPS);
const [dockApps, setDockApps] = useState(() => DOCK_IDS.map((id) => ({ id })));
const gridDrag = useDragOrder(setGridApps);
const dockDrag = useDragOrder(setDockApps);
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: boolean, drag: ReturnType<typeof useDragOrder>) => {
const app = APP_BY_ID[appId];
if (!app) return null;
return (
<button
key={app.id}
type="button"
data-drag-id={app.id}
className={
"mobile-app" +
(inDock ? " in-dock" : "") +
(drag.dragId === app.id ? " dragging" : "")
}
onPointerDown={drag.onPointerDown(app.id)}
onClick={(e) => {
if (drag.consumeDragged()) return;
launch(app.id, e);
}}
>
<span className="mobile-app-icon">
<span className="mobile-app-glyph">{app.icon}</span>
</span>
{!inDock && <span className="mobile-app-label">{app.title}</span>}
</button>
);
};
// 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 (
<div className="mobile-root">
<div
className="mobile-home"
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
aria-hidden={openApp ? true : undefined}
>
<div className="mobile-statusbar">
<span className="mobile-status-time">
<Clock />
</span>
<span className="mobile-status-icons" aria-hidden="true">
<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor">
<rect x="0" y="7" width="3" height="4" rx="1" />
<rect x="4.5" y="5" width="3" height="6" rx="1" />
<rect x="9" y="2.5" width="3" height="8.5" rx="1" />
<rect x="13.5" y="0" width="3" height="11" rx="1" />
</svg>
<svg width="16" height="11" viewBox="0 0 16 11" fill="currentColor">
<path d="M8 2.2c2.3 0 4.4.9 6 2.4l-1.2 1.3A6.9 6.9 0 0 0 8 4.2a6.9 6.9 0 0 0-4.8 1.7L2 4.6A8.5 8.5 0 0 1 8 2.2Z" />
<path d="M8 5.6c1.5 0 2.8.6 3.8 1.6l-1.2 1.3A3.7 3.7 0 0 0 8 7.6c-1 0-1.9.4-2.6 1l-1.2-1.4A5.7 5.7 0 0 1 8 5.6Z" />
<path d="M8 9a1.4 1.4 0 1 1 0 2.8A1.4 1.4 0 0 1 8 9Z" />
</svg>
<svg width="25" height="12" viewBox="0 0 25 12" aria-hidden="true">
<rect
x="0.5"
y="0.5"
width="21"
height="11"
rx="3.5"
fill="none"
stroke="currentColor"
opacity="0.5"
/>
<rect x="2" y="2" width="14" height="8" rx="2" fill="currentColor" />
<path d="M23 4v4a2 2 0 0 0 0-4Z" fill="currentColor" opacity="0.5" />
</svg>
</span>
</div>
<div className="mobile-grid">
<HomeWidget />
{gridApps.map((app) => renderIcon(app.id, false, gridDrag))}
</div>
<div className="mobile-dock">
{dockApps.map((d) => renderIcon(d.id, true, dockDrag))}
</div>
<div className="mobile-pagedots" aria-hidden="true">
<span className="active" />
<span />
<span />
</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>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
const TRAIN_ASCII = ` ==== ________ ___________
_D _| |_______/ \\__I_I_____===__|_________|
|(_)--- | H\\________/ | | =|___ ___| _________________ _________________ _________________ _________________ _________________ _________________
/ | | H | | | | ||_| |_|| _| \\_____| \\_____| \\_____| \\_____| \\_____| \\_____A
| | | H |__--------------------| [___] | =| | | | | | |
| ________|___H__/__|_____/[][]~\\_______| | -| | | | | | |
|/ | |-----------I_____I [][] [] D |=======|____|________________________|_______________________|_______________________|_______________________|_______________________|_______________________|_
__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__|__________________________|_______________________|_______________________|_______________________|_______________________|_______________________|
|/-=|___|= || || || |_____/~\\___/ |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_|
\\_/ \\O=====O=====O=====O_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/`;
export default function SlBackground() {
const [pos, setPos] = useState(150);
useEffect(() => {
let animationFrameId: number;
let lastTime = 0;
const animate = (timestamp: number) => {
if (!lastTime) lastTime = timestamp;
const delta = timestamp - lastTime;
// Update position every ~30ms to create a retro sliding terminal refresh feel
if (delta > 45) {
setPos((prev) => {
if (prev <= -150) return 150;
return prev - 1.5;
});
lastTime = timestamp;
}
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}, []);
return (
<>
<style>
{`
@keyframes rainbowShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
`}
</style>
<div
className="sl-background"
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
zIndex: 0,
overflow: "hidden",
display: "flex",
alignItems: "flex-end",
paddingBottom: "5vh",
justifyContent: "center",
userSelect: "none",
backgroundImage: "linear-gradient(rgba(29, 32, 33, 0.94), rgba(29, 32, 33, 0.94)), url('/gruvbox.webp')",
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<pre
style={{
margin: 0,
whiteSpace: "pre",
fontFamily: "var(--font-custom-mono), monospace",
fontSize: "1rem",
transform: "translateX(" + pos + "vw)",
willChange: "transform",
background: "linear-gradient(90deg, var(--red), var(--accent2), var(--accent), var(--green), var(--aqua), var(--blue), var(--purple))",
backgroundSize: "200% auto",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
animation: "rainbowShift 3s linear infinite",
opacity: 0.35,
filter: "drop-shadow(0 0 12px rgba(250, 189, 47, 0.5))",
}}
>
{TRAIN_ASCII}
</pre>
</div>
</>
);
}

View file

@ -0,0 +1,603 @@
import Image from "next/image";
import type { CSSProperties } from "react";
import SlBackground from "./SlBackground";
import JokerLogo from "./JokerLogo";
import ServiceButton from "./ServiceButton";
import ContributionHeatmap from "./ContributionHeatmap";
import { useTheme } from "./useTheme";
import { socials, codingProjects, techProjects, friends } from "../data";
type PanelStyle = CSSProperties & { "--panel-color"?: string };
const panelStyle = (color: string, style: CSSProperties = {}): PanelStyle => ({
...style,
"--panel-color": color,
});
export default function TerminalLayout() {
const { toggle } = useTheme();
return (
<>
<SlBackground />
<div className="container">
<header className="header">
<div className="header-center">
<JokerLogo />
<h1 className="site-name">dvorakmortonxd.dev</h1>
<button type="button" className="header-theme-btn" onClick={toggle}>
Desktop
</button>
</div>
</header>
<main className="hero">
<div className="hero-panel" style={panelStyle("var(--accent)")}>
<span
className="hero-panel-label"
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
>
README.MD
</span>
<h2 className="hero-title">$ whoami</h2>
<p className="hero-subtitle">
Welcome my personal website! Here I show off my projects and hardware I own,
aswell as my self-hosted services :)<span className="cursor-blink"></span>
</p>
</div>
<div className="social-section">
<div className="tui-panel" style={panelStyle("var(--accent)")}>
<span
className="tui-panel-label"
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
>
SOCIALS.JS
</span>
<div className="social-buttons">
{socials.map((s) => (
<a
key={s.name}
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="social-button"
>
<Image
src={s.icon}
alt={s.name}
width={16}
height={16}
className="social-icon"
/>
<span className="social-label">{s.name}</span>
</a>
))}
</div>
<div className="mail-contact">
mail if u ever wanna contact me:{" "}
<a href="mailto:dvorak@dvorakmortonxd.dev">dvorak@dvorakmortonxd.dev</a>
</div>
</div>
</div>
<div className="gym-section">
<div className="tui-panel" style={panelStyle("var(--white)")}>
<span
className="tui-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
GYM_SBD_AT_73KG_BW.DB
</span>
<div className="gym-grid">
<div className="gym-item">
<Image src="/squat.svg" alt="Squat" width={48} height={48} className="gym-icon" />
<div className="gym-label">SQUAT</div>
<div className="gym-weight">125kg</div>
<div className="gym-date">5.1.2025</div>
</div>
<div className="gym-item">
<Image src="/bench.svg" alt="Bench" width={48} height={48} className="gym-icon" />
<div className="gym-label">BENCH</div>
<div className="gym-weight">111kg</div>
<div className="gym-date">29.5.2026</div>
</div>
<div className="gym-item">
<Image
src="/deadlift.svg"
alt="Deadlift"
width={48}
height={48}
className="gym-icon"
/>
<div className="gym-label">DEADLIFT</div>
<div className="gym-weight">165kg</div>
<div className="gym-date">17.12.2024</div>
</div>
</div>
</div>
</div>
<div className="about-section">
<div className="tui-panel" style={panelStyle("var(--white)")}>
<span
className="tui-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
NOW.TXT
</span>
<div className="about-content">
<p>
<span style={{ color: "var(--green)" }}>martin@dvorakmortonxd</span>:
<span className="text-blue">~</span>$ cat now.txt
</p>
<div style={{ color: "#a3a3a3" }}>
<p>
I&#39;m currently hosting an SMP (dm me on discord). Also I&#39;m pretty
much cooking daily, because im bulking through out the non-summer seasons.
And me and my pal <a href="https://skopec.dev">Pepa</a> are working on an
education app so we can study more efficiently, It&#39;s called{" "}
<a href="https://git.dvorakmortonxd.dev/edu/maturita.c">maturita.c</a>!
</p>
</div>
</div>
</div>
</div>
<div className="about-section">
<div className="tui-panel" style={panelStyle("var(--accent)")}>
<span
className="tui-panel-label"
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
>
README.MD
</span>
<div className="about-content">
<p>
<span className="text-green">martin@dvorakmortonxd</span>:
<span className="text-blue">~</span>$ cat about_me.md
</p>
<div style={{ color: "#a3a3a3" }}>
<p>
Hey, I&#39;m Martin Dvořák — a 17(soon to be 18)-year-old IT student based
in the Czech Republic, specializing in network management and
administration at <a href="https://sssvt.cz">sssvt.cz</a>. I&#39;m a
sophomore and I have wanted to go to this school since 7th grade (2022) so
I&#39;m happy I got in in 2025, the school is pretty cool so if you are in
middle school and interested in IT, make sure to check it out. I&#39;m a
homelabber and developer heavily focused on self-hosting{" "}
<i>literally everything</i>, writing code (for example games for gamejams),
tinkering with GNU/Linux or Musl/Linux systems and retro hardware. I own a
small collection of laptops ranging from 2009 to 2014 (and ofcourse my 2025
macbook), most of these found in ewaste. In my free time I mostly spend
time with my gf {"<"}3, I also spend a bit of time setting up my self
hosted stuff (like jellyfin or gitdxd), and also when I&#39;m in my gaming
mood I play terraria (around 1300hr playtime btw) and minecraft modpacks
with mekanism and/or create. I also go to the gym around 3-5 times a week,
running a PPL split focused on strength/powerlifting and especially SBD. I
take a big focus on my health and physique.
<span className="cursor-blink"></span>
</p>
</div>
</div>
</div>
</div>
<div className="services-section">
<div className="tui-panel" style={panelStyle("var(--accent)")}>
<span
className="tui-panel-label"
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
>
SERVICES.HTML
</span>
<div className="services-buttons services-main">
<ServiceButton
url="https://proxmox.dvorakmortonxd.dev/"
className="proxmox"
icon="🖥️"
name="Proxmox"
desc="Server Management"
access="private"
disabled
/>
<ServiceButton
url="https://ai.dvorakmortonxd.dev/"
className="ai"
icon="🦾"
name="Llama.cpp"
desc="Local AI Server"
access="on_request"
/>
<ServiceButton
url="https://jelly.dvorakmortonxd.dev/"
className="jellyfin"
icon="🎬"
name="Jellyfin"
desc="Local Media Server"
access="on_request"
/>
<ServiceButton
url="https://discord.gg/vyvhk7d8bX"
checkUrl="minecraft://mc.dvorakmortonxd.dev:25565"
className="pterodactyl"
icon="🎮"
name="SMP"
desc="My Survival Minecraft Server"
access="on_request"
/>
<ServiceButton
url="https://navidrome.dvorakmortonxd.dev/"
className="funkwhale"
icon="🎸"
name="Navidrome"
desc="Music Streaming"
access="private"
/>
<ServiceButton
url="https://git.dvorakmortonxd.dev"
className="gitdxd"
icon="🐙"
name="GitDXD"
desc="Best Git Hosting OAT"
access="public"
/>
</div>
</div>
</div>
<div className="gitdxd-section" style={{ marginTop: "20px" }}>
<div className="tui-panel" style={panelStyle("var(--white)")}>
<span
className="tui-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
GITDXD.GIT
</span>
<div style={{ padding: "24px 16px 16px" }}>
<h2
style={{
fontSize: "2.4rem",
fontWeight: "bold",
color: "var(--white)",
marginBottom: "16px",
textAlign: "center",
}}
>
GitDXD
</h2>
<p
style={{
fontSize: "1.8rem",
color: "var(--white)",
fontWeight: "500",
textAlign: "center",
}}
>
My Self-hosted Git Instance
</p>
<div
style={{
marginTop: "16px",
marginBottom: "16px",
borderRadius: "8px",
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.4)",
boxShadow: "0 0 30px rgba(180, 255, 125, 0.15)",
}}
>
<Image
src="/gitdxd.webp"
alt="GitDXD Preview"
width={1280}
height={720}
style={{ width: "100%", height: "auto", display: "block" }}
/>
</div>
<div
style={{
color: "var(--gray)",
marginTop: "12px",
lineHeight: "1.6",
textAlign: "center",
}}
>
<p>
My very own forgejo git instance, originally started as a private git
instance for me and my friends to boycott GitHub but I&#39;ve made it
public now so. Join at{" "}
<span className="text-purple-500">
<a href="https://git.dvorakmortonxd.dev">git.dvorakmortonxd.dev</a>
</span>
.<span className="cursor-blink"></span>
</p>
</div>
</div>
</div>
</div>
<div className="commits-section" style={{ marginTop: "20px" }}>
<div className="tui-panel" style={panelStyle("var(--accent)")}>
<span
className="tui-panel-label"
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
>
CONTRIBUTIONS@GITDXD.GIT
</span>
<div className="commits-content" style={{ padding: "24px 12px 16px" }}>
<ContributionHeatmap />
</div>
</div>
</div>
<div className="revival-section" style={{ marginTop: "20px" }}>
<div className="tui-panel" style={panelStyle("var(--white)")}>
<span
className="tui-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
REVIVAL.INC
</span>
<div style={{ padding: "24px 16px 16px" }}>
<h2
style={{
fontSize: "2.4rem",
fontWeight: "bold",
color: "var(--white)",
marginBottom: "32px",
textAlign: "center",
}}
>
Revival inc.
</h2>
<div
style={{
marginTop: "12px",
marginBottom: "12px",
borderRadius: "8px",
overflow: "hidden",
border: "1px solid rgba(0, 0, 0, 0.4)",
boxShadow: "0 0 30px rgba(255, 255, 255, 0.35)",
width: "80%",
margin: "12px auto",
}}
>
<Image
src="/revivalinc.webp"
alt="revival inc thumbnail"
width={768}
height={576}
style={{ width: "100%", height: "auto", display: "block" }}
/>
</div>
<p
style={{
fontSize: "1.8rem",
color: "var(--white)",
fontWeight: "500",
textAlign: "center",
}}
>
What is it?
</p>
<div style={{ color: "var(--gray)", marginTop: "12px", lineHeight: "1.6" }}>
<p>
It&#39;s a game I made for the game jam &#34;Mini Jame Gam #55&#34;, the
theme was Necromancy and the special item was Milk, the game also includes
a tutorial so if you wanna try it do it. Check it out at{" "}
<span className="text-purple-500">
<a href="https://martin-dv.itch.io/revival-inc">itch.io</a>
</span>{" "}
or play{" "}
<span className="text-purple-500">
<a href="https://dvorakmortonxd.dev/revival-inc">here</a>
</span>
.<span className="cursor-blink"></span>
</p>
</div>
</div>
</div>
</div>
<div className="rustywolf-section" style={{ marginTop: "20px" }}>
<div className="tui-panel" style={panelStyle("var(--white)")}>
<span
className="tui-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
RUSTYWOLF.RS
</span>
<div style={{ padding: "24px 16px 16px" }}>
<h2
style={{
fontSize: "2.4rem",
fontWeight: "bold",
color: "var(--white)",
marginBottom: "32px",
textAlign: "center",
}}
>
Introducing RustyWolf
</h2>
<div
style={{
marginTop: "12px",
marginBottom: "12px",
borderRadius: "8px",
overflow: "hidden",
border: "1px solid rgba(255, 255, 255, 0.4)",
boxShadow: "0 0 30px rgba(255, 255, 255, 0.2)",
}}
>
<Image
src="/RustyWolfPreview.webp"
alt="RustyWolf Browser Preview"
width={1280}
height={720}
style={{ width: "100%", height: "auto", display: "block" }}
/>
</div>
<p
style={{
fontSize: "1.8rem",
color: "var(--white)",
fontWeight: "500",
textAlign: "center",
}}
>
What makes it so cool???
</p>
<div style={{ color: "var(--gray)", marginTop: "12px", lineHeight: "1.6" }}>
<p>
A lightweight browser made in Rust with WebKitGTK. It&#39;s the successor
to my previous project Venturesome, aiming to provide a fast and minimalist
browsing experience. It&#39;s a perfect browser for old machines that
struggle with modern browsers. Check it out at{" "}
<span style={{ color: "var(--accent2)" }}>
<a href="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf">
GitDXD
</a>
</span>
.<span className="cursor-blink"></span>
</p>
</div>
</div>
</div>
</div>
</main>
<section className="projects">
<div className="subsection-wrapper">
<div className="subsection-panel" style={panelStyle("var(--white)")}>
<span
className="subsection-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
TECH_LIST.JSON
</span>
<h2
style={{
fontSize: "2rem",
fontWeight: "bold",
textAlign: "center",
margin: "4px 0 20px",
color: "var(--white)",
}}
>
All my hardware equipment :)
</h2>
<div className="project-grid">
{techProjects.map((project) => (
<div key={project.title} className="project-card">
<div
className="project-image"
style={{ backgroundImage: `url(${project.image})` }}
/>
<h4 className="project-title">{project.title}</h4>
<p className="project-desc">{project.desc}</p>
</div>
))}
</div>
</div>
</div>
<div className="subsection-wrapper">
<div className="subsection-panel coding-panel" style={panelStyle("var(--accent)")}>
<span
className="subsection-panel-label"
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
>
PROJECTS.DOCX
</span>
<h2
style={{
fontSize: "2rem",
fontWeight: "bold",
textAlign: "center",
margin: "4px 0 20px",
color: "var(--accent)",
}}
>
All the stuff I&#39;ve worked on :))
</h2>
<div className="project-grid">
{codingProjects.map((project, idx) => (
<a
key={idx}
href={project.url}
target="_blank"
rel="noopener noreferrer"
className="project-card"
>
<div
className="project-image"
style={{ backgroundImage: `url(${project.img})` }}
/>
<h4 className="project-title">{project.title}</h4>
<p className="project-desc">{project.desc}</p>
</a>
))}
</div>
</div>
</div>
<div className="subsection-wrapper">
<div
className="subsection-panel friends-panel"
style={panelStyle("var(--white)", { backgroundColor: "rgba(255, 255, 255, 0.02)" })}
>
<span
className="subsection-panel-label"
style={{ color: "var(--white)", borderColor: "var(--white)" }}
>
FRIENDS.TSX
</span>
<h2
style={{
fontSize: "2rem",
fontWeight: "bold",
textAlign: "center",
margin: "4px 0 20px",
color: "var(--white)",
}}
>
Friend&#39;s websites and projects
</h2>
<div className="services-buttons" style={{ gap: "1rem" }}>
{friends.map((friend, idx) => (
<ServiceButton
key={idx}
url={friend.realUrl || friend.url}
className="friend"
icon={
<Image
src={friend.img}
alt={friend.name}
width={68}
height={68}
className="friend-icon"
style={{
borderRadius: "50%",
filter:
"brightness(0) invert(65%) hue-rotate(70deg) saturate(100%)",
}}
/>
}
name={friend.name}
desc={friend.desc}
/>
))}
</div>
</div>
</div>
</section>
<footer className="footer">
<span className="footer-label">v2.1.0</span>
© 2025-2026 Martin Dvořák. All rights reserved (not really I dont care, just follow
the GPL3.0 License)
</footer>
</div>
</>
);
}

View file

@ -1,25 +1,32 @@
"use client"; "use client";
import { useTheme } from "./useTheme"; import { useTheme } from "./useTheme";
import WebSite from "./WebSite"; import TerminalLayout from "./TerminalLayout";
import WindowsDesktop from "./WindowsDesktop"; import WindowsDesktop from "./WindowsDesktop";
import MobileHome from "./MobileHome";
// The web editorial site is the default and always the landing view (the theme
// is not persisted). Phones can never reach the KDE desktop: the Desktop-mode
// buttons are hidden there, so `theme === "skeuo"` only happens on desktop.
//
// The editorial page stays mounted (hidden) while the desktop is up, so its
// scroll position survives a round-trip. The desktop mounts on demand.
export default function ThemeRoot() { export default function ThemeRoot() {
const { theme } = useTheme(); const { theme } = useTheme();
const desktop = theme === "skeuo"; const terminal = theme === "terminal";
// 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 ( return (
<> <>
<div style={desktop ? { display: "none" } : undefined}> <div
<WebSite /> className="skeuo-desktop-root"
style={terminal ? { display: "none" } : undefined}
>
<WindowsDesktop />
</div> </div>
{desktop ? <WindowsDesktop /> : null} <div
className="skeuo-mobile-root"
style={terminal ? { display: "none" } : undefined}
>
<MobileHome />
</div>
{terminal && <TerminalLayout />}
</> </>
); );
} }

View file

@ -3,10 +3,11 @@
import { useRef } from "react"; import { useRef } from "react";
import { useServerInsertedHTML } from "next/navigation"; import { useServerInsertedHTML } from "next/navigation";
// Runs before paint to set the phone/desktop split. The theme itself is not // Runs before paint so the saved theme and the phone/desktop split are applied
// read from storage — the web theme is always the landing view — so this only // without a flash. It is injected with useServerInsertedHTML so the <script>
// tags the device, which CSS uses to hide the Desktop-mode button on phones. // lives in the SSR HTML stream instead of inside the React tree — React 19
const INIT_SCRIPT = `(function(){try{var ua=navigator.userAgent||'';var phone=/(iPhone|iPod|Android.+Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini|Mobile)/i.test(ua);document.documentElement.setAttribute('data-device',phone?'phone':'desktop');}catch(e){}})();`; // logs an error for script tags rendered by components on the client.
const INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme');if(t==='terminal'||t==='skeuo'){document.documentElement.setAttribute('data-theme',t);}var ua=navigator.userAgent||'';var phone=/(iPhone|iPod|Android.+Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini|Mobile)/i.test(ua);document.documentElement.setAttribute('data-device',phone?'phone':'desktop');}catch(e){}})();`;
export default function ThemeScript() { export default function ThemeScript() {
// useServerInsertedHTML can fire several times during a single render pass; // useServerInsertedHTML can fire several times during a single render pass;

View file

@ -1,569 +0,0 @@
"use client";
import { useState, type CSSProperties } from "react";
import { useTheme } from "./useTheme";
import { codingProjects, friends, socials, techProjects } from "../data";
import {
Keyboard,
Led,
MiniRack,
Monitor,
Plaque,
Report,
Reveal,
SectionHead,
ServiceUnit,
SocialGlyph,
type ServiceAccess,
} from "./web-ui";
/* --------------------------------------------------------------- content */
const NAV = [
{ href: "#about", label: "about" },
{ href: "#services", label: "services" },
{ href: "#projects", label: "projects" },
{ href: "#hardware", label: "machines" },
{ href: "#contact", label: "contact" },
];
/** One hue per item, so every list reads as separate pieces of hardware. */
const TONES = [
"var(--orange)",
"var(--teal)",
"var(--blue)",
"var(--lilac)",
"var(--mint)",
"var(--coral)",
"var(--yellow)",
];
const SERVICES: {
name: string;
desc: string;
url: string;
access?: ServiceAccess;
checkUrl?: string;
disabled?: boolean;
}[] = [
{ name: "proxmox", desc: "server management — private console", url: "https://proxmox.dvorakmortonxd.dev/", access: "private", disabled: true },
{ name: "llama.cpp", desc: "local models on a passthrough RTX 3070", url: "https://ai.dvorakmortonxd.dev/", access: "on_request" },
{ name: "jellyfin", desc: "media library, streamed from home", url: "https://jelly.dvorakmortonxd.dev/", access: "on_request" },
{ name: "minecraft smp", desc: "survival server on mc.dvorakmortonxd.dev", url: "https://discord.gg/vyvhk7d8bX", checkUrl: "minecraft://mc.dvorakmortonxd.dev:25565", access: "on_request" },
{ name: "navidrome", desc: "my music, no streaming subscription", url: "https://navidrome.dvorakmortonxd.dev/", access: "private" },
{ name: "gitdxd", desc: "self-hosted Forgejo forge", url: "https://git.dvorakmortonxd.dev", access: "public" },
];
const LIFTS = [
{ lift: "squat", weight: "125", date: "05 Jan 2025" },
{ lift: "bench", weight: "111", date: "29 May 2026" },
{ lift: "deadlift", weight: "165", date: "17 Dec 2024" },
];
/** Titles carry their state in parentheses, e.g. "dxdwm (Discontinued)". */
function splitStatus(title: string) {
const m = title.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
return m ? { name: m[1], status: m[2] } : { name: title, status: null };
}
function statusKind(status: string | null): "on" | "amber" | "off" {
if (!status) return "off";
const s = status.toLowerCase();
if (s.includes("finished") || s.includes("available")) return "on";
if (s.includes("wip") || s.includes("unfinished")) return "amber";
return "off";
}
/* ------------------------------------------------------------------ page */
export default function WebSite() {
const { setTheme } = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="w-page">
{/* -------------------------------------------------------------- rail */}
<header className="w-rail">
<div className="mx-auto flex h-16 w-full max-w-[1120px] items-center gap-5 px-5 sm:px-8">
<a href="#top" className="flex shrink-0 items-center gap-2.5" aria-label="Back to top">
<Led kind="on" pulse slow />
<span className="w-wordmark">dvorakmortonxd</span>
</a>
<nav className="ml-auto hidden items-center gap-6 md:flex" aria-label="Main">
{NAV.map((item) => (
<a key={item.href} href={item.href} className="w-navlink">
{item.label}
</a>
))}
</nav>
<div className="ml-auto flex items-center gap-3 md:ml-0">
<button
type="button"
aria-label="Switch to Desktop mode"
onClick={() => setTheme("skeuo")}
className="desktop-toggle flex items-center gap-3"
title="Flip to Desktop mode"
>
<span className="w-mono hidden text-[0.6rem] tracking-[0.2em] text-[var(--ink-soft)] sm:inline">
DESKTOP
</span>
<span className="w-rocker">
<span className="w-rocker__knob" />
<Led kind="off" className="w-rocker__led" />
</span>
</button>
<div className="md:hidden">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-label={menuOpen ? "Close menu" : "Open menu"}
className="w-keybtn"
>
{menuOpen ? "close" : "menu"}
</button>
</div>
</div>
</div>
{menuOpen ? (
<nav
className="w-plastic mx-auto mb-3 flex w-[calc(100%-2.5rem)] max-w-[1120px] flex-col gap-1 p-3 md:hidden"
aria-label="Main"
>
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setMenuOpen(false)}
className="w-display border-b-2 border-dashed border-[var(--plastic-edge)] py-3 text-[0.9rem] text-[var(--plastic-ink)] last:border-b-0"
>
{item.label}
</a>
))}
</nav>
) : null}
</header>
<span id="top" />
<main className="pt-24 sm:pt-28">
{/* ------------------------------------------------------------ hero */}
<section className="mx-auto w-full max-w-[1120px] px-5 sm:px-8">
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-end lg:gap-8">
<Reveal>
<Monitor>
<p className="w-tft">
<span className="a">martin@dvorakmortonxd</span>:~$ whoami
<span className="w-caret ml-2" />
</p>
<h1 className="w-display mt-5 text-[clamp(1.8rem,4.1vw,2.75rem)] text-[var(--glass-ink)]">
My own little internet — one rack, zero subscriptions.
</h1>
<p className="w-prose mt-5 max-w-[54ch] text-[var(--glass-dim)]">
I&rsquo;m Martin Dvořák. Git, media, music, an AI box and a Minecraft server all run on a
Proxmox machine in my room — plus the hardware graveyard I keep rescuing from e-waste.
</p>
<p className="mt-7 flex flex-wrap gap-3">
<a href="#services" className="w-cmd">
$ open rack --services
</a>
<a href="#projects" className="w-cmd w-cmd--quiet">
$ open drawer --projects
</a>
</p>
</Monitor>
<div className="-mt-1 px-1 pt-7 sm:px-8">
<Keyboard />
</div>
</Reveal>
<Reveal delay={120}>
<div className="flex flex-col gap-6">
<MiniRack />
<div className="grid grid-cols-2 gap-3">
<Plaque value={String(SERVICES.length)} label="services" tone="var(--orange)" />
<Plaque value={String(codingProjects.length)} label="projects" tone="var(--teal)" />
<Plaque value={String(techProjects.length)} label="machines" tone="var(--blue)" />
<Plaque value="401" label="kg best SBD" tone="var(--coral)" />
</div>
</div>
</Reveal>
</div>
</section>
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-24 px-5 pt-28 pb-24 sm:gap-28 sm:px-8">
{/* --------------------------------------------------------- about */}
<section id="about" className="scroll-mt-28">
<Reveal>
<SectionHead index="01" label="about" title="Who is behind the rack" />
</Reveal>
<div className="grid gap-7 lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)] lg:items-start">
<Reveal delay={60}>
<article className="w-paper w-sheet">
<div className="w-sheet__head">
<span>manifest.txt</span>
<span className="ml-auto">printed on the desk</span>
</div>
<div className="mt-5 grid gap-x-9 gap-y-4 sm:grid-cols-2">
<p className="w-prose">
I&rsquo;m a sophomore at{" "}
<a className="w-link" href="https://sssvt.cz" target="_blank" rel="noopener noreferrer">
sssvt.cz
</a>
, studying network management and administration. I wanted to go there since 7th
grade, so getting in was a good day.
</p>
<p className="w-prose">
I&rsquo;m a homelabber first: my own Git, media, music, AI and game servers, most of it
running on hardware other people threw away.
</p>
<p className="w-prose">
A laptop collection from 2009 to 2025, mostly e-waste rescues — ThinkPads, MacBooks,
a Legion Go, an Xbox with a bad-update exploit and one very dead 2010 unibody.
</p>
<p className="w-prose">
Away from the rack: PPL training 3–5× a week, Terraria (~1300 h), Minecraft modpacks
and the occasional game jam.
</p>
</div>
</article>
</Reveal>
<Reveal delay={150}>
<aside className="w-paper w-cardindex lg:mt-14" style={{ "--tone": "var(--teal)" } as CSSProperties}>
<p className="w-kicker">index card</p>
<p className="w-serif mt-3 text-[1.2rem] text-[var(--ink)]">
&ldquo;If it has a fan, I&rsquo;ll find a use for it.&rdquo;
</p>
<div className="w-rule my-4" />
<ul className="w-mono space-y-2 text-[0.68rem] text-[var(--ink-soft)]">
<li>school · sssvt.cz</li>
<li>gym · PPL 3–5×/week</li>
<li>games · Terraria, Minecraft</li>
<li>currently · bulking season</li>
</ul>
</aside>
</Reveal>
</div>
</section>
{/* ----------------------------------------------------------- now */}
<section id="now" className="scroll-mt-28">
<Reveal>
<SectionHead index="02" label="now" title="On the bench right now" />
</Reveal>
<div className="grid gap-7 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-start">
<Reveal delay={60}>
<div className="w-note">
<span className="w-pin" aria-hidden="true" />
<p className="w-kicker !text-[#8a7420]">now.txt · updated 09/2026</p>
<p className="w-prose mt-4 text-[#5c4a1f]">
I&rsquo;m hosting an SMP — message me on Discord if you want in. I&rsquo;m also cooking most
days, bulking through the non-summer seasons.
</p>
<p className="w-prose mt-3 text-[#5c4a1f]">
Me and my friend{" "}
<a className="w-link" href="https://skopec.dev" target="_blank" rel="noopener noreferrer">
Pepa
</a>{" "}
are building an education app so we can study more efficiently. It&rsquo;s called{" "}
<a
className="w-link"
href="https://git.dvorakmortonxd.dev/edu/maturita.c"
target="_blank"
rel="noopener noreferrer"
>
maturita.c
</a>
.
</p>
</div>
</Reveal>
<Reveal delay={140}>
<div className="flex flex-col gap-8">
<div className="w-paper w-cardindex w-cardindex--alt" style={{ "--tone": "var(--coral)" } as CSSProperties}>
<p className="w-kicker">bench status</p>
<ul className="w-display mt-4 space-y-2.5 text-[0.82rem] text-[var(--ink)]">
<li className="flex items-center gap-2.5">
<Led kind="on" /> smp · accepting players
</li>
<li className="flex items-center gap-2.5">
<Led kind="amber" pulse /> maturita.c · in development
</li>
<li className="flex items-center gap-2.5">
<Led kind="on" /> gym · PPL 3–5×/week
</li>
</ul>
</div>
<div className="flex items-end gap-6">
<i className="w-pen mb-3 w-40" aria-hidden="true" />
<span className="w-mug mr-6" aria-hidden="true" />
</div>
</div>
</Reveal>
</div>
</section>
{/* ------------------------------------------------------ services */}
<section id="services" className="scroll-mt-28">
<Reveal>
<SectionHead index="03" label="rack" title="Six machines, one shelf" />
</Reveal>
<Reveal delay={60}>
<div className="w-chassis w-rack">
<div className="w-rack__top">
<Led kind="on" pulse slow />
<span className="w-display text-[0.7rem] tracking-[0.18em] text-[var(--chassis-ink)]">
RACK 01 · SERVICES
</span>
<span className="w-mono ml-auto hidden text-[0.62rem] tracking-[0.12em] text-[var(--chassis-faint)] sm:inline">
health checked from this page
</span>
<span className="w-rack__vent w-vents" />
</div>
<div className="w-rack__grid">
<span className="w-rails" />
<div>
{SERVICES.map((s, i) => (
<ServiceUnit key={s.name} tone={TONES[i % TONES.length]} {...s} />
))}
</div>
<span className="w-rails" />
</div>
</div>
</Reveal>
</section>
{/* ------------------------------------------------------ projects */}
<section id="projects" className="scroll-mt-28">
<Reveal>
<SectionHead index="04" label="drawer" title="Eighteen drives, all labelled" />
</Reveal>
<Reveal delay={60}>
<div className="w-chassis w-drawer">
<div className="w-well w-drawer__inner w-scroll">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{codingProjects.map((p, i) => {
const { name, status } = splitStatus(p.title);
return (
<a
key={i}
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="w-drive"
style={
{
"--tilt": `${i % 2 ? 0.6 : -0.7}deg`,
"--tone": TONES[i % TONES.length],
} as CSSProperties
}
title={p.desc}
>
<span className="w-drive__port" aria-hidden="true" />
<span className="w-drive__sticker">
<span className="w-drive__name">{name}</span>
<span className="w-drive__meta">
{status ?? "unlabelled"} · {p.desc}
</span>
<span className="w-barcode w-drive__bar" aria-hidden="true" />
</span>
<Led kind={statusKind(status)} className="w-drive__led" />
</a>
);
})}
</div>
</div>
<div className="w-chassis-2 w-drawer__front">
<i className="w-screw" aria-hidden="true" />
<span className="w-drawer__handle" aria-hidden="true" />
<i className="w-screw" aria-hidden="true" />
</div>
</div>
</Reveal>
</section>
{/* ------------------------------------------------------ hardware */}
<section id="hardware" className="scroll-mt-28">
<Reveal>
<SectionHead index="05" label="machines" title="Pinned to the cork board" />
</Reveal>
<Reveal delay={60}>
<div className="w-pegboard">
<div className="w-pegboard__face">
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{techProjects.map((t, i) => (
<article
key={t.title}
className="w-paper w-hangtag"
style={{ "--tone": TONES[i % TONES.length] } as CSSProperties}
>
<span className="w-pin" aria-hidden="true" />
<h3 className="w-display text-[1.05rem] text-[var(--ink)]">{t.title}</h3>
<span className="w-hangtag__rule" aria-hidden="true" />
<p className="w-prose text-[0.84rem] text-[var(--ink-soft)]">{t.desc}</p>
</article>
))}
</div>
</div>
</div>
</Reveal>
</section>
{/* -------------------------------------------------- activity feed */}
<section id="gitdxd" className="scroll-mt-28">
<Reveal>
<SectionHead index="06" label="gitdxd" title="The receipt for this year's commits" />
</Reveal>
<Reveal delay={60}>
<Report />
</Reveal>
</section>
{/* ------------------------------------------------------- friends */}
<section id="friends" className="scroll-mt-28">
<Reveal>
<SectionHead index="07" label="friends" title="Cards on the desk" />
</Reveal>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{friends.map((f, i) => (
<Reveal key={f.name} delay={60 + i * 70}>
<a
href={f.realUrl || f.url}
target="_blank"
rel="noopener noreferrer"
className="w-bizcard"
style={
{
"--tilt": `${i === 1 ? 0.9 : i === 2 ? -1 : -1.4}deg`,
"--tone": TONES[(i + 1) % TONES.length],
} as CSSProperties
}
>
<p className="w-serif text-[1.2rem] text-[var(--ink)]">{f.name}</p>
<p className="w-mono mt-1 text-[0.68rem] text-[var(--ink-soft)]">{f.desc}</p>
</a>
</Reveal>
))}
</div>
</section>
{/* ----------------------------------------------------------- gym */}
<section id="gym" className="scroll-mt-28">
<Reveal>
<SectionHead index="08" label="strength" title="Numbers on the scoreboard" />
</Reveal>
<Reveal delay={60}>
<div className="w-chassis w-score">
<div className="w-score__head">
<Led kind="on" pulse slow />
<span>TRAINING LOG · 73 KG BODYWEIGHT</span>
<span className="ml-auto hidden sm:inline">3 LIFTS</span>
</div>
<div className="w-score__tiles">
{LIFTS.map((l, i) => (
<div
key={l.lift}
className="w-score__tile"
style={{ "--tone": TONES[(i + 2) % TONES.length] } as CSSProperties}
>
<span className="w-score__num">
{l.weight}
<small>kg</small>
</span>
<span className="w-score__lift">{l.lift}</span>
<span className="w-score__date">{l.date}</span>
</div>
))}
</div>
<p className="w-score__foot">BEST SBD TOTAL · 401 KG · 165 + 111 + 125</p>
</div>
</Reveal>
</section>
{/* ------------------------------------------------------- contact */}
<section id="contact" className="scroll-mt-28">
<Reveal>
<SectionHead index="09" label="patch panel" title="Twelve ports, all live" />
</Reveal>
<Reveal delay={60}>
<div className="w-chassis w-patch">
<div className="w-chassis-2 w-patch__face">
<div className="w-patch__head">
<i className="w-screw" aria-hidden="true" />
<span>PATCH 01 · SOCIALS</span>
<i className="w-screw ml-auto" aria-hidden="true" />
</div>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-3">
{socials.map((s, i) => (
<a
key={s.name}
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="w-port"
style={{ "--tone": TONES[i % TONES.length] } as CSSProperties}
>
<span className="w-port__jack" aria-hidden="true">
<span className="w-port__slot" />
</span>
<span className="w-port__icon">
<SocialGlyph name={s.name} icon={s.icon} />
</span>
<span className="w-port__label">{s.name}</span>
</a>
))}
</div>
<p className="w-mono mt-5 text-[0.66rem] text-[var(--chassis-faint)]">
fastest reply is Discord or Instagram · mail{" "}
<a className="w-link" href="mailto:dvorak@dvorakmortonxd.dev">
dvorak@dvorakmortonxd.dev
</a>
</p>
</div>
</div>
</Reveal>
</section>
</div>
</main>
{/* ------------------------------------------------------------ footer */}
<footer className="mx-auto w-full max-w-[1120px] px-5 pb-16 sm:px-8">
<Reveal>
<div className="w-plastic relative flex flex-col gap-5 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="w-display text-[0.8rem] tracking-[0.16em] text-[var(--plastic-ink)]">
DVORAKMORTONXD.LOCAL
</p>
<p className="w-mono mt-1.5 text-[0.64rem] text-[var(--ink-soft)]">
v4.0.0 · GPL-3.0-only · built with Next.js · no textures, all CSS ·{" "}
<a className="w-link" href="/credits">
credits
</a>
</p>
</div>
<button
type="button"
onClick={() => setTheme("skeuo")}
className="w-keybtn w-btn-accent desktop-toggle shrink-0 self-start sm:self-auto"
title="Flip to Desktop mode"
>
<Led kind="on" /> desktop mode
</button>
<i className="w-screw w-corner-tr" aria-hidden="true" />
<i className="w-screw w-corner-bl" aria-hidden="true" />
</div>
<p className="w-mono mt-4 text-[0.64rem] text-[var(--ink-soft)]">
© 2025–2026 Martin Dvořák — follow the licence or don&rsquo;t, I don&rsquo;t mind
</p>
</Reveal>
</footer>
</div>
);
}

View file

@ -577,10 +577,10 @@ export default function WindowsDesktop() {
type="button" type="button"
className="win-theme-toggle" className="win-theme-toggle"
onClick={toggle} onClick={toggle}
title={`Switch to ${theme === "skeuo" ? "web" : "Desktop"} theme`} title={`Switch to ${theme === "skeuo" ? "gruvbox" : "Desktop"} theme`}
> >
<span aria-hidden="true">{theme === "skeuo" ? "▚" : "❖"}</span> <span aria-hidden="true">{theme === "skeuo" ? "▚" : "❖"}</span>
<span>{theme === "skeuo" ? "Web" : "Desktop"}</span> <span>{theme === "skeuo" ? "Gruvbox" : "Desktop"}</span>
</button> </button>
<Clock className="win-clock" /> <Clock className="win-clock" />

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";
@ -45,14 +46,14 @@ function WelcomeApp() {
<li>Drag windows around by their title bar.</li> <li>Drag windows around by their title bar.</li>
<li>Use the buttons at the top-right to minimize, maximize or close.</li> <li>Use the buttons at the top-right to minimize, maximize or close.</li>
<li>Open apps from the Gentoo launcher, bottom-left.</li> <li>Open apps from the Gentoo launcher, bottom-left.</li>
<li>Switch back to the web theme from the panel tray, bottom-right.</li> <li>Switch back to the Gruvbox theme from the panel tray, bottom-right.</li>
</ul> </ul>
<p className="app-preference"> <p className="app-preference">
If you&#39;d prefer a normal website{" "} If you&#39;d prefer a normal website{" "}
<button <button
type="button" type="button"
className="app-inline-link" className="app-inline-link"
onClick={() => setTheme("web")} onClick={() => setTheme("terminal")}
> >
click here click here
</button> </button>
@ -62,8 +63,12 @@ function WelcomeApp() {
Wallpaper from{" "} Wallpaper from{" "}
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer"> <a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
wallpapercave.com/kde-plasma-wallpapers wallpapercave.com/kde-plasma-wallpapers
</a> </a>{" "}
. Gentoo logo by Matteo &apos;Peach&apos; Pescarin (CC BY-SA 2.5), via (desktop) and{" "}
<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. Wikimedia Commons.
</p> </p>
</div> </div>

View file

@ -2,30 +2,37 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
// "web" is the editorial site and always the landing view. "skeuo" is the KDE export type Theme = "skeuo" | "terminal";
// desktop, reachable from the editorial page's Desktop-mode button. The choice
// is deliberately NOT persisted, so a reload always comes back to the web theme
// (and there is no wrong-theme flash on load).
export type Theme = "web" | "skeuo";
const EVENT = "theme-change"; const EVENT = "theme-change";
function subscribe(callback: () => void) { function subscribe(callback: () => void) {
window.addEventListener(EVENT, callback); window.addEventListener(EVENT, callback);
return () => window.removeEventListener(EVENT, callback); window.addEventListener("storage", callback);
return () => {
window.removeEventListener(EVENT, callback);
window.removeEventListener("storage", callback);
};
} }
function getSnapshot(): Theme { function getSnapshot(): Theme {
return document.documentElement.getAttribute("data-theme") === "skeuo" ? "skeuo" : "web"; return document.documentElement.getAttribute("data-theme") === "terminal"
? "terminal"
: "skeuo";
} }
function getServerSnapshot(): Theme { function getServerSnapshot(): Theme {
return "web"; return "skeuo";
} }
export function setTheme(theme: Theme) { export function setTheme(theme: Theme) {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
document.documentElement.setAttribute("data-theme", theme); document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("theme", theme);
} catch {
/* ignore */
}
window.dispatchEvent(new Event(EVENT)); window.dispatchEvent(new Event(EVENT));
} }
@ -33,7 +40,6 @@ export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return { return {
theme, theme,
setTheme, toggle: () => setTheme(theme === "skeuo" ? "terminal" : "skeuo"),
toggle: () => setTheme(theme === "skeuo" ? "web" : "skeuo"),
}; };
} }

View file

@ -1,505 +0,0 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
/* --------------------------------------------------------------- reveal */
export function Reveal({
children,
delay = 0,
className = "",
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.classList.add("is-in");
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
el.classList.add("is-in");
io.disconnect();
}
}
},
{ rootMargin: "0px 0px -12% 0px", threshold: 0.06 }
);
const rect = el.getBoundingClientRect();
const onScreen = rect.top < window.innerHeight && rect.bottom > 0;
if (onScreen) {
// First screenful: stagger in document order so it fills top to bottom.
const all = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
const order = Math.min(Math.max(0, all.indexOf(el)), 8);
el.style.setProperty("--delay", `${delay + order * 90}ms`);
requestAnimationFrame(() => el.classList.add("is-in"));
} else {
io.observe(el);
}
return () => io.disconnect();
}, [delay]);
return (
<div ref={ref} className={`reveal ${className}`} style={{ "--delay": `${delay}ms` } as CSSProperties}>
{children}
</div>
);
}
/* ------------------------------------------------------------- hardware */
export function Led({
kind = "on",
pulse = false,
slow = false,
className = "",
style,
}: {
kind?: "on" | "amber" | "off";
pulse?: boolean;
slow?: boolean;
className?: string;
style?: CSSProperties;
}) {
return (
<i
aria-hidden="true"
className={`w-led w-led--${kind}${pulse ? (slow ? " w-led--pulse-slow" : " w-led--pulse") : ""} ${className}`}
style={style}
/>
);
}
export function Screws({ count = 2, className = "" }: { count?: number; className?: string }) {
return (
<span className={`w-unit__screws ${className}`}>
{Array.from({ length: count }).map((_, i) => (
<i key={i} className="w-screw" />
))}
</span>
);
}
/** Section heading: a coloured chip on the desk + a painted title. */
export function SectionHead({ index, label, title }: { index: string; label: string; title: string }) {
const tones = [
"var(--orange)",
"var(--teal)",
"var(--blue)",
"var(--lilac)",
"var(--mint)",
"var(--coral)",
"var(--yellow)",
];
const n = Number.parseInt(index, 10);
const tone = tones[Number.isFinite(n) ? (n - 1) % tones.length : 0];
return (
<div className="w-sechead">
<span className="w-chip w-sechead__tab" style={{ "--tone": tone } as CSSProperties}>
{index} · {label}
</span>
<h2 className="w-display w-paint w-sechead__title">{title}</h2>
</div>
);
}
/* ----------------------------------------------------------- the machine */
export function Monitor({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<div className={`w-monitor ${className}`}>
<div className="w-monitor__frame">
<div className="w-glass w-monitor__glass">{children}</div>
<div className="w-monitor__chin">
<Led kind="on" />
<span className="w-monitor__brand">DXD</span>
<span className="w-monitor__osd" aria-hidden="true">
<i className="w-osdkey" />
<i className="w-osdkey" />
<i className="w-osdkey" />
</span>
</div>
</div>
<div className="w-monitor__stand" aria-hidden="true">
<i className="w-monitor__neck" />
<i className="w-monitor__base" />
</div>
</div>
);
}
const KEY_ROWS = ["1234567890-=".split(""), "qwertyuiop[]".split(""), "asdfghjkl;'".split("")];
const CAP_TONES = ["var(--mint)", "var(--blue)", "var(--yellow)", "var(--coral)", "var(--lilac)", "var(--teal)"];
export function Keyboard() {
return (
<div className="w-keyboard" aria-hidden="true">
<div className="w-keyboard__deck">
<div className="w-keyboard__row">
<span className="w-key w-key--accent">esc</span>
{KEY_ROWS[0].map((k, i) => (
<span key={k} className="w-key" style={{ "--tone": CAP_TONES[i % CAP_TONES.length] } as CSSProperties}>
{k}
</span>
))}
</div>
{KEY_ROWS.slice(1).map((row, r) => (
<div key={r} className="w-keyboard__row">
{row.map((k, i) => (
<span
key={k}
className="w-key"
style={{ "--tone": CAP_TONES[(i + r + 2) % CAP_TONES.length] } as CSSProperties}
>
{k}
</span>
))}
</div>
))}
<div className="w-keyboard__row">
<span className="w-key w-key--space" />
</div>
<span className="w-keyboard__bar" />
</div>
</div>
);
}
const RACK_TONES = ["var(--mint)", "var(--blue)", "var(--yellow)", "var(--coral)"];
/** A small rack that sits on the desk in the hero. */
export function MiniRack({ label = "DXD-01" }: { label?: string }) {
return (
<div className="w-chassis w-rack" aria-hidden="true">
<div className="w-rack__top">
<Led kind="on" pulse />
<span className="w-display text-[0.68rem] tracking-[0.2em] text-[var(--chassis-ink)]">{label}</span>
<span className="w-rack__vent w-vents" />
</div>
<div className="w-rack__grid">
<span className="w-rails" />
<div>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="w-unit !min-h-[48px] !gap-[10px] !py-2" style={{ "--tone": RACK_TONES[i] } as CSSProperties}>
<span className="w-unit__grip !h-7 !w-3" />
<span className="w-unit__grille !h-7 !w-10" />
<Led kind="on" pulse style={{ animationDelay: `${i * 0.5}s` }} />
<Led kind="amber" pulse slow style={{ animationDelay: `${i * 0.3}s` }} />
<span className="w-display ml-auto text-[0.6rem] tracking-[0.14em] text-[var(--chassis-dim)]">
{["pxe", "aio", "nas", "web"][i]}
</span>
</div>
))}
</div>
<span className="w-rails" />
</div>
</div>
);
}
/** A big coloured sticker with one number on it. */
export function Plaque({ value, label, tone = "var(--orange)" }: { value: string; label: string; tone?: string }) {
return (
<div className="w-sticker w-plaque" style={{ "--tone": tone } as CSSProperties}>
<span className="w-plaque__value">{value}</span>
<span className="w-plaque__label">{label}</span>
<i className="w-plaque__dot" aria-hidden="true" />
</div>
);
}
/* ------------------------------------------------------------- services */
export type ServiceAccess = "public" | "private" | "on_request";
export function ServiceUnit({
name,
desc,
url,
access,
checkUrl,
disabled,
tone = "var(--teal)",
}: {
name: string;
desc: string;
url: string;
access?: ServiceAccess;
checkUrl?: string;
disabled?: boolean;
tone?: string;
}) {
const [status, setStatus] = useState<"pending" | "up" | "down">("pending");
useEffect(() => {
if (disabled) return;
let alive = true;
fetch(`/api/ping?url=${encodeURIComponent(checkUrl || url)}`)
.then((r) => r.json())
.then((d) => {
if (alive) setStatus(d.status === "up" ? "up" : "down");
})
.catch(() => {
if (alive) setStatus("down");
});
return () => {
alive = false;
};
}, [url, checkUrl, disabled]);
const live = disabled ? "up" : status;
const kind = live === "up" ? "on" : live === "down" ? "off" : "amber";
const stateClass = disabled ? "is-wait" : live === "up" ? "is-on" : live === "down" ? "is-off" : "is-wait";
const label = disabled ? "STANDBY" : live === "up" ? "ONLINE" : live === "down" ? "OFFLINE" : "POLLING";
const accessLabel = access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
const host = (() => {
try {
return new URL(url).host;
} catch {
return url;
}
})();
const inner = (
<>
<span className="w-unit__grip" aria-hidden="true" />
<span className="w-unit__grille w-grille" aria-hidden="true" />
<span className="w-unit__id">
<span className="w-unit__name">{name}</span>
<span className="w-tape mt-1.5">
{host}
{accessLabel ? ` · ${accessLabel}` : ""}
</span>
<span className="sr-only">{desc}</span>
</span>
<span className="w-unit__state">
<Led kind={kind} pulse={live === "up"} />
<span className={`w-unit__status ${stateClass}`}>{label}</span>
</span>
<Screws />
</>
);
if (disabled) {
return (
<span className="w-unit" style={{ "--tone": tone } as CSSProperties} aria-disabled="true">
{inner}
</span>
);
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="w-unit w-unit--link"
style={{ "--tone": tone } as CSSProperties}
title={desc}
>
{inner}
</a>
);
}
/* -------------------------------------------------------- activity report */
interface HeatmapPoint {
timestamp: number;
contributions: number;
}
interface Activity {
op_type: string;
content?: string | null;
created: string;
repo?: { full_name?: string };
}
const DOT_OPACITY = [0.12, 0.35, 0.55, 0.75, 1];
export function Report() {
const [weeks, setWeeks] = useState<{ date: string; contribs: number; level: number }[][]>([]);
const [total, setTotal] = useState(0);
const [activities, setActivities] = useState<Activity[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let alive = true;
(async () => {
try {
const res = await fetch("/api/heatmap");
if (!res.ok) throw new Error("bad status");
const data: HeatmapPoint[] = await res.json();
const map = new Map<string, number>();
let sum = 0;
let max = 0;
for (const item of data) {
const d = new Date(item.timestamp * 1000);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const v = (map.get(key) ?? 0) + item.contributions;
map.set(key, v);
if (v > max) max = v;
sum += item.contributions;
}
const end = new Date();
end.setHours(0, 0, 0, 0);
const start = new Date(end);
start.setFullYear(start.getFullYear() - 1);
start.setDate(start.getDate() - start.getDay());
const out: { date: string; contribs: number; level: number }[][] = [];
let week: { date: string; contribs: number; level: number }[] = [];
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const contribs = map.get(key) ?? 0;
let level = 0;
if (contribs > 0) {
level = 1;
if (contribs > max * 0.25) level = 2;
if (contribs > max * 0.5) level = 3;
if (contribs > max * 0.75) level = 4;
}
week.push({ date: key, contribs, level });
if (week.length === 7) {
out.push(week);
week = [];
}
}
if (week.length) out.push(week);
if (!alive) return;
setWeeks(out);
setTotal(sum);
setState("ready");
} catch {
if (alive) setState("error");
}
})();
(async () => {
try {
const res = await fetch("/api/activities");
if (!res.ok) return;
const data: Activity[] = await res.json();
if (alive) {
setActivities(data.filter((d) => d.op_type === "commit_repo" || d.op_type === "push_repo").slice(0, 3));
}
} catch {
/* ignore */
}
})();
return () => {
alive = false;
};
}, []);
return (
<div className="w-printout">
<div className="w-printout__paper">
<p className="w-print__head">
<span>GITDXD · ACTIVITY REPORT · 12 MONTHS</span>
<span className="ml-auto flex items-center gap-3">
<span className="w-barcode w-[110px]" aria-hidden="true" />
<span>{state === "ready" ? "PRINTED LIVE" : state === "error" ? "PRINTER OFFLINE" : "SPOOLING…"}</span>
</span>
</p>
{state === "error" ? (
<p className="w-mono mt-5 text-[0.74rem] text-[var(--ink-soft)]">could not reach the forge.</p>
) : (
<>
<div className="mt-5 flex flex-wrap items-end gap-x-6 gap-y-2">
<div>
<p className="w-kicker">total / 12 months</p>
<p className="w-display mt-1 text-[2.6rem] leading-none text-[var(--ink)]">
{state === "loading" ? "—" : total}
</p>
</div>
<p className="w-mono text-[0.64rem] leading-relaxed text-[var(--ink-soft)]">
each square = one day
<br />
strongest orange = busiest
</p>
</div>
<div className="w-dotgrid w-scroll" aria-hidden={state === "loading"}>
{weeks.map((w, i) => (
<span key={i} className="w-dotgrid__col">
{w.map((d) => (
<span
key={d.date}
className="w-dot"
style={{ opacity: DOT_OPACITY[d.level] }}
title={`${d.contribs} on ${d.date}`}
/>
))}
</span>
))}
</div>
{activities.length > 0 ? (
<div className="w-print__log">
{activities.map((a, i) => {
let msg = "commit";
try {
if (a.content) {
const parsed = JSON.parse(a.content) as {
Commits?: { Message?: string }[];
HeadCommit?: { Message?: string };
};
msg =
parsed.Commits?.[0]?.Message?.split("\n")[0] ??
parsed.HeadCommit?.Message?.split("\n")[0] ??
msg;
}
} catch {
/* keep default */
}
return (
<p key={i} className="truncate">
&gt; {a.repo?.full_name || "unknown"} — {msg}
</p>
);
})}
</div>
) : null}
</>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------- socials */
/** The bundled linkedin.svg is a filled badge, which masks to a plain square. */
function LinkedInGlyph({ className = "size-4" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.77-1.95 4.03 0 4.78 2.5 4.78 5.75V21h-4v-5.6c0-1.34-.03-3.06-1.95-3.06-1.95 0-2.25 1.45-2.25 2.96V21H9z" />
</svg>
);
}
export function SocialGlyph({ name, icon }: { name: string; icon: string }) {
if (name === "LinkedIn") return <LinkedInGlyph />;
return (
<span
aria-hidden="true"
className="social-mask size-4"
style={{ WebkitMaskImage: `url(${icon})`, maskImage: `url(${icon})` } as CSSProperties}
/>
);
}

View file

@ -1,107 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import "../web.css";
export const metadata: Metadata = {
title: "credits · dvorakmortonxd.dev",
description: "Type and other borrowed work used on dvorakmortonxd.dev.",
};
const FONTS = [
{
name: "Nunito",
by: "Vernon Adams, Cyreal, Jacques Le Bailly",
use: "hardware labels, headings, buttons",
href: "https://fonts.google.com/specimen/Nunito",
},
{
name: "Newsreader",
by: "Production Type",
use: "anything printed on paper",
href: "https://fonts.google.com/specimen/Newsreader",
},
{
name: "IBM Plex Sans",
by: "IBM",
use: "body copy",
href: "https://fonts.google.com/specimen/IBM+Plex+Sans",
},
{
name: "IBM Plex Mono",
by: "IBM",
use: "terminal text, receipt, technical labels",
href: "https://fonts.google.com/specimen/IBM+Plex+Mono",
},
];
export default function Credits() {
return (
<div className="w-page">
<main className="mx-auto w-full max-w-[1120px] px-5 pt-24 pb-20 sm:px-8">
<div className="w-sechead">
<span className="w-chip w-sechead__tab" style={{ "--tone": "var(--mint)" } as React.CSSProperties}>
credits
</span>
<h1 className="w-display w-paint w-sechead__title">Everything borrowed, credited.</h1>
</div>
<p className="w-prose w-paint max-w-[62ch] text-[var(--ink-soft)]">
The desk contains <strong>no images at all</strong> — every surface, screw, LED, paper sheet and coffee
mug is drawn in CSS. That means there are no photography or texture licences to track: the only
borrowed work is type.
</p>
<section className="mt-16">
<div className="w-sechead !mb-6">
<span className="w-chip w-sechead__tab" style={{ "--tone": "var(--teal)" } as React.CSSProperties}>
type
</span>
<h2 className="w-display w-paint w-sechead__title !text-[1.3rem]">
Google Fonts — SIL Open Font License 1.1
</h2>
</div>
<ul className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{FONTS.map((f, i) => (
<li
key={f.name}
className="w-paper w-cardindex"
style={{ "--tone": ["var(--orange)", "var(--blue)", "var(--lilac)", "var(--mint)"][i % 4] } as React.CSSProperties}
>
<p className="w-display text-[1.05rem] text-[var(--ink)]">{f.name}</p>
<p className="w-mono mt-2 text-[0.64rem] leading-relaxed text-[var(--ink-soft)]">
by {f.by}
<br />
{f.use}
<br />
<a className="w-link" href={f.href} target="_blank" rel="noopener noreferrer">
fonts.google.com
</a>{" "}
· OFL 1.1
</p>
</li>
))}
</ul>
</section>
<section className="mt-16">
<div className="w-sechead !mb-6">
<span className="w-chip w-sechead__tab" style={{ "--tone": "var(--lilac)" } as React.CSSProperties}>
notes
</span>
<h2 className="w-display w-paint w-sechead__title !text-[1.3rem]">Other marks</h2>
</div>
<p className="w-prose w-paint max-w-[62ch] text-[var(--ink-soft)]">
The patch panel uses brand marks that remain the property of their owners and are used only to
link to those accounts. Everything written on the paper — and every pixel of the desk itself — is
mine. The desktop mode (the KDE simulation) uses its own separate assets.
</p>
</section>
<p className="mt-16">
<Link href="/" className="w-link w-mono text-[0.78rem]">
← back to the desk
</Link>
</p>
</main>
</div>
);
}

View file

@ -270,3 +270,6 @@ export const friends: Friend[] = [
export const WALLPAPER = "/wallpaper.jpg"; export const WALLPAPER = "/wallpaper.jpg";
export const WALLPAPER_CREDIT = "https://wallpapercave.com/kde-plasma-wallpapers"; 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

@ -1,297 +1,26 @@
@import "tailwindcss"; @import "tailwindcss";
/* ============================================================
WEB THEME (default)
------------------------------------------------------------
A "technical print" layout painted in the gruvbox dark-hard
palette. Tokens live here; the page markup uses Tailwind
utilities plus the @utility classes below. Everything visual
is scoped to html[data-theme="web"] so the skeuo desktop
(which reads its own vars from skeuo.css) is never affected.
============================================================ */
:root { :root {
/* gruvbox dark-hard */ --background: #1d2021;
--bg: #1d2021; --foreground: #ebdbb2;
--bg-raised: #282828;
--bg-well: #161819;
--ink: #fbf1c7;
--body: #d5c4a1;
--muted: #a89984;
--faint: #928374;
/* one accent (orange); other gruvbox hues are for status only */
--accent: #fe8019;
--accent-strong: #d65d0e;
--negative: #fb4934;
--positive: #b8bb26;
--rule: rgba(235, 219, 178, 0.16);
--rule-soft: rgba(235, 219, 178, 0.09);
--rule-strong: rgba(235, 219, 178, 0.3);
--fill: rgba(235, 219, 178, 0.035);
--fill-strong: rgba(235, 219, 178, 0.07);
--ease-cinematic: cubic-bezier(0.22, 1, 0.36, 1);
}
/* Beats page.css's :root when the web theme is active. The desk is a light,
colourful, cartoon-skeuomorphic surface — the dark gruvbox values in :root
are only the fallback for the desktop mode. */
html[data-theme="web"] {
color-scheme: light;
--bg: #f7e7c6;
--bg-raised: #fffdf6;
--bg-well: #eed9b0;
--accent: #f4823a;
--accent2: #d4611c;
--accent3: #f2c14e;
--green: #58bd72;
--red: #ea6f6a;
--gray: #7f7768;
} }
@theme inline { @theme inline {
--color-ink: var(--ink); --color-background: var(--background);
--color-body: var(--body); --color-foreground: var(--foreground);
--color-muted: var(--muted); --font-sans: var(--font-geist-sans);
--color-faint: var(--faint); --font-mono: var(--font-geist-mono);
--color-accent: var(--accent);
--color-accent-strong: var(--accent-strong);
--color-positive: var(--positive);
--color-negative: var(--negative);
--color-bg: var(--bg);
--font-sans: var(--font-plex);
--font-mono: var(--font-plexmono);
--font-display: var(--font-news);
} }
html[data-theme="web"] { @media (prefers-color-scheme: dark) {
scroll-behavior: smooth; :root {
scroll-padding-top: 5.5rem; --background: #1d2021;
background-color: var(--bg); --foreground: #ebdbb2;
}
html[data-theme="web"] ::selection {
background: rgba(244, 130, 58, 0.3);
color: var(--ink);
}
html[data-theme="web"] :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
html[data-theme="web"] ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
html[data-theme="web"] ::-webkit-scrollbar-track {
background: var(--bg-well);
}
html[data-theme="web"] ::-webkit-scrollbar-thumb {
background: #d9c49b;
border: 3px solid var(--bg-well);
border-radius: 99px;
}
html[data-theme="web"] ::-webkit-scrollbar-thumb:hover {
background: #c9b184;
}
/* Phones never get the desktop-mode button. */
html[data-device="phone"] .desktop-toggle {
display: none !important;
}
/* ------------------------------------------------------------------
Type
------------------------------------------------------------------ */
@utility display {
font-family: var(--font-news), Georgia, "Times New Roman", serif;
font-weight: 500;
letter-spacing: -0.012em;
line-height: 1.12;
color: var(--ink);
}
@utility serif-num {
font-family: var(--font-news), Georgia, serif;
font-weight: 500;
font-variant-numeric: lining-nums tabular-nums;
color: var(--ink);
}
/* run-in metadata label: mono, sentence case — deliberately not all-caps */
@utility label {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.78rem;
font-weight: 400;
letter-spacing: 0.01em;
color: var(--muted);
}
@utility lede {
font-size: 1.0625rem;
line-height: 1.7;
color: var(--body);
}
@utility measure {
max-width: 34rem;
}
@utility measure-wide {
max-width: 46rem;
}
/* ------------------------------------------------------------------
Surfaces — restrained translucency, hairline rules
------------------------------------------------------------------ */
@utility shell {
width: 100%;
max-width: 1840px;
margin-inline: auto;
padding-inline: clamp(1.25rem, 3.5vw, 5rem);
}
@utility glass {
background-color: var(--fill);
border: 1px solid var(--rule);
border-radius: 10px;
backdrop-filter: blur(10px);
}
@utility glass-soft {
background-color: transparent;
border: 1px solid var(--rule);
border-radius: 6px;
}
@utility glass-sunken {
background-color: var(--bg-well);
border: 1px solid var(--rule-soft);
border-radius: 8px;
}
@utility glass-bar {
background-color: rgba(29, 32, 33, 0.94);
border-bottom: 1px solid rgba(235, 219, 178, 0.22);
backdrop-filter: blur(12px) saturate(120%);
}
@utility glass-press-sm {
background-color: transparent;
border: 1px solid var(--rule);
border-radius: 8px;
transition-property: background-color, border-color, color;
transition-duration: 160ms;
transition-timing-function: ease-out;
&:hover {
background-color: var(--fill-strong);
border-color: var(--rule-strong);
color: var(--ink);
}
&:active {
background-color: rgba(0, 0, 0, 0.25);
} }
} }
@utility rule { body {
border-top: 1px solid var(--rule); background: var(--background);
} color: var(--foreground);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@utility rule-strong {
border-top: 1px solid var(--rule-strong);
}
@utility rule-soft {
border-top: 1px solid var(--rule-soft);
}
@utility index-num {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.78rem;
color: var(--faint);
font-variant-numeric: tabular-nums;
}
/* ------------------------------------------------------------------
Motion — scroll reveals only
------------------------------------------------------------------ */
/* Scroll reveal. Hidden until `.is-in` lands, and every block on the editorial
page is wrapped, so the page fills in from top to bottom: the first screenful
cascades in document order on load, everything else reveals as it scrolls in.
A failed JS bundle leaves the page visible via the <noscript> rule in layout. */
.reveal {
opacity: 0;
transform: translate3d(0, 18px, 0);
transition:
opacity 0.8s var(--ease-cinematic),
transform 0.8s var(--ease-cinematic);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
transform: none;
}
@keyframes blink {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
.animate-blink {
animation: blink 1.05s step-end infinite;
}
@media (prefers-reduced-motion: reduce) {
html[data-theme="web"] {
scroll-behavior: auto;
}
.reveal {
opacity: 1 !important;
transform: none !important;
transition: none;
}
.animate-blink {
animation: none !important;
}
* {
transition-duration: 0.01ms !important;
}
}
/* ------------------------------------------------------------------
Helpers
------------------------------------------------------------------ */
html[data-theme="web"] .link {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: rgba(254, 128, 25, 0.4);
transition: text-decoration-color 150ms ease-out;
}
html[data-theme="web"] .link:hover {
text-decoration-color: var(--accent);
}
html[data-theme="web"] .hide-scrollbar {
scrollbar-width: none;
}
html[data-theme="web"] .hide-scrollbar::-webkit-scrollbar {
display: none;
} }

View file

@ -1,36 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Newsreader, IBM_Plex_Sans, IBM_Plex_Mono, Nunito } from "next/font/google"; // Removed unused google fonts since we use local Custom Mono
import "./globals.css"; import "./globals.css";
import ThemeScript from "./components/ThemeScript"; import ThemeScript from "./components/ThemeScript";
// The web desk pairs a rounded sans (hardware labels, headings) with a printed
// serif (anything on paper) and a mono (terminal text + technical labels).
const news = Newsreader({
variable: "--font-news",
subsets: ["latin"],
weight: ["400", "500", "600"],
style: ["normal", "italic"],
display: "swap",
});
const plex = IBM_Plex_Sans({
variable: "--font-plex",
subsets: ["latin"],
weight: ["400", "500", "600"],
display: "swap",
});
const plexMono = IBM_Plex_Mono({
variable: "--font-plexmono",
subsets: ["latin"],
weight: ["400", "500"],
display: "swap",
});
const round = Nunito({
variable: "--font-round",
subsets: ["latin"],
weight: ["600", "700", "800"],
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "dvorakmortonxd.dev", title: "dvorakmortonxd.dev",
description: "Martin Dvořák's personal website.", description: "Martin Dvořák's personal website.",
@ -44,7 +16,7 @@ export const metadata: Metadata = {
card: "summary_large_image", card: "summary_large_image",
title: "dvorakmortonxd.dev", title: "dvorakmortonxd.dev",
description: "Martin Dvořák's personal website.", description: "Martin Dvořák's personal website.",
}, }
}; };
export default function RootLayout({ export default function RootLayout({
@ -53,18 +25,8 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="en" data-theme="skeuo" suppressHydrationWarning>
lang="en"
data-theme="web"
suppressHydrationWarning
className={`${news.variable} ${plex.variable} ${plexMono.variable} ${round.variable}`}
>
<body className="antialiased"> <body className="antialiased">
<noscript>
{/* Scroll-reveal is progressive enhancement: without JS every block
must still be visible, since the base `.reveal` style is opacity 0. */}
<style>{`.reveal{opacity:1 !important;transform:none !important}`}</style>
</noscript>
<ThemeScript /> <ThemeScript />
{children} {children}
</body> </body>

View file

@ -44,11 +44,11 @@
--radius: 0px; --radius: 0px;
} }
/* RESET — margins/padding are zeroed by Tailwind's preflight, which lives in /* RESET */
@layer base. Declaring them here (unlayered) would out-rank every Tailwind
spacing utility, so this only sets box-sizing. */
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0;
padding: 0;
} }
html { html {

View file

@ -1,7 +1,6 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import './page.css'; import './page.css';
import './skeuo.css'; import './skeuo.css';
import './web.css';
import ThemeRoot from './components/ThemeRoot'; import ThemeRoot from './components/ThemeRoot';
export const metadata: Metadata = { export const metadata: Metadata = {

View file

@ -2,9 +2,9 @@
SKEUOMORPHIC THEME SKEUOMORPHIC THEME
------------------------------------------------------------ ------------------------------------------------------------
Desktop -> KDE Plasma (Breeze) Desktop -> KDE Plasma (Breeze)
Active when <html data-theme="skeuo">. The web editorial Mobile -> iOS-style home screen
site is the default; this is the alternate, reachable from Active when <html data-theme="skeuo">. The terminal theme is
the editorial page's Desktop-mode button or the tray. the alternate, reachable from the tray / launcher / app.
============================================================ */ ============================================================ */
html[data-theme="skeuo"] { html[data-theme="skeuo"] {
@ -27,8 +27,17 @@ html[data-theme="skeuo"] body {
font-family: var(--font-custom-mono); font-family: var(--font-custom-mono);
} }
/* The desktop root and its visibility are decided in JS (ThemeRoot); phones .skeuo-desktop-root { display: block; }
never mount it, and the CSS `.desktop-toggle` rule hides the entry button. */ .skeuo-mobile-root { display: none; }
@media (max-width: 640px) {
.skeuo-desktop-root { display: none; }
.skeuo-mobile-root { display: block; }
}
/* Real phones always get the mobile UI, even in landscape. */
html[data-device="phone"] .skeuo-desktop-root { display: none; }
html[data-device="phone"] .skeuo-mobile-root { display: block; }
/* ============================================================ /* ============================================================
DESKTOP (Plasma) DESKTOP (Plasma)
@ -1621,12 +1630,598 @@ a.win-icon {
background: rgba(61, 174, 233, 0.3); background: rgba(61, 174, 233, 0.3);
} }
/* ============================================================
MOBILE -> iOS-style home screen
============================================================ */
.mobile-root {
display: block;
}
.mobile-home {
position: fixed;
inset: 0;
height: 100dvh;
overflow: hidden;
background-color: #101319;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
color: #ffffff;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Roboto,
"Helvetica Neue", Arial, sans-serif;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}
.mobile-home::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.4),
rgba(0, 0, 0, 0.12) 28%,
rgba(0, 0, 0, 0.25) 62%,
rgba(0, 0, 0, 0.6)
);
}
.mobile-statusbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 34px;
z-index: 55;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.mobile-status-time {
font-size: 0.94rem;
font-weight: 600;
letter-spacing: 0;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.mobile-status-icons {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mobile-status-icons svg {
display: block;
}
/* 2x1 iOS-style clock widget */
.mobile-widget {
grid-column: span 2;
grid-row: span 1;
border-radius: 22px;
padding: 10px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 1px;
overflow: hidden;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.22);
background-color: rgba(28, 30, 38, 0.42);
background-image: linear-gradient(
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.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: 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.66rem;
font-weight: 500;
line-height: 1.1;
letter-spacing: 0.03em;
text-transform: uppercase;
opacity: 0.75;
}
.mobile-grid {
position: absolute;
top: 46px;
left: 0;
right: 0;
bottom: 112px;
z-index: 10;
overflow-y: auto;
padding: 6px 14px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 86px;
gap: 14px 6px;
align-content: start;
}
.mobile-app {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
min-width: 0;
touch-action: none;
}
.mobile-app.dragging {
opacity: 0.55;
}
.mobile-app-icon {
position: relative;
width: 58px;
height: 58px;
border-radius: 21px;
display: grid;
place-items: center;
font-size: 27px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.24);
background-color: rgba(22, 24, 30, 0.42);
background-image: linear-gradient(
160deg,
rgba(255, 255, 255, 0.38),
rgba(255, 255, 255, 0.08) 44%,
rgba(0, 0, 0, 0.08) 72%,
rgba(0, 0, 0, 0.3)
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 -9px 16px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.42);
-webkit-backdrop-filter: blur(16px) saturate(140%);
backdrop-filter: blur(16px) saturate(140%);
}
.mobile-app-glyph {
position: relative;
z-index: 1;
filter: saturate(0.35) contrast(1.02);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
}
.mobile-app-icon::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.3),
rgba(255, 255, 255, 0) 48%
);
pointer-events: none;
}
.mobile-app-icon::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.3),
rgba(255, 255, 255, 0) 48%
);
pointer-events: none;
}
.mobile-app-label {
max-width: 100%;
font-size: 0.66rem;
font-weight: 400;
line-height: 1.15;
text-align: center;
overflow-wrap: anywhere;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
.mobile-dock {
position: absolute;
left: 12px;
right: 12px;
bottom: 30px;
z-index: 15;
display: flex;
align-items: center;
justify-content: space-evenly;
gap: 8px;
padding: 11px 12px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 10px 30px rgba(0, 0, 0, 0.35);
-webkit-backdrop-filter: blur(22px) saturate(150%);
backdrop-filter: blur(22px) saturate(150%);
}
.mobile-app.in-dock .mobile-app-icon {
width: 54px;
height: 54px;
border-radius: 19px;
font-size: 25px;
}
.mobile-pagedots {
position: absolute;
left: 0;
right: 0;
bottom: 14px;
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.mobile-pagedots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
}
.mobile-pagedots span.active {
background: rgba(255, 255, 255, 0.9);
}
.mobile-app-view {
position: fixed;
inset: 0;
height: 100dvh;
z-index: 40;
display: flex;
flex-direction: column;
background: rgba(10, 13, 19, 0.66);
color: #eef1f6;
font-family: Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-backdrop-filter: blur(30px) saturate(160%);
backdrop-filter: blur(30px) saturate(160%);
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;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 12px 10px;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-webkit-backdrop-filter: blur(20px) saturate(150%);
backdrop-filter: blur(20px) saturate(150%);
}
.mobile-back {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: #eef1f6;
font-size: 1.2rem;
cursor: pointer;
}
.mobile-back:hover {
background: rgba(255, 255, 255, 0.12);
}
.mobile-appbar-title {
font-size: 1.1rem;
font-weight: 600;
color: #ffffff;
}
.mobile-app-body {
flex: 1;
overflow-y: auto;
background: transparent;
}
.mobile-app-body.app-surface {
--white: #eef1f6;
--gray: #a7b0bd;
--accent: #7cc4ff;
--accent2: #a99bff;
--accent3: #7cc4ff;
--green: #5bd67a;
--aqua: #4fd6c9;
--blue: #68b9ff;
--purple: #b79bff;
--red: #ff7a7a;
--darkpink: #ff86c8;
--fg: #eef1f6;
color: #eef1f6;
}
.mobile-app-body .project-card,
.mobile-app-body .service-button,
.mobile-app-body .social-button,
.mobile-app-body .gym-item {
border-radius: 14px;
}
/* ---- Dark glassmorphism content (mobile apps only) ---- */
.mobile-app-body .app-cmd {
color: #aeb7c4;
}
.mobile-app-body .app-user {
color: #5bd67a;
}
.mobile-app-body .app-path {
color: #7cc4ff;
}
.mobile-app-body .app-text,
.mobile-app-body .app-list {
color: #c7cfda;
}
.mobile-app-body .about-content,
.mobile-app-body .about-content > div {
color: #c7cfda !important;
}
.mobile-app-body a {
color: #7cc4ff;
}
.mobile-app-body .mail-contact {
color: #c7cfda;
}
.mobile-app-body .mail-contact a {
color: #7cc4ff;
}
.mobile-app-body .app-h1,
.mobile-app-body .app-h2,
.mobile-app-body .app-welcome-head h2 {
color: #ffffff;
}
.mobile-app-body .app-muted {
color: #98a2b0;
}
.mobile-app-body .app-welcome-head img {
border-color: rgba(255, 255, 255, 0.18);
}
.mobile-app-body .app-shot {
border-color: rgba(255, 255, 255, 0.16);
}
.mobile-app-body .app-linkrow a {
color: #eef1f6;
}
/* Glass cards */
.mobile-app-body .social-button,
.mobile-app-body .service-button,
.mobile-app-body .project-card,
.mobile-app-body .gym-item,
.mobile-app-body .app-linkrow a {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.07);
color: #eef1f6;
border-radius: 16px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.16),
0 10px 24px rgba(0, 0, 0, 0.35);
-webkit-backdrop-filter: blur(16px) saturate(150%);
backdrop-filter: blur(16px) saturate(150%);
}
.mobile-app-body .social-button:hover,
.mobile-app-body .service-button:hover,
.mobile-app-body .project-card:hover,
.mobile-app-body .gym-item:hover,
.mobile-app-body .app-linkrow a:hover {
background: rgba(255, 255, 255, 0.13);
border-color: rgba(124, 196, 255, 0.6);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 0 0 1px rgba(124, 196, 255, 0.35),
0 12px 28px rgba(0, 0, 0, 0.4);
}
.mobile-app-body .social-icon {
opacity: 0.95;
filter: brightness(1.1);
}
.mobile-app-body .service-name {
color: #ffffff;
}
.mobile-app-body .service-desc {
color: #a7b0bd;
}
.mobile-app-body .service-button:hover .service-desc {
color: #dfe6ef;
}
.mobile-app-body .friend-icon {
filter: invert(1) brightness(1.5);
}
.mobile-app-body .project-image {
border-color: rgba(255, 255, 255, 0.12);
}
.mobile-app-body .project-title {
color: #ffffff;
}
.mobile-app-body .project-card:hover .project-title {
color: #7cc4ff;
}
.mobile-app-body .project-desc {
color: #a7b0bd;
}
.mobile-app-body .project-card:hover .project-desc {
color: #dfe6ef;
}
.mobile-app-body .gym-label {
color: #a7b0bd;
}
.mobile-app-body .gym-weight {
color: #ffffff;
}
.mobile-app-body .gym-date {
color: #8b95a3;
}
/* Glass heatmap */
.mobile-app-body .heatmap-container {
border-color: rgba(124, 196, 255, 0.5);
background: rgba(255, 255, 255, 0.06);
border-radius: 12px;
}
.mobile-app-body .heatmap-cell,
.mobile-app-body .heatmap-legend-cell {
background-color: rgba(255, 255, 255, 0.08);
}
.mobile-app-body .heatmap-cell[data-level="1"],
.mobile-app-body .heatmap-legend-cell[data-level="1"] {
background-color: rgba(124, 196, 255, 0.3);
}
.mobile-app-body .heatmap-cell[data-level="2"],
.mobile-app-body .heatmap-legend-cell[data-level="2"] {
background-color: rgba(124, 196, 255, 0.55);
}
.mobile-app-body .heatmap-cell[data-level="3"],
.mobile-app-body .heatmap-legend-cell[data-level="3"] {
background-color: rgba(124, 196, 255, 0.8);
}
.mobile-app-body .heatmap-cell[data-level="4"],
.mobile-app-body .heatmap-legend-cell[data-level="4"] {
background-color: #7cc4ff;
}
.mobile-app-body .heatmap-cell:hover {
box-shadow: 0 0 8px rgba(124, 196, 255, 0.9);
}
.mobile-app-body .contribution-command,
.mobile-app-body .contribution-entry,
.mobile-app-body .contribution-total-title,
.mobile-app-body .contribution-message {
color: #eef1f6;
}
.mobile-app-body .contribution-repo {
color: #a7b0bd;
}
.mobile-app-body .contribution-date {
color: #5bd67a;
}
@media (max-width: 640px) {
.app-pad {
padding: 14px;
}
.app-surface .project-grid {
grid-template-columns: 1fr;
}
.app-surface .social-buttons {
grid-template-columns: 1fr 1fr;
}
.app-surface .services-buttons {
grid-template-columns: 1fr;
}
.app-surface .gym-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.app-surface .project-image {
height: 140px;
}
}
@media (max-width: 380px) {
.mobile-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* Phones in landscape: compact the home so the app grid (and its labels) fit. */
@media (orientation: landscape) and (max-height: 620px) {
.mobile-statusbar { height: 28px; font-size: 0.72rem; padding: 0 16px; }
.mobile-status-time { font-size: 0.84rem; }
.mobile-grid {
top: 30px;
bottom: 76px;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: 60px;
gap: 8px 4px;
padding: 4px 16px;
}
.mobile-app { gap: 2px; }
.mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
.mobile-app-label { font-size: 0.55rem; }
.mobile-widget { border-radius: 14px; padding: 5px 8px; }
.mobile-widget-time { font-size: 1rem; }
.mobile-widget-date { font-size: 0.5rem; }
.mobile-dock { bottom: 10px; padding: 6px 10px; border-radius: 22px; }
.mobile-app.in-dock .mobile-app-icon { width: 38px; height: 38px; border-radius: 13px; font-size: 18px; }
.mobile-pagedots { display: none; }
}
/* Respect reduced-motion preferences without breaking animationend callbacks /* Respect reduced-motion preferences without breaking animationend callbacks
(a near-zero duration still fires them so closing windows/apps unmount). */ (a near-zero duration still fires them so closing windows/apps unmount). */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.win-window, .win-window,
.win-window.closing, .win-window.closing,
.kickoff { .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; animation-duration: 0.01ms !important;
} }
.win-icon { .win-icon {

File diff suppressed because it is too large Load diff