rework the site: gruvbox editorial default with a desktop-mode toggle; drop the terminal layout and phone desktop

This commit is contained in:
Martin Dvořák 2026-09-30 07:49:31 +00:00
commit 86692637de
19 changed files with 1350 additions and 1664 deletions

View file

@ -2,27 +2,30 @@
> The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**. > The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**.
This is not a normal portfolio page. It ships **two fully interactive shells** and picks It's an **editorial-style personal site** in a **gruvbox** palette, 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 "Desktop mode" button. Two modes, one content set:
- **`skeuo`** (default) — a KDE Plasma / Windows-style **desktop simulation**: draggable - **Gruvbox editorial** (default) — a technical-print layout: serif headlines, mono labels in
snap-to-grid icons, resizable/floating windows, a Start menu, taskbar and live clock. the margin, hairline rules and a data strip. No images at all; the only graphics are the
On phones it becomes an **iOS-style home screen** with a widget grid, dock, drag-to-reorder social icons, used as monochrome CSS masks.
and an iOS launch animation. - **Desktop mode** — the KDE Plasma / Windows-style **desktop simulation**: draggable
- **`terminal`** — a **Gruvbox TUI** version of the site: TUI panels, an animated ASCII snap-to-grid icons, resizable/floating windows, a Start menu, taskbar, live clock and a
train background, a clickable easter-egg logo, and a working in-browser terminal (Alacritty) working in-browser terminal (Alacritty). Reachable from the "Desktop mode" button and hidden
with commands like `help`, `ls`, `cat`, `neofetch`, `emerge`, `ssh` and more. on phones.
Both shells surface the same personal content: socials, self-hosted services with **live Both modes 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**, a gym SBD panel and links to friends' sites. heatmap**, gym SBD and links to friends' sites.
## ✨ Features ## ✨ Features
- **Two themes / shells** — KDE-style desktop and Gruvbox terminal, toggled from either UI - **Gruvbox editorial by default** — a technical-print layout: serif headlines, mono labels in
and persisted in `localStorage` (applied pre-paint to avoid a flash). the margin, hairline rules, a data strip. Text and type only — the sole graphics are the
- **Phone vs. desktop detection** — a user-agent sniff on `<html data-device>` decides whether social icons, rendered as monochrome CSS masks.
the desktop or the mobile home screen is shown. - **Desktop mode (desktop-only)** — a "Desktop mode" button swaps to the KDE-style desktop;
the choice persists in `localStorage` (applied pre-paint to avoid a flash). Phones never get
it: the pre-paint script forces gruvbox and CSS hides the button, 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.
@ -58,13 +61,14 @@ heatmap**, a gym SBD panel 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:** hand-written CSS — `src/app/page.css` (terminal theme), `src/app/skeuo.css` - **Styling:** `src/app/globals.css` is the gruvbox editorial design system (CSS custom
(desktop + mobile), `src/app/globals.css` (base/reset). Tailwind v4 is imported for the properties, Tailwind v4 `@theme`/`@utility`, base + scroll reveals). `editorial.css` holds
base layer but the UI is custom CSS. page helpers; `page.css` keeps the shared component styles the desktop apps still use, and
- **Fonts:** Monocraft (self-hosted in `public/`, used by the terminal theme; the desktop `skeuo.css` is the KDE desktop. The editorial page itself is built from Tailwind utilities.
theme uses the system sans stack) - **Fonts:** Newsreader (display) + IBM Plex Sans/Mono (body/labels) via `next/font/google`;
- **Images:** `next/image` the desktop keeps its system sans stack.
- **Version:** `2.1.0` (see `package.json`) - **Images:** `next/image` — but the editorial page uses none (social SVGs as monochrome masks only).
- **Version:** `3.0.0` (see `package.json`)
## 🚀 Running Locally ## 🚀 Running Locally
@ -164,33 +168,34 @@ they contend for `:3000` and `.next/dev/lock`.
``` ```
src/app/ src/app/
page.tsx → renders <ThemeRoot/>, imports the two theme stylesheets page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell + metadata, injects ThemeScript layout.tsx → html shell + metadata; loads the fonts; injects ThemeScript
globals.css → base/reset styles + Tailwind import globals.css → gruvbox editorial design system (tokens, @utility classes, base)
page.css → terminal theme styles editorial.css → editorial page helpers (social masks, heatmap)
skeuo.css → desktop + mobile home styles page.css → shared component styles still used by the desktop apps
skeuo.css → KDE desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers data.ts → socials, codingProjects, techProjects, friends, wallpapers
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 → picks which shell(s) to mount ThemeRoot.tsx → gruvbox editorial vs. Desktop mode
ThemeScript.tsx → pre-paint script: theme + device detection ThemeScript.tsx → pre-paint script: theme + device detection
useTheme.ts → theme state via useSyncExternalStore useTheme.ts → theme state via useSyncExternalStore
TerminalLayout.tsx → the Gruvbox terminal shell EditorialSite.tsx → the gruvbox editorial page (nav, hero, sections)
editorial-ui.tsx → editorial primitives: Section, ServiceRow, Contributions, …
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 (mobile grid/dock) useDragOrder.ts → list reordering (used inside apps)
ServiceButton.tsx → service link + live status dot ServiceButton.tsx → desktop service link + live status dot
ContributionHeatmap.tsx → heatmap + recent commits widget ContributionHeatmap.tsx → desktop 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/

