rebuild the web theme as a skeuomorphic homelab desk (rack hero, drive drawer, LED dashboard, patch panel; CC0 textures + /credits)

This commit is contained in:
Martin Dvořák 2026-09-30 08:50:37 +00:00
commit dd6aeb5486
18 changed files with 1477 additions and 768 deletions

View file

@ -2,16 +2,18 @@
> The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**.
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:
It's a **skeuomorphic personal site**: a dark homelab desk built from anodised aluminium, OLED
glass, carbon fibre and paper, with the whole KDE desktop simulation kept behind a physical
power switch. Two modes, one content set:
- **Web** (default) — the editorial site: a technical-print layout painted in the gruvbox
palette, with 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.
- **Web** (default) — the homelab desk. A 4U rack carries the headline on an engraved nameplate
and a live console readout; services are rack units with status LEDs, the 18 coding projects
are labelled SSD caddies in a drive drawer, hardware hangs on a pegboard, the contribution
heatmap is an LED matrix and the socials are a patch panel. Textures are CC0 — see
[`/credits`](http://localhost:3000/credits).
- **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.
working in-browser terminal (Alacritty). Reached from the power switch and hidden on phones.
Both modes surface the same personal content: socials, self-hosted services with **live
up/down health checks**, a hardware collection, coding projects, a **GitDXD contribution
@ -19,13 +21,13 @@ heatmap**, gym SBD and links to friends' sites.
## ✨ Features
- **Web theme is the landing view** — the site always opens on the editorial page; the theme is
deliberately not persisted, so a reload never drops you into the desktop. A technical-print
layout with serif headlines, mono labels in the margin, hairline rules and 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 for
the session. Phones never get it: the buttons are hidden, so the old iOS-style phone home
screen is gone.
- **Web theme is the landing view** — the site always opens on the desk; the theme is
deliberately not persisted, so a reload never drops you into the desktop.
- **Skeuomorphic hardware** — rack, LEDs, rocker switch, screws, vents and panels are CSS
materials over five CC0 textures; every section is a real-world object rather than a card.
- **Desktop mode (desktop-only)** — a physical power switch in the top rail (and in the footer)
flips to the KDE-style desktop for the session. Phones never get it: the switch is hidden, so
the old iOS-style phone home screen is gone.
- **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
collide.
@ -61,15 +63,16 @@ heatmap**, gym SBD and links to friends' sites.
- **Framework:** Next.js 16 (App Router, Turbopack)
- **Language:** TypeScript, React 19 (client components everywhere interactivity lives)
- **Styling:** `src/app/globals.css` is the web editorial design system — the gruvbox palette as
CSS custom properties, plus Tailwind v4 `@theme`/`@utility`, base and 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 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`)
- **Styling:** `src/app/globals.css` holds the palette tokens, base styles, the Tailwind v4
`@theme`/`@utility` classes and the scroll reveals. `src/app/web.css` is the skeuomorphic
material system (metal, OLED glass, paper, carbon, rubber, plastic, LEDs, screws, vents,
switches) and the object layouts. `page.css` keeps the shared component styles the desktop
apps still use, and `skeuo.css` is the KDE desktop.
- **Fonts:** Newsreader (headlines, paper) + IBM Plex Sans/Mono (body, machine labels) via
`next/font/google`; the desktop keeps its system sans stack.
- **Textures:** five CC0 materials from ambientCG, downscaled to 512 px in `public/textures`.
Attribution is on `/credits`.
- **Version:** `3.1.0` (see `package.json`)
## 🚀 Running Locally
@ -171,11 +174,12 @@ they contend for `:3000` and `.next/dev/lock`.
src/app/
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell + metadata; loads the fonts; injects ThemeScript
globals.css → web editorial design system (gruvbox palette; tokens, @utility classes, base)
editorial.css → editorial page helpers (social masks, heatmap)
globals.css → palette tokens, base, Tailwind @theme/@utility, scroll reveals
web.css → skeuomorphic material system + object layouts
page.css → shared component styles still used by the desktop apps
skeuo.css → KDE desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers
credits/page.tsx → credit page for the textures and fonts
revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html
api/
heatmap/route.ts → contribution heatmap proxy
@ -183,11 +187,11 @@ src/app/
frame/route.ts → header-stripping proxy for unframeable friend sites
ping/route.ts → service health check (HTTP + Minecraft)
components/
ThemeRoot.tsx → web editorial vs. Desktop mode
ThemeRoot.tsx → web desk vs. Desktop mode
ThemeScript.tsx → pre-paint script: device detection
useTheme.ts → theme state via useSyncExternalStore
EditorialSite.tsx → the web editorial page (nav, hero, sections)
editorial-ui.tsx → editorial primitives: Section, ServiceRow, Contributions, …
WebSite.tsx → the desk: rack hero, drawer, pegboard, patch panel
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu
WinWindow.tsx → one draggable/resizable window
apps.tsx → APPS registry, window contents, in-app terminal

View file

@ -34,17 +34,19 @@ 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**. The default view is a
**web 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.
**skeuomorphic homelab desk** (a rack, LEDs, paper, anodised metal); a physical
power switch flips to a **KDE Plasma desktop simulation**. Phones only ever get
the desk.
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
- 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`).
- Styling: **Tailwind v4** for layout plus **`web.css`**, the skeuomorphic
material system (metal, OLED glass, paper, carbon, rubber, plastic, LEDs,
screws, vents, switches) over five CC0 textures in `public/textures`
(credited on `/credits`). `globals.css` holds the tokens, base styles and the
reveals; `skeuo.css` and `page.css` are the desktop and the shared
desktop-app styles.
- Path alias: `@/*` → `src/*`.
- Version: `3.0.0` (see `package.json`).
- Version: `3.1.0` (see `package.json`).
### Scripts
```bash
@ -77,11 +79,12 @@ token.** Do not print it, copy it into new files, or push it anywhere else.
src/app/
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
layout.tsx → html shell, metadata, next/font, ThemeScript
globals.css → web editorial design system (gruvbox palette; tokens, @utility, base, reveals)
editorial.css → editorial page helpers (social masks, heatmap)
globals.css → palette tokens, base, @theme/@utility, scroll reveals
web.css → skeuomorphic material system + object layouts
page.css → shared component styles used by the desktop apps
skeuo.css → KDE Plasma desktop styles
data.ts → socials, codingProjects, techProjects, friends, wallpapers
credits/page.tsx → credits for the CC0 textures and fonts
revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html
api/
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
@ -89,9 +92,9 @@ 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 → editorial page by default, desktop when theme === "skeuo"
EditorialSite.tsx → the web editorial page (nav, hero, sections)
editorial-ui.tsx → editorial primitives: Reveal, Section, ServiceRow, Contributions, …
ThemeRoot.tsx → the web desk by default, desktop when theme === "skeuo"
WebSite.tsx → the desk: rack hero, drive drawer, pegboard, patch panel
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
WinWindow.tsx → a single draggable/resizable window
apps.tsx → APPS registry + the content of each app + in-app terminal
@ -115,17 +118,19 @@ scripts/
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`;
`setTheme` sets the attribute and dispatches `theme-change`. Nothing is
persisted, so a reload returns to `web`.
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
3. `ThemeRoot` renders `WebSite` unless `theme === "skeuo"`, in which case it
mounts `WindowsDesktop` instead. The desk stays mounted (hidden) while the
desktop is up so its scroll position survives; the desktop mounts on demand
(phones can never reach `skeuo`).
Modes: `web` = the editorial site (default/landing), `skeuo` = KDE Plasma desktop.
Modes: `web` = the skeuomorphic homelab desk (default/landing), `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.
The desk uses **five CC0 textures** from ambientCG (`public/textures`: metal,
paper, carbon, rubber, plastic), downscaled to 512 px and credited on
`/credits`. The social SVGs are used as monochrome CSS masks (`.social-mask`);
`linkedin.svg` is a filled badge that masks to a plain square, so `web-ui.tsx`
renders an inline mark for it instead. The desktop still uses the image assets.
---
@ -225,14 +230,18 @@ 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), `app-` / `kickoff` etc.
(desktop windows), plus the editorial design-system classes (`shell`, `display`,
`label`, `rule*`, `glass*`). Grep by prefix to find styles.
- The editorial page reveals blocks with `.reveal` (see `Reveal` in
`editorial-ui.tsx`): hidden by default, `.is-in` shows. On load the first
screenful cascades top-to-bottom (90ms per block, in DOM order); the rest
reveal when they scroll in. **Wrap every content block** — anything left
unwrapped paints before the top and breaks the top-to-bottom order. The
`<noscript>` rule in `layout.tsx` keeps everything visible without JS.
(desktop windows), and `w-` (the web desk's materials and objects, in
`web.css`). **Never name a `w-*` class after a Tailwind utility** — `.w-screen`
collided with Tailwind's `w-screen` (`width: 100vw`) and silently stretched
every panel to the viewport. Also remember `web.css` is unlayered, so its
properties beat Tailwind utilities on the same element; use a dedicated class
(e.g. `.w-navlink`) or `!` when a utility must win.
- The desk reveals blocks with `.reveal` (see `Reveal` in `web-ui.tsx`): hidden by
default, `.is-in` shows. On load the first screenful cascades top-to-bottom
(90ms per block, in DOM order); the rest reveal when they scroll in. **Wrap
every content block** — anything left unwrapped paints before the top and breaks
the top-to-bottom order. The `<noscript>` rule in `layout.tsx` keeps everything
visible without JS.
- Several places deliberately use **imperative DOM work** (e.g. the drag ghost).
This is intentional for performance; prefer matching the existing pattern over
"Reactifying" it.

View file

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

BIN
public/textures/carbon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/textures/metal.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/textures/paper.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/textures/plastic.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
public/textures/rubber.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -9,7 +9,7 @@ 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 the services list in
// components/EditorialSite.tsx, and with the friend list in data.ts.
// components/WebSite.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

@ -1,467 +0,0 @@
"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">
<Reveal>
<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>
</Reveal>
</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>
<Reveal delay={60}>
<div className="mt-12">
<ul className="flex flex-col">
{SERVICES.map((s) => (
<li key={s.name}>
<ServiceRow {...s} />
</li>
))}
</ul>
</div>
</Reveal>
</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>
<Reveal delay={60}>
<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>
</Reveal>
</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>
<Reveal delay={60}>
<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>
</Reveal>
</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>
<Reveal delay={60}>
<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>
</Reveal>
</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>
<Reveal delay={60}>
<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>
</Reveal>
</Section>
</main>
{/* ---------------------------------------------------------- footer */}
<footer className="shell pb-14 pt-10">
<Reveal>
<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>
</Reveal>
</footer>
</>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useTheme } from "./useTheme";
import EditorialSite from "./EditorialSite";
import WebSite from "./WebSite";
import WindowsDesktop from "./WindowsDesktop";
// The web editorial site is the default and always the landing view (the theme
@ -17,7 +17,7 @@ export default function ThemeRoot() {
return (
<>
<div style={desktop ? { display: "none" } : undefined}>
<EditorialSite />
<WebSite />
</div>
{desktop ? <WindowsDesktop /> : null}
</>

View file

@ -0,0 +1,483 @@
"use client";
import { useState } from "react";
import { useTheme } from "./useTheme";
import { codingProjects, friends, socials, techProjects } from "../data";
import {
Bay,
ContributionsPanel,
Led,
Reveal,
ServiceUnit,
SocialGlyph,
type ServiceAccess,
} from "./web-ui";
/* --------------------------------------------------------------- content */
const NAV = [
{ href: "#about", label: "about" },
{ href: "#services", label: "rack" },
{ href: "#projects", label: "projects" },
{ href: "#hardware", label: "inventory" },
{ href: "#contact", label: "ports" },
];
const SERVICES: {
name: string;
desc: string;
url: string;
access?: ServiceAccess;
checkUrl?: string;
disabled?: boolean;
}[] = [
{ name: "proxmox", desc: "server management — private console", url: "https://proxmox.dvorakmortonxd.dev/", access: "private", disabled: true },
{ name: "llama.cpp", desc: "local models on a passthrough RTX 3070", url: "https://ai.dvorakmortonxd.dev/", access: "on_request" },
{ name: "jellyfin", desc: "media library, streamed from home", url: "https://jelly.dvorakmortonxd.dev/", access: "on_request" },
{ name: "minecraft smp", desc: "survival server on mc.dvorakmortonxd.dev", url: "https://discord.gg/vyvhk7d8bX", checkUrl: "minecraft://mc.dvorakmortonxd.dev:25565", access: "on_request" },
{ name: "navidrome", desc: "my music, no streaming subscription", url: "https://navidrome.dvorakmortonxd.dev/", access: "private" },
{ name: "gitdxd", desc: "self-hosted Forgejo forge", url: "https://git.dvorakmortonxd.dev", access: "public" },
];
const LIFTS = [
{ lift: "squat", weight: "125", unit: "kg", date: "05 Jan 2025" },
{ lift: "bench", weight: "111", unit: "kg", date: "29 May 2026" },
{ lift: "deadlift", weight: "165", unit: "kg", date: "17 Dec 2024" },
];
const NEOFETCH: [string, string][] = [
["host", "Proxmox VE · home rack"],
["os", "Gentoo Linux (x86_64)"],
["cpu", "Xeon E5-2680 v4 · 14c/28t"],
["gpu", "RTX 3070 · passthrough"],
["memory", "68 GB DDR4 RDIMM"],
["storage", "256 GB NVMe · 1 TB · 750 GB"],
["services", "6 containers, 0 subscriptions"],
["uptime", "17 years"],
];
/** Titles carry their state in parentheses, e.g. "dxdwm (Discontinued)". */
function splitStatus(title: string) {
const m = title.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
return m ? { name: m[1], status: m[2] } : { name: title, status: null };
}
function statusKind(status: string | null): "green" | "amber" | "red" | "off" {
if (!status) return "off";
const s = status.toLowerCase();
if (s.includes("finished") || s.includes("available")) return "green";
if (s.includes("wip") || s.includes("unfinished")) return "amber";
return "red";
}
/* ------------------------------------------------------------------ page */
export default function WebSite() {
const { setTheme } = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
const stats = [
{ value: String(SERVICES.length), label: "self-hosted services" },
{ value: String(codingProjects.length), label: "projects on the shelf" },
{ value: String(techProjects.length), label: "machines logged" },
{ value: "401", label: "kg best SBD total" },
];
return (
<div className="w-root">
{/* ----------------------------------------------------------- rail */}
<header className="w-rail w-metal-flat" style={{ borderRadius: 0 }}>
<div className="shell flex h-16 items-center gap-5">
<a href="#top" className="flex shrink-0 items-center gap-3" aria-label="Back to top">
<Led kind="green" pulse />
<span className="w-rack__title">dvorakmortonxd.local</span>
</a>
<nav className="ml-auto hidden items-center gap-6 md:flex" aria-label="Main">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
className="w-navlink"
>
{item.label}
</a>
))}
</nav>
<div className="ml-auto flex items-center gap-3 md:ml-0">
<button
type="button"
aria-label="Switch to Desktop mode"
data-desktop-mode="off"
onClick={() => setTheme("skeuo")}
className="desktop-toggle flex items-center gap-3"
title="Flip to Desktop mode"
>
<span className="w-plate">desktop</span>
<span className="w-switch">
<span className="w-switch__knob" />
<Led kind="off" className="w-switch__led" />
</span>
</button>
<div className="md:hidden">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-label={menuOpen ? "Close menu" : "Open menu"}
className="w-btn px-3 py-2"
>
{menuOpen ? "close" : "menu"}
</button>
</div>
</div>
</div>
{menuOpen ? (
<nav className="shell w-metal-flat mb-3 mt-1 flex flex-col gap-1 p-3 md:hidden" aria-label="Main">
{NAV.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => setMenuOpen(false)}
className="w-unit__name block border-b border-[rgba(255,255,255,0.06)] py-3"
>
{item.label}
</a>
))}
</nav>
) : null}
</header>
<span id="top" />
<main className="pt-16">
{/* --------------------------------------------------------- hero */}
<section className="shell pt-12 pb-14 sm:pt-16">
<Reveal>
<div className="w-rack w-metal">
<div className="w-rack__head">
<Led kind="green" pulse />
<span className="w-rack__title">dvorakmortonxd.local</span>
<span className="w-unit__meta ml-auto hidden sm:block">4U · asus p9x79 · 24/7</span>
<span className="w-screw ml-2 hidden sm:block" />
<span className="w-screw hidden sm:block" />
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div className="w-nameplate min-w-0">
<p className="w-kicker">homelab · czech republic · online</p>
<h1 className="mt-4 text-[2rem] leading-[1.05] sm:text-[2.6rem] lg:text-[3rem]">
My own little internet — one rack, zero subscriptions.
</h1>
<p className="w-prose mt-5 max-w-[48ch]">
I’m Martin Dvořák. Git, media, music, an AI box and a Minecraft server all run on a
Proxmox machine in my room — plus the hardware graveyard I keep rescuing from e-waste.
</p>
<div className="mt-7 flex flex-wrap items-center gap-3">
<a href="#services" className="w-btn w-btn--accent">
<Led kind="amber" /> see what I run
</a>
<a href="#projects" className="w-btn">
open the drive drawer
</a>
</div>
</div>
<div className="w-panel min-w-0 p-4">
<div className="w-rack__head !border-b-0 !pb-2">
<span className="w-rack__title">console — neofetch</span>
<Led kind="green" className="ml-auto" />
</div>
<div className="w-screen-text">
{NEOFETCH.map(([k, v]) => (
<p key={k} className="flex gap-3">
<span className="k w-[8ch] shrink-0">{k}</span>
<span className="v min-w-0 truncate">{v}</span>
</p>
))}
<p className="mt-1">
<span className="k">$</span> <span className="w-cursor" />
</p>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((s) => (
<div key={s.label} className="w-unit w-metal-flat">
<Led kind="amber" />
<span className="min-w-0">
<span className="w-digits block text-[1.5rem] leading-none">{s.value}</span>
<span className="w-unit__meta mt-1 block truncate">{s.label}</span>
</span>
</div>
))}
</div>
</div>
</Reveal>
</section>
<div className="shell flex flex-col gap-16 pb-20 sm:gap-24">
{/* -------------------------------------------------------- about */}
<Bay id="about" index="01" label="about" title="Who is behind the rack">
<div className="grid gap-6 md:grid-cols-2">
<Reveal delay={40}>
<article className="w-paper w-card h-full">
<div className="flex items-center gap-3">
<span className="w-grommet" />
<span className="w-kicker !text-[var(--paper-ink-soft)]">manifest.txt</span>
</div>
<p className="w-prose mt-4">
I’m a sophomore at{" "}
<a className="w-link" href="https://sssvt.cz" target="_blank" rel="noopener noreferrer">
sssvt.cz
</a>
, studying network management and administration. I wanted to go there since 7th
grade, so getting in was a good day.
</p>
<p className="w-prose mt-3">
I’m a homelabber first: my own Git, media, music, AI and game servers, most of it
running on hardware other people threw away.
</p>
</article>
</Reveal>
<Reveal delay={130}>
<article className="w-paper w-card w-note--tilt h-full">
<div className="flex items-center gap-3">
<span className="w-grommet" />
<span className="w-kicker !text-[var(--paper-ink-soft)]">inventory.txt</span>
</div>
<p className="w-prose mt-4">
A laptop collection from 2009 to 2025, mostly e-waste rescues — ThinkPads, MacBooks,
a Legion Go, an Xbox with a bad-update exploit and one very dead 2010 unibody.
</p>
<p className="w-prose mt-3">
Away from the rack: PPL training 3–5× a week, Terraria (~1300 h), Minecraft modpacks
and the occasional game jam.
</p>
</article>
</Reveal>
</div>
</Bay>
{/* ---------------------------------------------------------- now */}
<Bay id="now" index="02" label="now" title="On the bench right now">
<Reveal delay={40}>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
<article className="w-paper w-note min-w-0">
<span className="w-kicker !text-[var(--paper-ink-soft)]">now.txt · updated 09/2026</span>
<p className="w-prose mt-4">
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="w-prose mt-3">
Me and my friend{" "}
<a className="w-link" href="https://skopec.dev" target="_blank" rel="noopener noreferrer">
Pepa
</a>{" "}
are building an education app so we can study more efficiently. It’s called{" "}
<a className="w-link" href="https://git.dvorakmortonxd.dev/edu/maturita.c" target="_blank" rel="noopener noreferrer">
maturita.c
</a>
.
</p>
</article>
<div className="w-panel min-w-0 p-4">
<div className="w-rack__head !border-b-0 !pb-2">
<span className="w-rack__title">status</span>
<Led kind="green" pulse className="ml-auto" />
</div>
<div className="w-screen-text">
<p><span className="k">smp</span> <span className="v">accepting players</span></p>
<p><span className="k">bulk</span> <span className="v">in progress</span></p>
<p><span className="k">maturita.c</span> <span className="v">in development</span></p>
<p><span className="k">gym</span> <span className="v">PPL · 3–5×/week</span></p>
</div>
</div>
</div>
</Reveal>
</Bay>
{/* ----------------------------------------------------- services */}
<Bay id="services" index="03" label="rack" title="Rack elevation — live status">
<Reveal delay={40}>
<div className="w-rack w-metal">
<div className="w-rack__head">
<span className="w-rack__title">services</span>
<span className="w-unit__meta ml-auto">checked through this site</span>
</div>
{SERVICES.map((s) => (
<ServiceUnit key={s.name} {...s} />
))}
</div>
</Reveal>
</Bay>
{/* ----------------------------------------------------- projects */}
<Bay id="projects" index="04" label="projects" title="The drive drawer">
<Reveal delay={40}>
<div className="w-drawer w-metal">
<div className="w-drawer__handle mx-auto mb-4 w-40" />
<div className="max-h-[560px] overflow-y-auto pr-1">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{codingProjects.map((p, i) => {
const { name, status } = splitStatus(p.title);
return (
<a
key={i}
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="w-caddy w-carbon"
title={p.desc}
>
<Led kind={statusKind(status)} />
<span className="min-w-0 flex-1">
<span className="w-caddy__label block truncate">{name}</span>
<span className="w-caddy__status block truncate">
{status ?? "unlabelled"} · {p.desc}
</span>
</span>
</a>
);
})}
</div>
</div>
</div>
</Reveal>
</Bay>
{/* ----------------------------------------------------- hardware */}
<Bay id="hardware" index="05" label="inventory" title="On the pegboard">
<Reveal delay={40}>
<div className="w-pegboard w-metal">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{techProjects.map((t) => (
<article key={t.title} className="w-paper w-tag">
<span className="w-tag__ring" />
<h3 className="w-serif text-[1rem]">{t.title}</h3>
<p className="w-prose mt-2 text-[0.8rem]">{t.desc}</p>
</article>
))}
</div>
</div>
</Reveal>
</Bay>
{/* -------------------------------------------------- contributions */}
<Bay id="gitdxd" index="06" label="gitdxd" title="Forge telemetry">
<Reveal delay={40}>
<ContributionsPanel />
</Reveal>
</Bay>
{/* -------------------------------------------------------- friends */}
<Bay id="friends" index="07" label="friends" title="Cards on the desk">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{friends.map((f, i) => (
<Reveal key={f.name} delay={40 + i * 70}>
<a
href={f.realUrl || f.url}
target="_blank"
rel="noopener noreferrer"
className={`w-paper w-card block transition-transform hover:-translate-y-1 ${i % 2 ? "w-note--tilt" : ""}`}
>
<h3 className="w-serif text-[1.1rem]">{f.name}</h3>
<p className="mt-1 font-mono text-[0.72rem] text-[var(--paper-ink-soft)]">{f.desc}</p>
</a>
</Reveal>
))}
</div>
</Bay>
{/* ------------------------------------------------------------ gym */}
<Bay id="gym" index="08" label="strength" title="On the platform">
<Reveal delay={40}>
<div className="w-metal p-5">
<div className="grid gap-3 sm:grid-cols-3">
{LIFTS.map((l) => (
<div key={l.lift} className="w-panel p-4 text-center">
<div className="w-digits text-[2.2rem] leading-none">
{l.weight}
<span className="ml-1 text-[1rem] opacity-70">{l.unit}</span>
</div>
<div className="w-kicker mt-3">{l.lift}</div>
<div className="w-unit__meta mt-1">{l.date} · 73 kg BW</div>
</div>
))}
</div>
</div>
</Reveal>
</Bay>
{/* -------------------------------------------------------- contact */}
<Bay id="contact" index="09" label="ports" title="Patch panel">
<Reveal delay={40}>
<div className="w-patch w-metal">
<div className="w-rack__head">
<span className="w-rack__title">socials</span>
<span className="w-unit__meta ml-auto">12 ports</span>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{socials.map((s) => (
<a key={s.name} href={s.url} target="_blank" rel="noopener noreferrer" className="w-port">
<span className="w-port__jack" />
<span className="text-[var(--faint)]">
<SocialGlyph name={s.name} icon={s.icon} />
</span>
<span className="w-port__label min-w-0 truncate">{s.name}</span>
<Led kind="green" className="ml-auto" />
</a>
))}
</div>
<p className="w-unit__meta mt-5">
fastest reply is Discord or Instagram · mail{" "}
<a className="w-link" href="mailto:dvorak@dvorakmortonxd.dev">
dvorak@dvorakmortonxd.dev
</a>
</p>
</div>
</Reveal>
</Bay>
</div>
</main>
{/* --------------------------------------------------------- footer */}
<footer className="shell pb-14 pt-10">
<Reveal>
<div className="w-metal-flat flex flex-col gap-5 p-5 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<Led kind="green" />
<span className="w-rack__title">dvorakmortonxd.local</span>
</div>
<p className="w-unit__meta mt-2">
built with Next.js · textures CC0 via ambientCG ·{" "}
<a className="w-link" href="/credits">
credits
</a>
</p>
</div>
<button
type="button"
onClick={() => setTheme("skeuo")}
className="w-btn desktop-toggle shrink-0"
title="Flip to Desktop mode"
>
<Led kind="amber" /> desktop mode
</button>
</div>
<div className="mt-4 flex flex-col gap-1 font-mono text-[0.68rem] text-[var(--faint)] sm:flex-row sm:justify-between">
<span>v3.1.0 · © 2025–2026 Martin Dvořák</span>
<span>GPL-3.0-only — follow it or don’t, I don’t mind</span>
</div>
</Reveal>
</footer>
</div>
);
}

View file

@ -22,7 +22,6 @@ export function Reveal({
el.classList.add("is-in");
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
@ -56,90 +55,62 @@ export function Reveal({
);
}
/* ------------------------------------------------------------ primitives */
/* ------------------------------------------------------------- hardware */
export function Section({
id,
children,
export function Led({
kind = "green",
pulse = false,
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;
kind?: "green" | "amber" | "red" | "blue" | "off";
pulse?: boolean;
className?: string;
title?: string;
}) {
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
className={`w-led w-led--${kind}${pulse ? " w-led--pulse" : ""} ${className}`}
title={title}
aria-hidden="true"
className="social-mask size-4"
style={{ WebkitMaskImage: `url(${icon})`, maskImage: `url(${icon})` } as CSSProperties}
/>
);
}
/** Section header, mounted on the chassis. */
export function Bay({
id,
index,
label,
title,
children,
}: {
id: string;
index: string;
label: string;
title: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className="scroll-mt-28">
<Reveal>
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<span className="w-plate">
{index} <span className="opacity-50">/</span> {label}
</span>
<h2 className="w-serif text-[1.6rem] sm:text-[2rem]">{title}</h2>
</div>
</Reveal>
<div className="mt-8">{children}</div>
</section>
);
}
/* ------------------------------------------------------------- services */
export type ServiceAccess = "public" | "private" | "on_request";
export function ServiceRow({
export function ServiceUnit({
name,
desc,
url,
@ -173,10 +144,9 @@ export function ServiceRow({
}, [url, checkUrl, disabled]);
const effective = disabled ? "up" : status;
const dot =
effective === "up" ? "var(--positive)" : effective === "down" ? "var(--negative)" : "var(--faint)";
const kind = effective === "up" ? "green" : effective === "down" ? "red" : "off";
const statusLabel = disabled
? "private"
? "standby"
: effective === "up"
? "online"
: effective === "down"
@ -185,34 +155,47 @@ export function ServiceRow({
const accessLabel =
access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
const body = (
const host = (() => {
try {
return new URL(url).host;
} catch {
return url;
}
})();
const inner = (
<>
<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 className="w-unit__leds">
<Led kind={kind} pulse={effective === "up"} />
<Led kind="amber" pulse={effective === "up"} />
</span>
<span className="min-w-0 flex-1">
<span className="w-unit__name block truncate">{name}</span>
<span className="w-unit__meta block truncate">
{host} {accessLabel ? `· ${accessLabel}` : ""}
</span>
</span>
<span className="hidden text-right sm:block">
<span className="w-unit__meta block max-w-[26ch] truncate">{desc}</span>
</span>
<span className="w-plate shrink-0">
<Led kind={kind} /> {statusLabel}
</span>
</>
);
const cls = "rule-soft flex items-center gap-4 py-4 md:gap-8";
const cls = "w-unit w-metal-flat";
if (disabled) {
return (
<span className={cls} aria-disabled="true">
{body}
{inner}
</span>
);
}
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} transition-colors hover:bg-[var(--fill)]`}>
{body}
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} w-unit--link`}>
{inner}
</a>
);
}
@ -230,9 +213,7 @@ interface Activity {
repo?: { full_name?: string };
}
const LEVEL_BG = ["rgba(235,219,178,0.07)", "#6d7420", "#98971a", "#b8bb26", "#d8e05a"];
export function Contributions() {
export function ContributionsPanel() {
const [weeks, setWeeks] = useState<{ date: string; contribs: number; level: number }[][]>([]);
const [total, setTotal] = useState(0);
const [activities, setActivities] = useState<Activity[]>([]);
@ -310,108 +291,99 @@ export function Contributions() {
};
}, []);
if (state === "loading") return <p className="label">Loading contributions…</p>;
if (state === "error") return <p className="label">Could not load contributions.</p>;
// level 4 = brightest LED, level 0 = off
const levelOn = (l: number) => (l <= 0 ? "0" : String(5 - l));
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 className="w-dashboard w-metal">
<div className="w-rack__head">
<span className="w-rack__title">gitdxd.activity</span>
<span className="ml-auto flex items-center gap-2">
<Led kind={state === "error" ? "red" : "green"} pulse={state === "ready"} />
<span className="w-unit__meta">{state === "error" ? "link down" : "streaming"}</span>
</span>
</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 className="mt-4 flex flex-wrap items-end justify-between gap-3">
<div className="w-digits text-[2rem] leading-none sm:text-[2.5rem]">{total}</div>
<div className="w-kicker">contributions / last year</div>
</div>
{state === "loading" ? (
<p className="w-screen-text mt-5 opacity-70">reading matrix…</p>
) : state === "error" ? (
<p className="w-screen-text mt-5 opacity-70">could not reach the forge.</p>
) : (
<>
<div className="w-ledgrid mt-5">
{weeks.map((w, i) => (
<div key={i} className="w-ledgrid__col">
{w.map((d) => (
<div key={d.date} className="w-ledgrid__cell" data-on={levelOn(d.level)} title={`${d.contribs} on ${d.date}`} />
))}
</div>
))}
</div>
))}
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<span className="w-unit__meta">less</span>
{[4, 3, 2, 1, 0].map((l) => (
<span key={l} className="w-ledgrid__cell" data-on={levelOn(l)} />
))}
<span className="w-unit__meta">more</span>
</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}
{activities.length > 0 ? (
<div className="w-panel mt-5 p-3">
<div className="w-screen-text">
{activities.map((a, i) => {
let msg = "commit";
try {
if (a.content) {
const parsed = JSON.parse(a.content) as {
Commits?: { Message?: string }[];
HeadCommit?: { Message?: string };
};
msg =
parsed.Commits?.[0]?.Message?.split("\n")[0] ??
parsed.HeadCommit?.Message?.split("\n")[0] ??
msg;
}
} catch {
/* keep default */
}
return (
<p key={i} className="truncate">
<span className="k">$</span> {a.repo?.full_name || "unknown"} — {msg}
</p>
);
})}
</div>
</div>
) : null}
</>
)}
</div>
);
}
/* ---------------------------------------------------------- terminal card */
/* ------------------------------------------------------------- socials */
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"],
];
/** The bundled linkedin.svg is a filled badge, which masks to a plain square. */
function LinkedInGlyph({ className = "size-4" }: { className?: string }) {
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>
<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}
/>
);
}

138
src/app/credits/page.tsx Normal file
View file

@ -0,0 +1,138 @@
import type { Metadata } from "next";
import Link from "next/link";
import "../web.css";
export const metadata: Metadata = {
title: "credits · dvorakmortonxd.dev",
description: "Textures, type and other borrowed work used on dvorakmortonxd.dev.",
};
const TEXTURES = [
{ file: "metal.jpg", asset: "Metal 051 C", id: "Metal051C" },
{ file: "paper.jpg", asset: "Paper 003", id: "Paper003" },
{ file: "carbon.jpg", asset: "Fabric 004", id: "Fabric004" },
{ file: "rubber.jpg", asset: "Rubber 004", id: "Rubber004" },
{ file: "plastic.jpg", asset: "Plastic 012 A", id: "Plastic012A" },
];
const FONTS = [
{
name: "Newsreader",
by: "Production Type",
use: "headlines, paper copy",
href: "https://fonts.google.com/specimen/Newsreader",
},
{
name: "IBM Plex Sans",
by: "IBM",
use: "body text",
href: "https://fonts.google.com/specimen/IBM+Plex+Sans",
},
{
name: "IBM Plex Mono",
by: "IBM",
use: "labels, readouts, LEDs",
href: "https://fonts.google.com/specimen/IBM+Plex+Mono",
},
];
export default function Credits() {
return (
<div className="w-root">
<main className="shell pb-20 pt-24">
<p className="w-kicker">credits</p>
<h1 className="w-serif mt-3 text-[2rem] sm:text-[2.6rem]">Everything borrowed, credited.</h1>
<p className="w-prose mt-5 max-w-[62ch]">
The web theme is built out of a few CC0 textures and open-licensed typefaces. Nothing here is
scraped or unlicensed — everything below is free to reuse, and the authors are named even where
the licence does not require it.
</p>
<section className="mt-12">
<div className="flex flex-wrap items-center gap-4">
<span className="w-plate">textures</span>
<h2 className="w-serif text-[1.3rem]">ambientCG — CC0 1.0 (public domain)</h2>
</div>
<p className="w-prose mt-4 max-w-[62ch]">
All five materials come from{" "}
<a className="w-link" href="https://ambientcg.com" target="_blank" rel="noopener noreferrer">
ambientCG
</a>{" "}
(Struffel Productions), released under the{" "}
<a
className="w-link"
href="https://creativecommons.org/publicdomain/zero/1.0/"
target="_blank"
rel="noopener noreferrer"
>
CC0 1.0
</a>{" "}
public-domain dedication. I downloaded the 1K JPG colour maps, downscaled them to 512&nbsp;px and
tint them in CSS.
</p>
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
{TEXTURES.map((t) => (
<li key={t.id} className="w-paper w-card">
<div className="w-serif text-[1.02rem]">{t.asset}</div>
<div className="mt-1 font-mono text-[0.7rem] leading-relaxed text-[var(--paper-ink-soft)]">
source:{" "}
<a
className="w-link"
href={`https://ambientcg.com/view?id=${t.id}`}
target="_blank"
rel="noopener noreferrer"
>
ambientCG / {t.id}
</a>
<br />
licence: CC0 1.0 · used as <code>{`/textures/${t.file}`}</code>
</div>
</li>
))}
</ul>
</section>
<section className="mt-14">
<div className="flex flex-wrap items-center gap-4">
<span className="w-plate">type</span>
<h2 className="w-serif text-[1.3rem]">Google Fonts — SIL Open Font License 1.1</h2>
</div>
<ul className="mt-6 grid gap-3 sm:grid-cols-3">
{FONTS.map((f) => (
<li key={f.name} className="w-paper w-card">
<div className="w-serif text-[1.02rem]">{f.name}</div>
<div className="mt-1 font-mono text-[0.7rem] leading-relaxed text-[var(--paper-ink-soft)]">
by {f.by} · {f.use}
<br />
<a className="w-link" href={f.href} target="_blank" rel="noopener noreferrer">
fonts.google.com
</a>{" "}
· OFL 1.1
</div>
</li>
))}
</ul>
</section>
<section className="mt-14">
<div className="flex flex-wrap items-center gap-4">
<span className="w-plate">notes</span>
<h2 className="w-serif text-[1.3rem]">Other marks</h2>
</div>
<p className="w-prose mt-4 max-w-[62ch]">
The social buttons use brand marks that remain the property of their owners and are used only
to link to those accounts. The project and hardware text is my own. The desktop mode (KDE
sim) uses separate assets credited inside it.
</p>
</section>
<p className="mt-14">
<Link href="/" className="w-link font-mono text-[0.8rem]">
← back to the rack
</Link>
</p>
</main>
</div>
);
}

