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)**.
This is not a normal portfolio page. It ships **two fully interactive shells** and picks
which one to show based on the saved theme and the device, all from the same content:
It's an **editorial-style personal site** in a **gruvbox** palette, with the whole KDE
desktop simulation kept behind a "Desktop mode" button. Two modes, one content set:
- **`skeuo`** (default) — a KDE Plasma / Windows-style **desktop simulation**: draggable
snap-to-grid icons, resizable/floating windows, a Start menu, taskbar and live clock.
On phones it becomes an **iOS-style home screen** with a widget grid, dock, drag-to-reorder
and an iOS launch animation.
- **`terminal`** — a **Gruvbox TUI** version of the site: TUI panels, an animated ASCII
train background, a clickable easter-egg logo, and a working in-browser terminal (Alacritty)
with commands like `help`, `ls`, `cat`, `neofetch`, `emerge`, `ssh` and more.
- **Gruvbox editorial** (default) — a technical-print layout: serif headlines, mono labels in
the margin, hairline rules and a data strip. No images at all; the only graphics are the
social icons, used as monochrome CSS masks.
- **Desktop mode** — the KDE Plasma / Windows-style **desktop simulation**: draggable
snap-to-grid icons, resizable/floating windows, a Start menu, taskbar, live clock and a
working in-browser terminal (Alacritty). Reachable from the "Desktop mode" button and hidden
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
heatmap**, a gym SBD panel and links to friends' sites.
heatmap**, gym SBD and links to friends' sites.
## ✨ Features
- **Two themes / shells** — KDE-style desktop and Gruvbox terminal, toggled from either UI
and persisted in `localStorage` (applied pre-paint to avoid a flash).
- **Phone vs. desktop detection** — a user-agent sniff on `<html data-device>` decides whether
the desktop or the mobile home screen is shown.
- **Gruvbox editorial by default** — a technical-print layout: serif headlines, mono labels in
the margin, hairline rules, a data strip. Text and type only — the sole graphics are the
social icons, rendered as monochrome CSS masks.
- **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
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
collide.
@ -58,13 +61,14 @@ heatmap**, a gym SBD panel and links to friends' sites.
- **Framework:** Next.js 16 (App Router, Turbopack)
- **Language:** TypeScript, React 19 (client components everywhere interactivity lives)
- **Styling:** hand-written CSS — `src/app/page.css` (terminal theme), `src/app/skeuo.css`
(desktop + mobile), `src/app/globals.css` (base/reset). Tailwind v4 is imported for the
base layer but the UI is custom CSS.
- **Fonts:** Monocraft (self-hosted in `public/`, used by the terminal theme; the desktop
theme uses the system sans stack)
- **Images:** `next/image`
- **Version:** `2.1.0` (see `package.json`)
- **Styling:** `src/app/globals.css` is the gruvbox editorial design system (CSS custom
properties, Tailwind v4 `@theme`/`@utility`, base + scroll reveals). `editorial.css` holds
page helpers; `page.css` keeps the shared component styles the desktop apps still use, and
`skeuo.css` is the KDE desktop. The editorial page itself is built from Tailwind utilities.
- **Fonts:** Newsreader (display) + IBM Plex Sans/Mono (body/labels) via `next/font/google`;
the desktop keeps its system sans stack.
- **Images:** `next/image` — but the editorial page uses none (social SVGs as monochrome masks only).
- **Version:** `3.0.0` (see `package.json`)
## 🚀 Running Locally
@ -164,33 +168,34 @@ they contend for `:3000` and `.next/dev/lock`.
```
src/app/
page.tsx → renders <ThemeRoot/>, imports the two theme stylesheets
layout.tsx → html shell + metadata, injects ThemeScript
globals.css → base/reset styles + Tailwind import
page.css → terminal theme styles
skeuo.css → desktop + mobile home styles
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell + metadata; loads the fonts; injects ThemeScript
globals.css → gruvbox editorial design system (tokens, @utility classes, base)
editorial.css → editorial page helpers (social masks, heatmap)
page.css → shared component styles still used by the desktop apps
skeuo.css → KDE desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers
revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html
api/
heatmap/route.ts → contribution heatmap 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)
components/
ThemeRoot.tsx → picks which shell(s) to mount
ThemeRoot.tsx → gruvbox editorial vs. Desktop mode
ThemeScript.tsx → pre-paint script: theme + device detection
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
MobileHome.tsx → phone shell: iOS home screen + app view
WinWindow.tsx → one draggable/resizable window
apps.tsx → APPS registry, window contents, in-app terminal
useDesktopIcons.ts → desktop icon grid + drag/drop logic
useDragOrder.ts → list reordering (mobile grid/dock)
ServiceButton.tsx → service link + live status dot
ContributionHeatmap.tsx → heatmap + recent commits widget
useDragOrder.ts → list reordering (used inside apps)
ServiceButton.tsx → desktop service link + live status dot
ContributionHeatmap.tsx → desktop heatmap + recent commits widget
Clock.tsx → live clock
JokerLogo.tsx → logo with click easter egg
SlBackground.tsx → animated ASCII train background
scripts/
dev-server.mjs → custom dev server: live updates without the dev-only debug surface
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
Personal portfolio site for **dvorakmortonxd.dev**. It's not a normal website —
it ships **two fully interactive desktop/OS simulations** plus a terminal, and
picks which one to show based on theme and device.
Personal portfolio site for **dvorakmortonxd.dev**. The default view is a
**gruvbox editorial page** (a technical-print layout, no images); a "Desktop mode"
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.
- Styling: plain CSS files (`page.css`, `skeuo.css`), no CSS-in-JS, no Tailwind
utilities in the components (Tailwind is present via PostCSS but the look is
hand-written CSS).
- Styling: **Tailwind v4** for the editorial page — `globals.css` is the design
system (CSS custom properties, `@theme` tokens, `@utility` classes) — plus plain
CSS for the desktop (`skeuo.css`) and the shared component styles the desktop
apps still use (`page.css`).
- Path alias: `@/*` → `src/*`.
- Version: `2.1.0` (see `package.json`).
- Version: `3.0.0` (see `package.json`).
### Scripts
```bash
@ -73,12 +75,13 @@ token.** Do not print it, copy it into new files, or push it anywhere else.
```
src/app/
page.tsx → renders <ThemeRoot/>, imports page.css + skeuo.css
layout.tsx → html shell, metadata, ThemeScript
globals.css → base/reset styles + Tailwind import
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell, metadata, next/font, ThemeScript
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
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
api/
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
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist
components/
ThemeRoot.tsx → picks which shell to show (the router of the UI)
TerminalLayout.tsx → "terminal" theme (Gruvbox)
ThemeRoot.tsx → editorial page by default, desktop when theme === "skeuo"
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
MobileHome.tsx → "skeuo" theme, phone: app grid + dock
WinWindow.tsx → a single draggable/resizable window
apps.tsx → APPS registry + the content of each app + in-app terminal
+ embedded-site windows + the Minecraft-style SMP menu
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
ThemeScript.tsx → inline pre-hydration script: theme + device detection
ServiceButton.tsx → service link + live status dot (pings /api/ping)
ContributionHeatmap.tsx → heatmap + recent-commit widget (uses the two proxies)
ServiceButton.tsx → desktop service link + live status dot (pings /api/ping)
ContributionHeatmap.tsx → desktop heatmap + recent-commit widget (uses the two proxies)
Clock.tsx → live clock (useSyncExternalStore + interval)
JokerLogo.tsx → site logo with a click easter egg
SlBackground.tsx → animated ASCII train background (terminal theme)
scripts/
dev-server.mjs → custom dev server: live updates minus the dev-only debug surface
```
### How UI selection works
1. `ThemeScript` runs before paint. It reads `localStorage.theme` and sets
`data-theme` (`skeuo` | `terminal`) and `data-device` (`phone` | `desktop`)
on `<html>` from a user-agent sniff.
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via
`useSyncExternalStore` and dispatches a `theme-change` event on toggle.
3. `ThemeRoot` shows the terminal when `theme === "terminal"`; otherwise it
mounts **both** `WindowsDesktop` and `MobileHome` and lets CSS
(`html[data-device="phone"]`) decide which is visible. The desktop stays
mounted while hidden so the Welcome window isn't recreated.
`data-theme` (`gruvbox` | `skeuo`) and `data-device` (`phone` | `desktop`) on
`<html>` from a user-agent sniff. On a phone it forces `gruvbox` and CSS hides
every `.desktop-toggle`, so the desktop is unreachable there.
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`;
`setTheme` writes the attribute, persists to `localStorage`, and dispatches
`theme-change`.
3. `ThemeRoot` renders `EditorialSite` unless `theme === "skeuo"`, in which case it
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.
### Related
`useDragOrder.ts` is a **different, simpler** hook used only by `MobileHome` for
reordering the phone app grid/dock (drag one item over another to swap). It is
list reordering, not free placement. Don't confuse the two.
`useDragOrder.ts` is a **different, simpler** hook used inside the desktop apps
for reordering lists (drag one item over another to swap). It is list reordering,
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
pointer events and window APIs freely.
- CSS classes are prefixed by area: `win-` (desktop), `mobile-` (phone),
`app-` / `kickoff` / `terminal` etc. Grep by prefix to find styles.
- Several places deliberately use **imperative DOM work** (drag ghost,
transform-origin for the mobile launch animation). This is intentional for
performance; prefer matching the existing pattern over "Reactifying" it.
- CSS classes are prefixed by area: `win-` (desktop), `app-` / `kickoff` etc.
(desktop windows), plus the editorial design-system classes (`shell`, `display`,
`label`, `rule*`, `glass*`). Grep by prefix to find styles.
- Several places deliberately use **imperative DOM work** (e.g. the drag ghost).
This is intentional for performance; prefer matching the existing pattern over
"Reactifying" it.
- `setState` inside an effect is blocked by the ESLint config
(`react-hooks/set-state-in-effect`). The one intentional exception is the
grid init in `useDesktopIcons.ts`, which carries a scoped

View file

@ -1,6 +1,6 @@
{
"name": "dvorakmortonxd",
"version": "2.1.0",
"version": "3.0.0",
"private": true,
"scripts": {
"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
// dvorakmortonxd.dev). Keep this in sync with the ServiceButton `url`/`checkUrl`
// values in components/apps.tsx and components/TerminalLayout.tsx, and with the
// friend list in data.ts.
// values in components/apps.tsx and the services list in
// components/EditorialSite.tsx, and with the friend list in data.ts.
const ALLOWED_HOSTS = [
'dvorakmortonxd.dev', // ai / jelly / navidrome / git / proxmox / mc (SMP)
'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";
import { useTheme } from "./useTheme";
import TerminalLayout from "./TerminalLayout";
import EditorialSite from "./EditorialSite";
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() {
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 (
<>
<div
className="skeuo-desktop-root"
style={terminal ? { display: "none" } : undefined}
>
<WindowsDesktop />
<div style={desktop ? { display: "none" } : undefined}>
<EditorialSite />
</div>
<div
className="skeuo-mobile-root"
style={terminal ? { display: "none" } : undefined}
>
<MobileHome />
</div>
{terminal && <TerminalLayout />}
{desktop ? <WindowsDesktop /> : null}
</>
);
}

View file

@ -7,7 +7,10 @@ import { useServerInsertedHTML } from "next/navigation";
// 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
// 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() {
// useServerInsertedHTML can fire several times during a single render pass;

View file

@ -53,7 +53,7 @@ function WelcomeApp() {
<button
type="button"
className="app-inline-link"
onClick={() => setTheme("terminal")}
onClick={() => setTheme("gruvbox")}
>
click here
</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";
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";
@ -16,13 +18,11 @@ function subscribe(callback: () => void) {
}
function getSnapshot(): Theme {
return document.documentElement.getAttribute("data-theme") === "terminal"
? "terminal"
: "skeuo";
return document.documentElement.getAttribute("data-theme") === "skeuo" ? "skeuo" : "gruvbox";
}
function getServerSnapshot(): Theme {
return "skeuo";
return "gruvbox";
}
export function setTheme(theme: Theme) {
@ -40,6 +40,7 @@ export function useTheme() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return {
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";
/* ============================================================
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 {
--background: #1d2021;
--foreground: #ebdbb2;
/* gruvbox dark-hard */
--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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-ink: var(--ink);
--color-body: var(--body);
--color-muted: var(--muted);
--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) {
:root {
--background: #1d2021;
--foreground: #ebdbb2;
html[data-theme="gruvbox"] {
scroll-behavior: smooth;
scroll-padding-top: 5.5rem;
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 {
background: var(--background);
color: var(--foreground);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@utility rule {
border-top: 1px solid var(--rule);
}
@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";
// 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 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 = {
title: "dvorakmortonxd.dev",
description: "Martin Dvořák's personal website.",
@ -16,7 +38,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "dvorakmortonxd.dev",
description: "Martin Dvořák's personal website.",
}
},
};
export default function RootLayout({
@ -25,7 +47,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
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">
<ThemeScript />
{children}

View file

@ -44,11 +44,11 @@
--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;
margin: 0;
padding: 0;
}
html {

View file

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

View file

@ -2,9 +2,9 @@
SKEUOMORPHIC THEME
------------------------------------------------------------
Desktop -> KDE Plasma (Breeze)
Mobile -> iOS-style home screen
Active when <html data-theme="skeuo">. The terminal theme is
the alternate, reachable from the tray / launcher / app.
Active when <html data-theme="skeuo">. The gruvbox editorial
site is the default; this is the alternate, reachable from
the editorial page's Desktop-mode button or the tray.
============================================================ */
html[data-theme="skeuo"] {
@ -27,17 +27,8 @@ html[data-theme="skeuo"] body {
font-family: var(--font-custom-mono);
}
.skeuo-desktop-root { display: block; }
.skeuo-mobile-root { display: none; }
@media (max-width: 640px) {
.skeuo-desktop-root { display: none; }
.skeuo-mobile-root { display: block; }
}
/* Real phones always get the mobile UI, even in landscape. */
html[data-device="phone"] .skeuo-desktop-root { display: none; }
html[data-device="phone"] .skeuo-mobile-root { display: block; }
/* The desktop root and its visibility are decided in JS (ThemeRoot); phones
never mount it, and the CSS `.desktop-toggle` rule hides the entry button. */
/* ============================================================
DESKTOP (Plasma)
@ -1630,598 +1621,12 @@ a.win-icon {
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
(a near-zero duration still fires them so closing windows/apps unmount). */
@media (prefers-reduced-motion: reduce) {
.win-window,
.win-window.closing,
.kickoff,
.mobile-app-view.opening,
.mobile-app-view.closing,
.mobile-app-view .mobile-appbar,
.mobile-app-view .mobile-app-body {
.kickoff {
animation-duration: 0.01ms !important;
}
.win-icon {