View file

@ -33,16 +33,18 @@ 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**. It's not a normal website — Personal portfolio site for **dvorakmortonxd.dev**. The default view is a
it ships **two fully interactive desktop/OS simulations** plus a terminal, and **gruvbox editorial page** (a technical-print layout, no images); a "Desktop mode"
picks which one to show based on theme and device. button swaps to a **KDE Plasma desktop simulation**. Phones only ever get the
editorial page.
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript. - Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
- Styling: plain CSS files (`page.css`, `skeuo.css`), no CSS-in-JS, no Tailwind - Styling: **Tailwind v4** for the editorial page — `globals.css` is the design
utilities in the components (Tailwind is present via PostCSS but the look is system (CSS custom properties, `@theme` tokens, `@utility` classes) — plus plain
hand-written CSS). CSS for the desktop (`skeuo.css`) and the shared component styles the desktop
apps still use (`page.css`).
- Path alias: `@/*` → `src/*`. - Path alias: `@/*` → `src/*`.
- Version: `2.1.0` (see `package.json`). - Version: `3.0.0` (see `package.json`).
### Scripts ### Scripts
```bash ```bash
@ -73,12 +75,13 @@ token.** Do not print it, copy it into new files, or push it anywhere else.
``` ```
src/app/ src/app/
page.tsx → renders <ThemeRoot/>, imports page.css + skeuo.css page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell, metadata, ThemeScript layout.tsx → html shell, metadata, next/font, ThemeScript
globals.css → base/reset styles + Tailwind import globals.css → gruvbox editorial design system (tokens, @utility, base, reveals)
editorial.css → editorial page helpers (social masks, heatmap)
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
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
@ -86,38 +89,44 @@ 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 → picks which shell to show (the router of the UI) ThemeRoot.tsx → editorial page by default, desktop when theme === "skeuo"
TerminalLayout.tsx → "terminal" theme (Gruvbox) EditorialSite.tsx → the gruvbox editorial page (nav, hero, sections)
editorial-ui.tsx → editorial primitives: Reveal, Section, ServiceRow, Contributions, …
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 (mobile home screen only) useDragOrder.ts → simple list reordering (used inside desktop apps)
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 → service link + live status dot (pings /api/ping) ServiceButton.tsx → desktop service link + live status dot (pings /api/ping)
ContributionHeatmap.tsx → heatmap + recent-commit widget (uses the two proxies) ContributionHeatmap.tsx → desktop 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. It reads `localStorage.theme` and sets 1. `ThemeScript` runs before paint. It reads `localStorage.theme` and sets
`data-theme` (`skeuo` | `terminal`) and `data-device` (`phone` | `desktop`) `data-theme` (`gruvbox` | `skeuo`) and `data-device` (`phone` | `desktop`) on
on `<html>` from a user-agent sniff. `<html>` from a user-agent sniff. On a phone it forces `gruvbox` and CSS hides
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via every `.desktop-toggle`, so the desktop is unreachable there.
`useSyncExternalStore` and dispatches a `theme-change` event on toggle. 2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`;
3. `ThemeRoot` shows the terminal when `theme === "terminal"`; otherwise it `setTheme` writes the attribute, persists to `localStorage`, and dispatches
mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS `theme-change`.
(`html[data-device="phone"]`) decide which is visible. The desktop stays 3. `ThemeRoot` renders `EditorialSite` unless `theme === "skeuo"`, in which case it
mounted while hidden so the Welcome window isn't recreated. mounts `WindowsDesktop` instead. The editorial page stays mounted (hidden) while
the desktop is up so its scroll position survives; the desktop mounts on demand
(phones can never reach `skeuo`).
Themes: `skeuo` = KDE Plasma / Windows desktop, `terminal` = Gruvbox terminal. Modes: `gruvbox` = the default editorial site, `skeuo` = KDE Plasma desktop.
The editorial page is **asset-free** apart from the social SVGs, which are used as
monochrome CSS masks (`.social-mask` in `editorial.css`). `linkedin.svg` is a filled
badge that masks to a plain square, so `editorial-ui.tsx` renders an inline mark for
it instead. The desktop still uses the image assets.
--- ---
@ -180,9 +189,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 only by `MobileHome` for `useDragOrder.ts` is a **different, simpler** hook used inside the desktop apps
reordering the phone app grid/dock (drag one item over another to swap). It is for reordering lists (drag one item over another to swap). It is list reordering,
list reordering, not free placement. Don't confuse the two. not free placement. Don't confuse the two.
--- ---
@ -216,11 +225,12 @@ list reordering, 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), `mobile-` (phone), - CSS classes are prefixed by area: `win-` (desktop), `app-` / `kickoff` etc.
`app-` / `kickoff` / `terminal` etc. Grep by prefix to find styles. (desktop windows), plus the editorial design-system classes (`shell`, `display`,
- Several places deliberately use **imperative DOM work** (drag ghost, `label`, `rule*`, `glass*`). Grep by prefix to find styles.
transform-origin for the mobile launch animation). This is intentional for - Several places deliberately use **imperative DOM work** (e.g. the drag ghost).
performance; prefer matching the existing pattern over "Reactifying" it. 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": "2.1.0", "version": "3.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node scripts/dev-server.mjs", "dev": "node scripts/dev-server.mjs",

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 components/TerminalLayout.tsx, and with the // values in components/apps.tsx and the services list in
// friend list in data.ts. // components/EditorialSite.tsx, and with the 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,453 @@
"use client";
import { useState } from "react";
import { useTheme } from "./useTheme";
import { codingProjects, friends, socials, techProjects } from "../data";
import {
Contributions,
Reveal,
Section,
SectionHeading,
ServiceRow,
SocialGlyph,
Stat,
TerminalCard,
type ServiceAccess,
} from "./editorial-ui";
/* --------------------------------------------------------------- content */
const NAV = [
{ href: "#about", label: "About" },
{ href: "#services", label: "Services" },
{ href: "#projects", label: "Projects" },
{ href: "#hardware", label: "Hardware" },
{ href: "#friends", label: "Friends" },
{ href: "#contact", label: "Contact" },
];
const SERVICES: {
name: string;
desc: string;
url: string;
access?: ServiceAccess;
checkUrl?: string;
disabled?: boolean;
}[] = [
{ name: "Proxmox", desc: "Server management — private.", url: "https://proxmox.dvorakmortonxd.dev/", access: "private", disabled: true },
{ name: "Llama.cpp", desc: "Local AI server on a GPU-passthrough RTX 3070.", url: "https://ai.dvorakmortonxd.dev/", access: "on_request" },
{ name: "Jellyfin", desc: "Self-hosted media streaming.", url: "https://jelly.dvorakmortonxd.dev/", access: "on_request" },
{ name: "SMP", desc: "My survival Minecraft server.", url: "https://discord.gg/vyvhk7d8bX", checkUrl: "minecraft://mc.dvorakmortonxd.dev:25565", access: "on_request" },
{ name: "Navidrome", desc: "My music, streamed from home.", url: "https://navidrome.dvorakmortonxd.dev/", access: "private" },
{ name: "GitDXD", desc: "My self-hosted Forgejo instance.", url: "https://git.dvorakmortonxd.dev", access: "public" },
];
const LIFTS = [
{ lift: "Squat", weight: "125 kg", date: "5 Jan 2025" },
{ lift: "Bench", weight: "111 kg", date: "29 May 2026" },
{ lift: "Deadlift", weight: "165 kg", 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 };
}
/* ------------------------------------------------------------------ page */
export default function EditorialSite() {
const { setTheme } = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
const stats = [
{ value: String(SERVICES.length), label: "self-hosted services", sub: "git, media, music, AI, game server" },
{ value: String(codingProjects.length), label: "coding projects", sub: "games, tools, plugins, forks" },
{ value: String(techProjects.length), label: "machines collected", sub: "2009 – 2025, mostly e-waste" },
{ value: "401 kg", label: "best SBD total", sub: "squat 125 · bench 111 · deadlift 165" },
];
return (
<>
{/* ------------------------------------------------------------- nav */}
<header className="glass-bar fixed inset-x-0 top-0 z-50">
<div className="shell flex h-16 items-center gap-6">
<a href="#top" className="flex shrink-0 items-baseline gap-2.5" aria-label="Back to top">
<span className="display text-[1.05rem] tracking-tight">dvorakmortonxd.dev</span>
<span className="label hidden lg:inline">I use gentoo btw</span>
</a>
<nav className="ml-auto hidden items-center gap-7 md:flex" aria-label="Main">
{NAV.map((item) => (
<a key={item.href} href={item.href} className="text-sm text-muted transition-colors hover:text-ink">
{item.label}
</a>
))}
</nav>
<div className="ml-auto flex items-center gap-3 md:ml-0">
<button
type="button"
onClick={() => setTheme("skeuo")}
className="desktop-toggle glass-press-sm px-3 py-1.5 text-sm text-ink"
style={{ borderColor: "var(--rule-strong)" }}
>
Desktop mode
</button>
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-label={menuOpen ? "Close menu" : "Open menu"}
className="glass-press-sm px-3 py-2 text-sm text-ink md:hidden"
>
{menuOpen ? "Close" : "Menu"}
</button>
</div>
</div>
{menuOpen ? (
<div className="animate-rise border-t border-[var(--rule-soft)] md:hidden">
<nav className="shell py-2" aria-label="Main">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setMenuOpen(false)}
className="display block border-b border-[var(--rule-soft)] py-4 text-xl text-ink"
>
{item.label}
</a>
))}
</nav>
</div>
) : null}
</header>
<span id="top" />
<main className="pt-16">
{/* ---------------------------------------------------------- hero */}
<Section className="pb-16 pt-14 sm:pb-24 sm:pt-20">
<div className="grid grid-cols-1 items-start gap-14 lg:grid-cols-[1.05fr_1fr] lg:gap-20">
<Reveal>
<div className="flex flex-col items-start">
<p className="label">September 2026 &nbsp;·&nbsp; Czech Republic &nbsp;·&nbsp; IT student</p>
<h1 className="display mt-6 text-[2.6rem] leading-[1.06] sm:text-[3.4rem] lg:text-[3.9rem]">
I host my own internet, and I write code for <em>fun</em>.
</h1>
<p className="lede measure mt-7">
I’m Martin Dvořák — 17 years old, studying network management in Czechia. I self-host
everything from Git to music, collect old laptops, build small games, and lift when I’m
not doing any of that.
</p>
<div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-4">
<a
href="#services"
className="bg-[var(--accent)] px-5 py-3 text-sm font-medium text-[#1d2021] transition-colors hover:bg-[#ff9a3d]"
>
See what I run
</a>
<a href="#projects" className="link text-sm">
Read the projects
</a>
</div>
<p className="label mt-9">Self-hosted services &nbsp;·&nbsp; retro hardware &nbsp;·&nbsp; pygame game jams</p>
</div>
</Reveal>
<Reveal delay={100}>
<TerminalCard />
</Reveal>
</div>
</Section>
{/* ----------------------------------------------------------- stats */}
<Section className="pt-0 sm:pt-0">
<div className="rule-strong grid grid-cols-1 gap-x-10 gap-y-8 pt-8 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<Stat key={s.label} {...s} />
))}
</div>
</Section>
{/* ----------------------------------------------------------- about */}
<Section id="about" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="01"
eyebrow="About"
title={<>Who is behind the terminal</>}
lead="A student who would rather run the whole stack himself than rent it. The parts of it that are actually interesting:"
/>
</Reveal>
<Reveal delay={60}>
<div className="mt-12 grid grid-cols-1 gap-8 md:grid-cols-2 md:gap-16">
<p className="text-body">
I’m a sophomore at <a className="link" href="https://sssvt.cz" target="_blank" rel="noopener noreferrer">sssvt.cz</a>
, specializing in network management and administration. I wanted to go there since 7th
grade, so getting in was a good day. If you’re in middle school and into IT, it’s worth a
look.
</p>
<p className="text-body">
I’m a homelabber and self-hosting obsessive — I run my own Git, media, music, AI and game
servers. I write code for game jams, tinker with GNU/Linux and musl systems, and rescue old
hardware. My laptop collection spans 2009 to 2025; most of it came out of e-waste.
</p>
</div>
</Reveal>
</Section>
{/* ------------------------------------------------------------- now */}
<Section id="now" className="scroll-mt-24 pt-0 sm:pt-0">
<Reveal>
<SectionHeading
index="02"
eyebrow="Now"
title={<>What I’m doing right now</>}
lead="A snapshot, not a feed."
/>
</Reveal>
<Reveal delay={60}>
<div className="mt-12 grid grid-cols-1 gap-8 md:grid-cols-[1fr_1fr] md:gap-16">
<p className="text-body">
I’m hosting an SMP — message me on Discord if you want in. I’m also cooking most days,
bulking through the non-summer seasons.
</p>
<p className="text-body">
Me and my friend <a className="link" href="https://skopec.dev" target="_blank" rel="noopener noreferrer">Pepa</a>
{" "}are building an education app so we can study more efficiently. It’s called{" "}
<a className="link" href="https://git.dvorakmortonxd.dev/edu/maturita.c" target="_blank" rel="noopener noreferrer">
maturita.c
</a>
.
</p>
</div>
</Reveal>
</Section>
{/* -------------------------------------------------------- services */}
<Section id="services" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="03"
eyebrow="Self-hosted"
title={<>Services I run from home</>}
lead="Live status is checked through this site. Some are private or shared on request — that’s what the label means."
/>
</Reveal>
<div className="mt-12">
<ul className="flex flex-col">
{SERVICES.map((s) => (
<li key={s.name}>
<ServiceRow {...s} />
</li>
))}
</ul>
</div>
</Section>
{/* -------------------------------------------------------- projects */}
<Section id="projects" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="04"
eyebrow={`${codingProjects.length} entries`}
title={<>Things I’ve built</>}
lead="Games, a browser, a window manager, a stack of abandoned servers and one very persistent Discord bot phase. Status in the margin."
/>
</Reveal>
<ol className="mt-12 flex flex-col">
{codingProjects.map((p, i) => {
const { name, status } = splitStatus(p.title);
return (
<li key={i} className="rule flex flex-col gap-2 py-5 md:flex-row md:gap-10">
<span className="label shrink-0 md:w-44 md:pt-1">
<span className="index-num">{String(i + 1).padStart(2, "0")}</span>
{status ? <span className="ml-3 text-faint">{status}</span> : null}
</span>
<div className="measure-wide min-w-0">
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="display text-lg transition-colors hover:text-accent"
>
{name}
</a>
<p className="mt-2 text-sm leading-relaxed text-body">{p.desc}</p>
</div>
</li>
);
})}
</ol>
</Section>
{/* -------------------------------------------------------- hardware */}
<Section id="hardware" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="05"
eyebrow={`${techProjects.length} machines`}
title={<>The hardware pile</>}
lead="From a 2009 ThinkPad to a 2025 MacBook Air — specs are whatever the machine actually shipped with."
/>
</Reveal>
<div className="mt-12 grid grid-cols-1 gap-x-16 gap-y-6 md:grid-cols-2">
{techProjects.map((t) => (
<div key={t.title} className="rule-soft pt-4">
<h3 className="display text-lg text-ink">{t.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-body">{t.desc}</p>
</div>
))}
</div>
</Section>
{/* ----------------------------------------------------- contributions */}
<Section id="gitdxd" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="06"
eyebrow="GitDXD"
title={<>Commits, on my own forge</>}
lead={
<>
I run a Forgejo instance at{" "}
<a className="link" href="https://git.dvorakmortonxd.dev" target="_blank" rel="noopener noreferrer">
git.dvorakmortonxd.dev
</a>
. It started as a way to stop renting GitHub; it’s public now.
</>
}
/>
</Reveal>
<Reveal delay={60}>
<div className="mt-12 max-w-4xl">
<Contributions />
</div>
</Reveal>
</Section>
{/* --------------------------------------------------------- friends */}
<Section id="friends" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="07"
eyebrow="Friends"
title={<>People worth visiting</>}
lead="Sites built by friends — go look at them."
/>
</Reveal>
<ul className="mt-12 flex flex-col">
{friends.map((f) => (
<li key={f.name} className="rule">
<a
href={f.realUrl || f.url}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-wrap items-baseline justify-between gap-x-8 gap-y-1 py-5"
>
<span className="display text-lg transition-colors group-hover:text-accent">{f.name}</span>
<span className="label">{f.desc}</span>
</a>
</li>
))}
</ul>
</Section>
{/* --------------------------------------------------------- lifting */}
<Section id="gym" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="08"
eyebrow="Strength"
title={<>SBD, five days a week</>}
lead="A PPL split focused on strength. Current best singles at 73 kg bodyweight:"
/>
</Reveal>
<Reveal delay={60}>
<div className="rule-strong mt-12 grid grid-cols-1 gap-x-10 gap-y-8 pt-8 sm:grid-cols-3">
{LIFTS.map((l) => (
<Stat key={l.lift} value={l.weight} label={l.lift} sub={l.date} />
))}
</div>
</Reveal>
</Section>
{/* --------------------------------------------------------- contact */}
<Section id="contact" className="scroll-mt-24">
<Reveal>
<SectionHeading
index="09"
eyebrow="Contact"
title={<>Find me elsewhere</>}
lead={
<>
Fastest reply is Discord or Instagram. For anything longer, mail{" "}
<a className="link" href="mailto:dvorak@dvorakmortonxd.dev">
dvorak@dvorakmortonxd.dev
</a>
.
</>
}
/>
</Reveal>
<div className="rule-strong mt-12 grid grid-cols-2 gap-x-10 gap-y-3 pt-8 sm:grid-cols-3 lg:grid-cols-4">
{socials.map((s) => (
<a
key={s.name}
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 py-1.5 text-sm text-body transition-colors hover:text-ink"
>
<SocialGlyph name={s.name} icon={s.icon} />
<span>{s.name}</span>
</a>
))}
</div>
</Section>
</main>
{/* ---------------------------------------------------------- footer */}
<footer className="shell pb-14 pt-10">
<div className="rule-strong pt-8">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<div className="display text-xl">dvorakmortonxd.dev</div>
<p className="measure mt-2 text-sm text-body">
Built with Next.js. No trackers here — the same reason I run my own services.
</p>
</div>
<button
type="button"
onClick={() => setTheme("skeuo")}
className="desktop-toggle glass-press-sm shrink-0 px-3 py-1.5 text-sm text-ink"
>
Or open the desktop &rarr;
</button>
</div>
<div className="rule mt-10 flex flex-col gap-2 pt-5 text-xs text-faint sm:flex-row sm:justify-between">
<span>v3.0.0 · © 2025–2026 Martin Dvořák</span>
<span>Follow the GPL-3.0 license (or don’t, I don’t mind).</span>
</div>
</div>
</footer>
</>
);
}

View file

@ -1,232 +0,0 @@
"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

@ -1,106 +0,0 @@
"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

@ -1,603 +0,0 @@
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,32 +1,25 @@
"use client"; "use client";
import { useTheme } from "./useTheme"; import { useTheme } from "./useTheme";
import TerminalLayout from "./TerminalLayout"; import EditorialSite from "./EditorialSite";
import WindowsDesktop from "./WindowsDesktop"; import WindowsDesktop from "./WindowsDesktop";
import MobileHome from "./MobileHome";
// The gruvbox editorial site is the default. Phones can never reach the KDE
// desktop: ThemeScript forces gruvbox there (ignoring a stored "skeuo") and CSS
// hides the Desktop-mode buttons, so `theme === "skeuo"` is desktop-only.
//
// 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 terminal = theme === "terminal"; const desktop = theme === "skeuo";
// 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 <div style={desktop ? { display: "none" } : undefined}>
className="skeuo-desktop-root" <EditorialSite />
style={terminal ? { display: "none" } : undefined}
>
<WindowsDesktop />
</div> </div>
<div {desktop ? <WindowsDesktop /> : null}
className="skeuo-mobile-root"
style={terminal ? { display: "none" } : undefined}
>
<MobileHome />
</div>
{terminal && <TerminalLayout />}
</> </>
); );
} }

View file

@ -7,7 +7,10 @@ import { useServerInsertedHTML } from "next/navigation";
// without a flash. It is injected with useServerInsertedHTML so the <script> // without a flash. It is injected with useServerInsertedHTML so the <script>
// lives in the SSR HTML stream instead of inside the React tree — React 19 // lives in the SSR HTML stream instead of inside the React tree — React 19
// logs an error for script tags rendered by components on the client. // 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){}})();`; //
// Phones always get gruvbox: the desktop mode is mouse-oriented, so we ignore a
// stored "skeuo" there and the CSS also hides the Desktop-mode button.
const INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme');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');if(phone){t='gruvbox';}if(t==='gruvbox'||t==='skeuo'){document.documentElement.setAttribute('data-theme',t);}}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