View file

@ -1,52 +0,0 @@
/* ============================================================
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

@ -70,18 +70,6 @@ html[data-theme="web"] {
background-color: var(--bg);
}
html[data-theme="web"] 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="web"] ::selection {
background: rgba(254, 128, 25, 0.28);
color: var(--ink);

View file

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

634
src/app/web.css Normal file
View file

@ -0,0 +1,634 @@
/* ============================================================
WEB THEME — a skeuomorphic homelab desk.
------------------------------------------------------------
Modern dark hardware: anodised aluminium, OLED glass, carbon
fibre, black rubber and matte plastic, with paper objects
resting on it. Textures are CC0 (see /credits). Everything is
scoped under `.w-root` so the KDE desktop is never affected.
============================================================ */
html[data-theme="web"] {
color-scheme: dark;
/* room + chassis */
--room: #0f1113;
--room-2: #15181a;
--chassis: #24282b;
--chassis-2: #1a1e20;
--chassis-edge: #0a0c0d;
--screen: #0b0e0f;
/* ink */
--ink: #fbf1c7;
--body: #d5c4a1;
--muted: #a89984;
--faint: #928374;
--paper-ink: #2a2521;
--paper-ink-soft: #5c544b;
/* signals */
--accent: #fe8019;
--accent-2: #fabd2f;
--led-green: #b8bb26;
--led-amber: #fabd2f;
--led-red: #fb4934;
--led-blue: #83a598;
--ease: cubic-bezier(0.22, 1, 0.36, 1);
}
html[data-theme="web"] body {
background:
radial-gradient(120% 70% at 50% -15%, #191d20 0%, transparent 55%),
linear-gradient(var(--room-2), var(--room));
background-attachment: fixed;
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;
}
/* ---------------------------------------------------------- materials */
.w-metal {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(0, 0, 0, 0.16) 40%, rgba(0, 0, 0, 0.34)),
linear-gradient(rgba(32, 36, 39, 0.8), rgba(18, 21, 23, 0.86)),
url("/textures/metal.jpg") center / 420px;
border: 1px solid var(--chassis-edge);
border-radius: 10px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.07),
inset 0 -1px 0 rgba(0, 0, 0, 0.55),
0 18px 40px rgba(0, 0, 0, 0.45);
}
.w-metal-flat {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.22)),
linear-gradient(rgba(30, 34, 37, 0.86), rgba(20, 23, 25, 0.9)),
url("/textures/metal.jpg") center / 380px;
border: 1px solid #0b0d0e;
border-radius: 8px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
}
.w-carbon {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.4)),
linear-gradient(rgba(26, 29, 31, 0.66), rgba(14, 16, 18, 0.78)),
url("/textures/carbon.jpg") center / 150px, #14171a;
border: 1px solid #0a0c0d;
border-radius: 7px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 6px 14px rgba(0, 0, 0, 0.4);
}
.w-rubber {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0.35)),
linear-gradient(rgba(22, 25, 27, 0.66), rgba(12, 14, 15, 0.76)),
url("/textures/rubber.jpg") center / 280px;
border: 1px solid #090b0c;
border-radius: 12px;
}
.w-plastic {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.3)),
linear-gradient(rgba(34, 38, 41, 0.88), rgba(20, 23, 25, 0.92)),
url("/textures/plastic.jpg") center / 240px;
border: 1px solid #0a0c0d;
border-radius: 9px;
}
.w-paper {
background:
linear-gradient(rgba(253, 250, 243, 0.88), rgba(243, 236, 223, 0.9)),
url("/textures/paper.jpg") center / 340px;
color: var(--paper-ink);
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 4px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.6) inset,
0 10px 22px rgba(0, 0, 0, 0.42);
}
.w-panel {
position: relative;
background:
radial-gradient(130% 90% at 50% -10%, rgba(254, 128, 25, 0.07), transparent 62%),
linear-gradient(var(--screen), #070a0b);
border: 1px solid #000;
border-radius: 8px;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.045),
inset 0 14px 34px rgba(0, 0, 0, 0.75),
0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
}
/* faint scanlines so the glass reads as a panel, not a flat fill */
.w-panel::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.022) 0 1px,
transparent 1px 3px
);
opacity: 0.7;
}
/* ------------------------------------------------------------- details */
/* NOTE: no class in here may collide with a Tailwind utility name. `.w-screen`
was a real bug: Tailwind's `w-screen` sets width:100vw and beat this file, so
every "screen" panel was forced to full viewport width. Keep the `w-` names
away from Tailwind's width namespace (`w-full`, `w-screen`, `w-min`, ...). */
.w-screw {
position: relative;
width: 11px;
height: 11px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #767c7a, #2b2f30 68%);
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.65),
0 1px 1px rgba(0, 0, 0, 0.5);
flex: none;
}
.w-screw::after {
content: "";
position: absolute;
inset: 3px;
background: linear-gradient(90deg, transparent 38%, #101314 38%, #101314 62%, transparent 62%);
transform: rotate(38deg);
}
.w-vent {
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.85) 1.1px, transparent 1.3px);
background-size: 8px 8px;
background-position: 0 0;
opacity: 0.75;
}
.w-led {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
box-shadow:
0 0 7px currentColor,
inset 0 0 2px rgba(255, 255, 255, 0.7);
flex: none;
}
.w-led--green { color: var(--led-green); }
.w-led--amber { color: var(--led-amber); }
.w-led--red { color: var(--led-red); }
.w-led--blue { color: var(--led-blue); }
.w-led--off {
color: #3a3f41;
box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.8);
}
.w-led--pulse {
animation: w-led-pulse 1.6s ease-in-out infinite;
}
@keyframes w-led-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* etched / engraved label plate */
.w-plate {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.28rem 0.6rem;
border-radius: 5px;
background: linear-gradient(180deg, #2a2e31, #1b1f21);
border: 1px solid #0b0d0e;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.07),
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
color: var(--faint);
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
}
/* push button */
.w-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.95rem;
border-radius: 8px;
background: linear-gradient(180deg, #3c4145, #24282b);
border: 1px solid #0b0d0e;
color: var(--ink);
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.78rem;
letter-spacing: 0.02em;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
0 3px 0 #0a0c0d,
0 9px 18px rgba(0, 0, 0, 0.45);
transition: filter 140ms ease-out, transform 90ms ease-out, box-shadow 90ms ease-out;
}
.w-btn:hover { filter: brightness(1.12); }
.w-btn:active {
transform: translateY(3px);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 #0a0c0d,
0 4px 10px rgba(0, 0, 0, 0.45);
}
.w-btn--accent {
background: linear-gradient(180deg, #ff9a3d, #d65d0e);
border-color: #6d2f06;
color: #22160a;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
}
.w-btn--accent:hover { filter: brightness(1.06); }
/* rocker power switch */
.w-switch {
position: relative;
width: 56px;
height: 30px;
border-radius: 7px;
background: linear-gradient(180deg, #0d0f10, #191d1f);
border: 1px solid #000;
box-shadow: inset 0 3px 7px rgba(0, 0, 0, 0.85);
flex: none;
}
.w-switch__knob {
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 22px;
border-radius: 5px;
background: linear-gradient(180deg, #565d61, #2b3033);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
0 2px 4px rgba(0, 0, 0, 0.6);
transition: transform 180ms var(--ease);
}
.w-switch__knob::after {
content: "";
position: absolute;
inset: 6px 5px;
border-radius: 2px;
background: repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0 1px, transparent 1px 4px);
}
[data-desktop-mode="on"] .w-switch__knob { transform: translateX(26px); }
.w-switch__led {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
}
[data-desktop-mode="on"] .w-switch__led { color: var(--led-green); }
/* ------------------------------------------------------------ objects */
/* top chassis rail */
.w-rail {
position: fixed;
inset: 0 0 auto;
z-index: 50;
border-bottom: 1px solid #050607;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
}
/* the hero rack */
.w-rack {
padding: 14px;
display: flex;
flex-direction: column;
gap: 9px;
}
.w-rack__head {
display: flex;
align-items: center;
gap: 10px;
padding: 0 4px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
}
.w-rack__title {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.66rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--faint);
}
.w-rack__ears {
display: flex;
align-items: center;
justify-content: space-between;
}
/* a 1U unit */
.w-unit {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
min-height: 58px;
}
.w-unit__leds {
display: flex;
align-items: center;
gap: 5px;
flex: none;
}
.w-unit__name {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.9rem;
color: var(--ink);
letter-spacing: 0.01em;
}
.w-unit__meta {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.68rem;
color: var(--faint);
}
.w-unit--link {
transition: filter 150ms ease-out, transform 150ms ease-out;
}
.w-unit--link:hover {
filter: brightness(1.14);
transform: translateX(2px);
}
/* engraved brass-style nameplate */
.w-nameplate {
padding: 1.1rem 1.3rem;
border-radius: 8px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(0, 0, 0, 0.22)),
linear-gradient(rgba(38, 42, 45, 0.9), rgba(23, 26, 28, 0.94)),
url("/textures/metal.jpg") center / 460px;
border: 1px solid #0a0c0d;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09),
inset 0 -1px 0 rgba(0, 0, 0, 0.6);
}
.w-nameplate h1 {
font-family: var(--font-news), Georgia, serif;
font-weight: 500;
letter-spacing: -0.015em;
line-height: 1.06;
color: var(--ink);
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.85),
0 -1px 0 rgba(255, 255, 255, 0.12);
}
/* paper objects */
.w-note {
padding: 1.1rem 1.2rem;
transform: rotate(-0.6deg);
}
.w-note--tilt { transform: rotate(0.7deg); }
.w-card {
padding: 1rem 1.1rem;
}
.w-grommet {
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, #6d7371, #26292a 70%);
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.45), 0 1px 1px rgba(0, 0, 0, 0.4);
}
/* SSD caddy */
.w-caddy {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 6px;
transition: transform 150ms var(--ease), filter 150ms ease-out;
}
.w-caddy:hover { transform: translateY(-2px); filter: brightness(1.15); }
.w-caddy__label {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.82rem;
color: var(--ink);
}
.w-caddy__status {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
/* drawer */
.w-drawer {
padding: 12px;
}
.w-drawer__handle {
height: 16px;
border-radius: 8px;
background: linear-gradient(180deg, #3a3f43, #22262a);
border: 1px solid #0a0c0d;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 2px 5px rgba(0, 0, 0, 0.5);
}
/* pegboard + hanging tag */
.w-pegboard {
padding: 14px;
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.7) 1.4px, transparent 1.6px);
background-size: 22px 22px;
}
.w-tag {
padding: 0.9rem 1rem 0.9rem 2.6rem;
position: relative;
}
.w-tag__ring {
position: absolute;
left: 12px;
top: 14px;
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, #6d7371, #26292a 70%);
box-shadow:
inset 0 0 0 2px rgba(0, 0, 0, 0.45),
0 1px 1px rgba(0, 0, 0, 0.4);
}
/* LED dashboard */
.w-dashboard {
padding: 14px;
}
.w-ledgrid {
display: flex;
gap: 3px;
overflow-x: auto;
padding-bottom: 4px;
}
.w-ledgrid__col { display: flex; flex-direction: column; gap: 3px; }
.w-ledgrid__cell {
width: 10px;
height: 10px;
border-radius: 2px;
background: #23272a;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.5);
}
.w-ledgrid__cell[data-on="1"] { background: var(--led-green); box-shadow: 0 0 6px rgba(184, 187, 38, 0.65); }
.w-ledgrid__cell[data-on="2"] { background: #9ea22a; box-shadow: 0 0 6px rgba(158, 162, 42, 0.6); }
.w-ledgrid__cell[data-on="3"] { background: #6f7420; }
.w-ledgrid__cell[data-on="4"] { background: #4a4e16; }
/* scoreboard digits */
.w-digits {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-weight: 500;
color: var(--led-amber);
text-shadow: 0 0 12px rgba(250, 189, 47, 0.45);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
/* patch panel */
.w-patch {
padding: 14px;
}
.w-port {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border-radius: 6px;
background: linear-gradient(180deg, #2c3134, #1c2022);
border: 1px solid #0a0c0d;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), inset 0 -2px 0 rgba(0, 0, 0, 0.45);
transition: filter 140ms ease-out, transform 140ms ease-out;
}
.w-port:hover { filter: brightness(1.16); transform: translateY(-1px); }
.w-port__jack {
width: 14px;
height: 14px;
border-radius: 3px;
background: linear-gradient(180deg, #0b0d0e, #1a1e20);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.9);
position: relative;
flex: none;
}
.w-port__jack::after {
content: "";
position: absolute;
left: 3px;
right: 3px;
top: 5px;
height: 3px;
border-radius: 1px;
background: #3f4548;
}
.w-port__label {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.72rem;
color: var(--body);
}
/* nav link — its own class because these rules are unlayered and would beat
Tailwind's hover utilities on the same element */
.w-navlink {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.68rem;
color: var(--faint);
transition: color 140ms ease-out;
}
.w-navlink:hover { color: var(--ink); }
/* monochrome social icon: the SVG is an alpha mask painted with currentColor */
.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;
}
/* screen text */
.w-screen-text {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.78rem;
line-height: 1.7;
color: var(--body);
position: relative;
z-index: 1;
}
.w-screen-text .k { color: var(--accent); }
.w-screen-text .v { color: var(--ink); }
.w-cursor {
display: inline-block;
width: 0.55em;
height: 1em;
background: var(--accent);
vertical-align: -0.15em;
animation: w-blink 1.05s step-end infinite;
}
@keyframes w-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* body copy on paper / machine */
.w-serif {
font-family: var(--font-news), Georgia, serif;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--ink);
}
.w-paper .w-serif { color: var(--paper-ink); }
.w-prose {
font-size: 0.98rem;
line-height: 1.7;
}
.w-paper .w-prose { color: var(--paper-ink-soft); }
.w-kicker {
font-family: var(--font-plexmono), ui-monospace, monospace;
font-size: 0.66rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--faint);
}
.w-link {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
text-decoration-color: rgba(254, 128, 25, 0.45);
}
.w-link:hover { text-decoration-color: var(--accent); }
.w-paper .w-link { color: #b1560c; text-decoration-color: rgba(177, 86, 12, 0.45); }
@media (prefers-reduced-motion: reduce) {
.w-led--pulse,
.w-cursor { animation: none !important; }
.w-btn,
.w-btn:hover,
.w-btn:active,
.w-unit--link,
.w-caddy,
.w-port,
.w-switch__knob { transition: none !important; }
}