@ -53,7 +53,7 @@ function WelcomeApp() {
<button <button
type="button" type="button"
className="app-inline-link" className="app-inline-link"
onClick={() => setTheme("terminal")} onClick={() => setTheme("gruvbox")}
> >
click here click here
</button> </button>

View file

@ -0,0 +1,405 @@
"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 -10% 0px", threshold: 0.05 }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div ref={ref} className={`reveal ${className}`} style={{ "--delay": `${delay}ms` } as CSSProperties}>
{children}
</div>
);
}
/* ------------------------------------------------------------ primitives */
export function Section({
id,
children,
className = "",
}: {
id?: string;
children: ReactNode;
className?: string;
}) {
return (
<section id={id} className={`shell relative py-14 sm:py-20 ${className}`}>
{children}
</section>
);
}
export function SectionHeading({
index,
eyebrow,
title,
lead,
}: {
index?: string;
eyebrow?: string;
title: ReactNode;
lead?: ReactNode;
}) {
return (
<header className="rule pt-5">
<div className="flex flex-col gap-3 md:flex-row md:gap-10">
<div className="label shrink-0 md:w-44 md:pt-1">
{index ? <span className="text-accent">{index}</span> : null}
{index && eyebrow ? <span className="px-1.5 text-faint">/</span> : null}
{eyebrow}
</div>
<div className="measure-wide min-w-0">
<h2 className="display text-[1.75rem] sm:text-[2.125rem]">{title}</h2>
{lead ? <p className="lede mt-4">{lead}</p> : null}
</div>
</div>
</header>
);
}
export function Stat({ value, label, sub }: { value: string; label: string; sub?: string }) {
return (
<div className="rule pt-4">
<div className="serif-num text-[2rem] leading-none sm:text-[2.5rem]">{value}</div>
<div className="mt-3 text-sm leading-snug text-body">{label}</div>
{sub ? <div className="label mt-1.5 block">{sub}</div> : null}
</div>
);
}
/* -------------------------------------------------------------- socials */
/** Minimal inline LinkedIn mark (the bundled linkedin.svg is a filled badge,
* which masks to a plain square). Everything else masks its SVG. */
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}
/>
);
}
/* ------------------------------------------------------------- services */
export type ServiceAccess = "public" | "private" | "on_request";
export function ServiceRow({
name,
desc,
url,
access,
checkUrl,
disabled,
}: {
name: string;
desc: string;
url: string;
access?: ServiceAccess;
checkUrl?: string;
disabled?: boolean;
}) {
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 effective = disabled ? "up" : status;
const dot =
effective === "up" ? "var(--positive)" : effective === "down" ? "var(--negative)" : "var(--faint)";
const statusLabel = disabled
? "private"
: effective === "up"
? "online"
: effective === "down"
? "offline"
: "checking";
const accessLabel =
access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
const body = (
<>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="display text-lg">{name}</span>
{accessLabel ? <span className="label">{accessLabel}</span> : null}
</div>
<p className="mt-1 text-sm text-body">{desc}</p>
</div>
<span className="flex shrink-0 items-center gap-2">
<span className="size-1.5 rounded-full" style={{ backgroundColor: dot }} />
<span className="label">{statusLabel}</span>
</span>
</>
);
const cls = "rule-soft flex items-center gap-4 py-4 md:gap-8";
if (disabled) {
return (
<span className={cls} aria-disabled="true">
{body}
</span>
);
}
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} transition-colors hover:bg-[var(--fill)]`}>
{body}
</a>
);
}
/* -------------------------------------------------------- contributions */
interface HeatmapPoint {
timestamp: number;
contributions: number;
}
interface Activity {
op_type: string;
content?: string | null;
created: string;
repo?: { full_name?: string };
}
const LEVEL_BG = ["rgba(235,219,178,0.07)", "#6d7420", "#98971a", "#b8bb26", "#d8e05a"];
export function Contributions() {
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;
};
}, []);
if (state === "loading") return <p className="label">Loading contributions…</p>;
if (state === "error") return <p className="label">Could not load contributions.</p>;
return (
<div>
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="serif-num text-[2rem] leading-none sm:text-[2.5rem]">{total}</div>
<div className="label">contributions in the last year</div>
</div>
<div className="ed-heatmap mt-6 pb-2">
{weeks.map((w, i) => (
<div key={i} className="ed-heatmap-week">
{w.map((d) => (
<div
key={d.date}
className="ed-heatmap-cell"
style={{ backgroundColor: LEVEL_BG[d.level] }}
title={`${d.contribs} on ${d.date}`}
/>
))}
</div>
))}
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<span className="label">less</span>
<span className="ed-heatmap-legend">
{LEVEL_BG.map((bg, i) => (
<span key={i} className="ed-heatmap-cell" style={{ backgroundColor: bg }} />
))}
</span>
<span className="label">more</span>
</div>
{activities.length > 0 ? (
<ul className="mt-8 flex flex-col">
{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 (
<li key={i} className="rule-soft flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 py-3">
<span className="min-w-0 text-sm text-ink">
<span className="text-muted">{a.repo?.full_name || "unknown"}</span> — {msg}
</span>
<span className="label shrink-0">{new Date(a.created).toLocaleDateString()}</span>
</li>
);
})}
</ul>
) : null}
</div>
);
}
/* ---------------------------------------------------------- terminal card */
export function TerminalCard() {
const rows: [string, string][] = [
["OS", "Gentoo Linux (x86_64)"],
["WM", "SwayFX"],
["Shell", "zsh"],
["Editor", "neovim"],
["Services", "6 self-hosted"],
["Gym", "SBD 401 kg @ 73 kg"],
["Uptime", "17 years"],
];
return (
<div className="glass-sunken overflow-hidden">
<div className="flex items-center gap-2 border-b border-[var(--rule-soft)] px-4 py-2.5">
<span className="size-2.5 rounded-full bg-[var(--rule)]" />
<span className="size-2.5 rounded-full bg-[var(--rule)]" />
<span className="size-2.5 rounded-full bg-[var(--rule)]" />
<span className="label ml-2">martin@dvorakmortonxd</span>
</div>
<div className="space-y-1.5 p-5 font-mono text-[0.78rem] leading-relaxed text-body">
<p className="text-ink">
<span className="text-accent">$</span> neofetch
</p>
{rows.map(([k, v]) => (
<p key={k} className="flex gap-3">
<span className="w-20 shrink-0 text-accent">{k.toLowerCase()}</span>
<span>{v}</span>
</p>
))}
<p className="text-ink">
<span className="text-accent">$</span> <span className="animate-blink">█</span>
</p>
</div>
</div>
);
}

View file

@ -2,7 +2,9 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
export type Theme = "skeuo" | "terminal"; // "gruvbox" is the editorial site (the default). "skeuo" is the KDE desktop,
// reachable from the editorial page's Desktop-mode button on non-phones.
export type Theme = "gruvbox" | "skeuo";
const EVENT = "theme-change"; const EVENT = "theme-change";
@ -16,13 +18,11 @@ function subscribe(callback: () => void) {
} }
function getSnapshot(): Theme { function getSnapshot(): Theme {
return document.documentElement.getAttribute("data-theme") === "terminal" return document.documentElement.getAttribute("data-theme") === "skeuo" ? "skeuo" : "gruvbox";
? "terminal"
: "skeuo";
} }
function getServerSnapshot(): Theme { function getServerSnapshot(): Theme {
return "skeuo"; return "gruvbox";
} }
export function setTheme(theme: Theme) { export function setTheme(theme: Theme) {
@ -40,6 +40,7 @@ export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return { return {
theme, theme,
toggle: () => setTheme(theme === "skeuo" ? "terminal" : "skeuo"), setTheme,
toggle: () => setTheme(theme === "skeuo" ? "gruvbox" : "skeuo"),
}; };
} }

52
src/app/editorial.css Normal file
View file

@ -0,0 +1,52 @@
/* ============================================================
Editorial page — small page-scoped helpers.
Layout is Tailwind utilities; only the pieces that can't be
expressed that way live here (social-icon masks, heatmap).
============================================================ */
/* Monochrome social icon: the SVG is used as an alpha mask and
painted with currentColor, so it follows the theme. */
.social-mask {
display: inline-block;
background-color: currentColor;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
/* GitDXD contribution heatmap: 53 columns of 7 cells. */
.ed-heatmap {
display: flex;
gap: 3px;
overflow-x: auto;
}
.ed-heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.ed-heatmap-cell {
width: 11px;
height: 11px;
border-radius: 2px;
background-color: var(--fill-strong);
}
.ed-heatmap-legend {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ed-heatmap-legend span.ed-heatmap-cell {
width: 11px;
height: 11px;
}
@media (max-width: 720px) {
.ed-heatmap-cell {
width: 9px;
height: 9px;
}
}

View file

@ -1,26 +1,298 @@
@import "tailwindcss"; @import "tailwindcss";
/* ============================================================
GRUVBOX EDITORIAL THEME (default)
------------------------------------------------------------
A "technical print" layout in gruvbox dark-hard. Tokens live
here; the page markup uses Tailwind utilities plus the
@utility classes below. Everything visual is scoped to
html[data-theme="gruvbox"] so the skeuo desktop (which reads
its own vars from skeuo.css) is never affected.
============================================================ */
:root { :root {
--background: #1d2021; /* gruvbox dark-hard */
--foreground: #ebdbb2; --bg: #1d2021;
--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);
}
/* Beats page.css's :root when the editorial theme is active. */
html[data-theme="gruvbox"] {
color-scheme: dark;
--accent: #fe8019;
--accent2: #d65d0e;
--accent3: #fabd2f;
--green: #b8bb26;
--red: #fb4934;
--gray: #a89984;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-ink: var(--ink);
--color-foreground: var(--foreground); --color-body: var(--body);
--font-sans: var(--font-geist-sans); --color-muted: var(--muted);
--font-mono: var(--font-geist-mono); --color-faint: var(--faint);
--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);
} }
@media (prefers-color-scheme: dark) { html[data-theme="gruvbox"] {
:root { scroll-behavior: smooth;
--background: #1d2021; scroll-padding-top: 5.5rem;
--foreground: #ebdbb2; background-color: var(--bg);
}
html[data-theme="gruvbox"] body {
background-color: var(--bg);
background-image: radial-gradient(120% 60% at 50% -10%, rgba(254, 128, 25, 0.05), transparent 70%);
background-repeat: no-repeat;
color: var(--body);
font-family: var(--font-plex), ui-sans-serif, system-ui, sans-serif;
font-size: 1rem;
line-height: 1.65;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
html[data-theme="gruvbox"] ::selection {
background: rgba(254, 128, 25, 0.28);
color: var(--ink);
}
html[data-theme="gruvbox"] :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
html[data-theme="gruvbox"] ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
html[data-theme="gruvbox"] ::-webkit-scrollbar-track {
background: var(--bg-well);
}
html[data-theme="gruvbox"] ::-webkit-scrollbar-thumb {
background: #3c3836;
border: 3px solid var(--bg-well);
border-radius: 99px;
}
html[data-theme="gruvbox"] ::-webkit-scrollbar-thumb:hover {
background: #504945;
}
/* 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);
} }
} }
body { @utility rule {
background: var(--background); border-top: 1px solid var(--rule);
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
------------------------------------------------------------------ */
.reveal {
opacity: 0;
transform: translate3d(0, 14px, 0);
transition:
opacity 0.55s ease-out,
transform 0.55s ease-out;
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="gruvbox"] {
scroll-behavior: auto;
}
.reveal {
opacity: 1;
transform: none;
transition: none;
}
.animate-blink {
animation: none !important;
}
* {
transition-duration: 0.01ms !important;
}
}
/* ------------------------------------------------------------------
Helpers
------------------------------------------------------------------ */
html[data-theme="gruvbox"] .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="gruvbox"] .link:hover {
text-decoration-color: var(--accent);
}
html[data-theme="gruvbox"] .hide-scrollbar {
scrollbar-width: none;
}
html[data-theme="gruvbox"] .hide-scrollbar::-webkit-scrollbar {
display: none;
} }

View file

@ -1,8 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
// Removed unused google fonts since we use local Custom Mono import { Newsreader, IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import ThemeScript from "./components/ThemeScript"; import ThemeScript from "./components/ThemeScript";
// Display serif for headlines, Plex for body/labels — same pairing as the
// reference editorial site.
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",
});
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.",
@ -16,7 +38,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({
@ -25,7 +47,12 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" data-theme="skeuo" suppressHydrationWarning> <html
lang="en"
data-theme="gruvbox"
suppressHydrationWarning
className={`${news.variable} ${plex.variable} ${plexMono.variable}`}
>
<body className="antialiased"> <body className="antialiased">
<ThemeScript /> <ThemeScript />
{children} {children}

View file

@ -44,11 +44,11 @@
--radius: 0px; --radius: 0px;
} }
/* RESET */ /* RESET — margins/padding are zeroed by Tailwind's preflight, which lives in
@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,6 +1,7 @@
import { Metadata } from 'next'; import { Metadata } from 'next';
import './page.css'; import './page.css';
import './skeuo.css'; import './skeuo.css';
import './editorial.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)
Mobile -> iOS-style home screen Active when <html data-theme="skeuo">. The gruvbox editorial
Active when <html data-theme="skeuo">. The terminal theme is site is the default; this is the alternate, reachable from
the alternate, reachable from the tray / launcher / app. the editorial page's Desktop-mode button or the tray.
============================================================ */ ============================================================ */
html[data-theme="skeuo"] { html[data-theme="skeuo"] {
@ -27,17 +27,8 @@ html[data-theme="skeuo"] body {
font-family: var(--font-custom-mono); font-family: var(--font-custom-mono);
} }
.skeuo-desktop-root { display: block; } /* The desktop root and its visibility are decided in JS (ThemeRoot); phones
.skeuo-mobile-root { display: none; } never mount it, and the CSS `.desktop-toggle` rule hides the entry button. */
@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)
@ -1630,598 +1621,12 @@ 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 {