feat: retro blog engine with swappable OS/console themes

Next.js 16 (App Router, TS) + SQLite (better-sqlite3) + marked.

Core: a shared semantic HTML skeleton (rb- classes in Shell.tsx) that
each theme reskins via a scoped [data-theme="..."] CSS file. Theme is
persisted in a cookie, resolved server-side in the root layout, and
swapped live by the client switcher (no reload, no FOUC).

- DB auto-migrates and seeds posts on first run (data/blog.db, gitignored)
- Pages: post list, post detail (markdown), about
- Themes shipped: Windows XP (Luna), Windows 9x, PlayStation 2
- Adding a skin = registry entry + one scoped CSS file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:42:20 +02:00
commit 37ba9b3e19
29 changed files with 5752 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# blog database
/data
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+28
View File
@@ -0,0 +1,28 @@
{
"name": "retroblog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"better-sqlite3": "^12.10.0",
"marked": "^18.0.5",
"next": "16.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.7",
"typescript": "^5"
}
}
+4043
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
better-sqlite3: true
sharp: false
unrs-resolver: false
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+36
View File
@@ -0,0 +1,36 @@
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import { THEMES } from "@/themes/registry";
export default async function AboutPage() {
const theme = await getTheme();
return (
<Shell theme={theme} title="About">
<article className="rb-article">
<h1 className="rb-article-title">About RetroBlog</h1>
<div className="rb-prose">
<p>
RetroBlog is a blog engine that wears the chrome of bygone operating
systems and game consoles. The posts live in a SQLite database; the
look lives entirely in CSS. Switch skins from the menu bar and the
same content reskins instantly.
</p>
<h2>Available skins</h2>
<ul>
{THEMES.map((t) => (
<li key={t.id}>
<strong>{t.name}</strong> ({t.era}) {t.blurb}
</li>
))}
</ul>
<h2>How it works</h2>
<p>
A shared, semantic HTML skeleton (the <code>rb-</code> classes) is
restyled per theme. Adding a new skin means writing one scoped CSS
file and registering it no markup changes required.
</p>
</div>
</article>
</Shell>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+184
View File
@@ -0,0 +1,184 @@
/* ============================================================
RetroBlog — globals
Shared, theme-agnostic skeleton. Visual identity lives in the
per-theme files imported below, each scoped to
[data-theme="..."]. This file only handles reset + layout
structure that every skin reuses.
============================================================ */
@import "../themes/xp.css";
@import "../themes/ninex.css";
@import "../themes/ps2.css";
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
min-height: 100vh;
}
a {
color: inherit;
}
img {
max-width: 100%;
}
/* ---- structural layout (positioning only; themes paint) ---- */
.rb-desktop {
min-height: 100vh;
display: flex;
flex-direction: column;
padding: 0 0 40px; /* room for the fixed taskbar */
}
.rb-window {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin: 24px auto;
width: min(920px, calc(100% - 32px));
}
.rb-titlebar {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
user-select: none;
}
.rb-titlebar-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rb-titlebar-buttons {
display: flex;
gap: 2px;
}
.rb-menubar {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.rb-spacer {
flex: 1;
}
.rb-content {
flex: 1;
min-height: 0;
overflow: auto;
}
.rb-statusbar {
display: flex;
gap: 4px;
flex: 0 0 auto;
}
.rb-status-grow {
flex: 1;
text-align: right;
}
/* taskbar pinned to viewport bottom */
.rb-taskbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 40px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px;
z-index: 50;
}
.rb-tasks {
flex: 1;
display: flex;
gap: 4px;
overflow: hidden;
}
.rb-tray {
display: flex;
align-items: center;
}
/* ---- post list ---- */
.rb-post-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.rb-post-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.rb-post-card-title {
margin: 0;
}
.rb-post-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.rb-post-tags {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.rb-readmore {
margin-left: auto;
}
.rb-article-meta {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.rb-switcher {
display: flex;
align-items: center;
gap: 6px;
}
:root {
color-scheme: light;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globals.css";
import { getTheme } from "@/themes/server";
export const metadata: Metadata = {
title: "RetroBlog",
description: "A blog engine wearing the skins of retro OSes and consoles.",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const theme = await getTheme();
return (
<html lang="en" data-theme={theme}>
<body>{children}</body>
</html>
);
}
+47
View File
@@ -0,0 +1,47 @@
import Link from "next/link";
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import { getAllPosts, formatDate } from "@/lib/posts";
export default async function HomePage() {
const theme = await getTheme();
const posts = getAllPosts();
return (
<Shell theme={theme} title="Home">
<header className="rb-page-head">
<h1 className="rb-page-title">RetroBlog</h1>
<p className="rb-page-sub">
Words from the past, rendered in the chrome of the past.
</p>
</header>
<ul className="rb-post-list">
{posts.map((p) => (
<li key={p.id} className="rb-post-card">
<div className="rb-post-card-head">
<h2 className="rb-post-card-title">
<Link href={`/posts/${p.slug}`}>{p.title}</Link>
</h2>
<span className="rb-post-date">{formatDate(p.createdAt)}</span>
</div>
<p className="rb-post-excerpt">{p.excerpt}</p>
<div className="rb-post-meta">
<span className="rb-post-author">by {p.author}</span>
<span className="rb-post-tags">
{p.tags.map((t) => (
<span key={t} className="rb-tag">
{t}
</span>
))}
</span>
<Link className="rb-readmore" href={`/posts/${p.slug}`}>
Read more &raquo;
</Link>
</div>
</li>
))}
</ul>
</Shell>
);
}
+53
View File
@@ -0,0 +1,53 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import Shell from "@/components/Shell";
import { getTheme } from "@/themes/server";
import {
getAllPosts,
getPostBySlug,
renderMarkdown,
formatDate,
} from "@/lib/posts";
export function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const theme = await getTheme();
const post = getPostBySlug(slug);
if (!post) notFound();
const html = renderMarkdown(post.body);
return (
<Shell theme={theme} title={post.title}>
<article className="rb-article">
<Link className="rb-back" href="/">
&laquo; Back to all posts
</Link>
<h1 className="rb-article-title">{post.title}</h1>
<div className="rb-article-meta">
<span>by {post.author}</span>
<span>{formatDate(post.createdAt)}</span>
<span className="rb-post-tags">
{post.tags.map((t) => (
<span key={t} className="rb-tag">
{t}
</span>
))}
</span>
</div>
<div
className="rb-prose"
dangerouslySetInnerHTML={{ __html: html }}
/>
</article>
</Shell>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect, useState } from "react";
// Live system-tray clock. Hydration-safe: renders nothing until mounted so the
// server and first client paint match.
export default function Clock() {
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
setNow(new Date());
const id = setInterval(() => setNow(new Date()), 1000 * 30);
return () => clearInterval(id);
}, []);
if (!now) return <span className="rb-clock" aria-hidden suppressHydrationWarning />;
const time = now.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
return (
<span className="rb-clock" suppressHydrationWarning>
{time}
</span>
);
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { type ThemeId } from "@/themes/registry";
import ThemeSwitcher from "./ThemeSwitcher";
import Clock from "./Clock";
// Shared, theme-agnostic page chrome. Every theme restyles these `rb-` classes
// to look like its own OS/console. The HTML skeleton never changes.
export default function Shell({
theme,
title,
children,
}: {
theme: ThemeId;
title: string;
children: ReactNode;
}) {
return (
<div className="rb-desktop">
<div className="rb-window" role="application">
<div className="rb-titlebar">
<span className="rb-titlebar-icon" aria-hidden />
<span className="rb-titlebar-text">{title} RetroBlog</span>
<div className="rb-titlebar-buttons" aria-hidden>
<button className="rb-tb-btn rb-tb-min" tabIndex={-1}>
<span>_</span>
</button>
<button className="rb-tb-btn rb-tb-max" tabIndex={-1}>
<span></span>
</button>
<button className="rb-tb-btn rb-tb-close" tabIndex={-1}>
<span>×</span>
</button>
</div>
</div>
<nav className="rb-menubar" aria-label="Main">
<Link className="rb-menu-link" href="/">
Home
</Link>
<Link className="rb-menu-link" href="/about">
About
</Link>
<a
className="rb-menu-link"
href="https://github.com"
target="_blank"
rel="noreferrer"
>
Links
</a>
<span className="rb-spacer" />
<ThemeSwitcher initial={theme} />
</nav>
<div className="rb-content">{children}</div>
<div className="rb-statusbar">
<span className="rb-status-cell">Ready</span>
<span className="rb-status-cell rb-status-grow">
RetroBlog v0.1
</span>
</div>
</div>
<div className="rb-taskbar">
<button className="rb-start">
<span className="rb-start-logo" aria-hidden />
start
</button>
<div className="rb-tasks">
<button className="rb-task rb-task-active">
<span className="rb-task-icon" aria-hidden />
RetroBlog
</button>
</div>
<div className="rb-tray">
<Clock />
</div>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { THEMES, THEME_COOKIE, type ThemeId } from "@/themes/registry";
export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
const [theme, setTheme] = useState<ThemeId>(initial);
function apply(id: ThemeId) {
setTheme(id);
document.documentElement.dataset.theme = id;
// Persist for SSR on next request. 1 year.
document.cookie = `${THEME_COOKIE}=${id}; path=/; max-age=31536000; samesite=lax`;
}
return (
<div className="rb-switcher" role="group" aria-label="Choose theme">
<span className="rb-switcher-label">Theme:</span>
<select
className="rb-switcher-select"
value={theme}
onChange={(e) => apply(e.target.value as ThemeId)}
aria-label="Theme selector"
>
{THEMES.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.era})
</option>
))}
</select>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
// Single shared connection. better-sqlite3 is synchronous, which suits
// Next.js server components fine (no event-loop blocking concerns at this scale).
let _db: Database.Database | null = null;
function dataDir(): string {
const dir = path.join(process.cwd(), "data");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
export function getDb(): Database.Database {
if (_db) return _db;
const file = path.join(dataDir(), "blog.db");
const db = new Database(file);
db.pragma("journal_mode = WAL");
migrate(db);
seed(db);
_db = db;
return db;
}
function migrate(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT 'webmaster',
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
function seed(db: Database.Database) {
const count = db.prepare("SELECT COUNT(*) AS n FROM posts").get() as {
n: number;
};
if (count.n > 0) return;
const insert = db.prepare(
`INSERT INTO posts (slug, title, excerpt, body, author, tags, created_at)
VALUES (@slug, @title, @excerpt, @body, @author, @tags, @created_at)`
);
const tx = db.transaction((rows: SeedRow[]) => {
for (const r of rows) insert.run(r);
});
tx(SEED_POSTS);
}
type SeedRow = {
slug: string;
title: string;
excerpt: string;
body: string;
author: string;
tags: string;
created_at: string;
};
const SEED_POSTS: SeedRow[] = [
{
slug: "welcome-to-retroblog",
title: "Welcome to RetroBlog",
excerpt:
"A blog engine that dresses up in the skins of operating systems and consoles from a more pixelated era.",
author: "webmaster",
tags: "meta,intro",
created_at: "2002-03-14 09:21:00",
body: `## Boot sequence complete
Welcome, traveler from the future. **RetroBlog** is a blog engine wearing the
clothes of yesterday's machines. Same words, same posts — but the chrome around
them shifts depending on which *theme* you pick from the switcher.
Right now you can choose between:
- **Windows XP** — the friendly blue Luna era
- **Windows 9x** — gray bevels and the System font
- **PlayStation 2** — that hypnotic blue boot menu
> The content lives in SQLite. The vibe lives in CSS.
Pick a skin, scroll some posts, and pretend it's a Tuesday afternoon in 2002.`,
},
{
slug: "why-skeuomorphism-rules",
title: "Why Skeuomorphism Secretly Rules",
excerpt:
"Flat design is fine, but those glossy buttons and beveled edges carried real information.",
author: "webmaster",
tags: "design,opinion",
created_at: "2002-04-02 17:45:00",
body: `## Buttons that looked like buttons
There was a time when a button *looked* pressable. It had a highlight on top,
a shadow on the bottom, and when you clicked it the bevel flipped inward. You
never had to wonder whether something was tappable.
Old interfaces wore their affordances on their sleeve:
1. Raised = clickable
2. Sunken = a text field or a pressed state
3. Gray dithered = disabled
Modern flat UIs threw a lot of that away in the name of cleanliness. Sometimes
that's progress. Sometimes you tap a label three times wondering why nothing
happens.
RetroBlog is a small love letter to the loud, helpful, *tactile* interface.`,
},
{
slug: "consoles-as-operating-systems",
title: "When Consoles Pretended to Be Operating Systems",
excerpt:
"The PS2 browser, the Wii Channels, the Dreamcast's web access — game machines wanted to be your desktop.",
author: "webmaster",
tags: "consoles,history",
created_at: "2002-05-19 21:10:00",
body: `## The dashboard era
Somewhere along the way, consoles stopped being *just* for games. They booted
into menus. They had clocks, memory card managers, calendars, even web
browsers. They wanted to be the box under your TV that did *everything*.
The PlayStation 2's blue swaying-towers boot screen is burned into a
generation's memory. The Dreamcast literally shipped with a modem and a
browser. The Wii had a whole grid of *Channels*.
These dashboards were operating systems in a trench coat — and they had more
personality than most desktops ever did. RetroBlog steals shamelessly from
all of them.`,
},
];
+66
View File
@@ -0,0 +1,66 @@
import "server-only";
import { marked } from "marked";
import { getDb } from "./db";
export type Post = {
id: number;
slug: string;
title: string;
excerpt: string;
body: string;
author: string;
tags: string[];
createdAt: string;
};
type Row = {
id: number;
slug: string;
title: string;
excerpt: string;
body: string;
author: string;
tags: string;
created_at: string;
};
function toPost(r: Row): Post {
return {
id: r.id,
slug: r.slug,
title: r.title,
excerpt: r.excerpt,
body: r.body,
author: r.author,
tags: r.tags ? r.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
createdAt: r.created_at,
};
}
export function getAllPosts(): Post[] {
const rows = getDb()
.prepare("SELECT * FROM posts ORDER BY created_at DESC")
.all() as Row[];
return rows.map(toPost);
}
export function getPostBySlug(slug: string): Post | null {
const row = getDb()
.prepare("SELECT * FROM posts WHERE slug = ?")
.get(slug) as Row | undefined;
return row ? toPost(row) : null;
}
export function renderMarkdown(md: string): string {
return marked.parse(md, { async: false }) as string;
}
export function formatDate(iso: string): string {
const d = new Date(iso.replace(" ", "T"));
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
+244
View File
@@ -0,0 +1,244 @@
/* ============================================================
Theme: Windows 9x — scoped to [data-theme="ninex"]
Classic gray, double-beveled 3D chrome, teal desktop.
============================================================ */
[data-theme="ninex"] {
--g-face: #c0c0c0;
--g-shadow: #808080;
--g-dark: #0a0a0a;
--g-light: #ffffff;
--g-hilite: #dfdfdf;
font-family: "MS Sans Serif", "Pixelated MS Sans Serif", Tahoma, sans-serif;
font-size: 13px;
color: #000;
}
[data-theme="ninex"] body {
background: #008080; /* the teal */
}
/* raised bevel helper look, applied to several blocks */
[data-theme="ninex"] .rb-window {
background: var(--g-face);
border-radius: 0;
padding: 3px;
box-shadow:
inset -1px -1px var(--g-dark),
inset 1px 1px var(--g-light),
inset -2px -2px var(--g-shadow),
inset 2px 2px var(--g-hilite);
}
[data-theme="ninex"] .rb-titlebar {
height: 22px;
padding: 0 2px 0 4px;
background: linear-gradient(90deg, #000080, #1084d0);
color: #fff;
font-weight: bold;
}
[data-theme="ninex"] .rb-titlebar-icon {
width: 16px;
height: 16px;
background: #c0c0c0;
box-shadow:
inset -1px -1px #808080,
inset 1px 1px #fff;
}
[data-theme="ninex"] .rb-tb-btn {
width: 18px;
height: 16px;
border: 0;
background: var(--g-face);
color: #000;
font: bold 11px "MS Sans Serif", sans-serif;
line-height: 1;
cursor: default;
box-shadow:
inset -1px -1px var(--g-dark),
inset 1px 1px var(--g-light),
inset -2px -2px var(--g-shadow),
inset 2px 2px var(--g-hilite);
}
/* ---- menubar ---- */
[data-theme="ninex"] .rb-menubar {
padding: 2px;
margin-top: 2px;
}
[data-theme="ninex"] .rb-menu-link {
text-decoration: none;
padding: 2px 8px;
color: #000;
}
[data-theme="ninex"] .rb-menu-link:hover {
background: #000080;
color: #fff;
}
[data-theme="ninex"] .rb-switcher-select {
font: 12px "MS Sans Serif", sans-serif;
border: 0;
border-radius: 0;
padding: 1px;
background: #fff;
box-shadow:
inset -1px -1px var(--g-light),
inset 1px 1px var(--g-shadow),
inset -2px -2px var(--g-hilite),
inset 2px 2px var(--g-dark);
}
/* ---- content (sunken field) ---- */
[data-theme="ninex"] .rb-content {
background: #fff;
margin: 3px 0;
padding: 16px 18px;
box-shadow:
inset -1px -1px var(--g-light),
inset 1px 1px var(--g-shadow),
inset -2px -2px var(--g-hilite),
inset 2px 2px var(--g-dark);
}
[data-theme="ninex"] .rb-statusbar {
font-size: 11px;
padding: 2px;
}
[data-theme="ninex"] .rb-status-cell {
padding: 1px 6px;
box-shadow:
inset -1px -1px var(--g-light),
inset 1px 1px var(--g-shadow);
}
/* ---- taskbar ---- */
[data-theme="ninex"] .rb-taskbar {
background: var(--g-face);
box-shadow: inset 0 1px 0 var(--g-light);
border-top: 1px solid var(--g-light);
}
[data-theme="ninex"] .rb-start {
display: flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 8px;
border: 0;
font: bold 13px "MS Sans Serif", sans-serif;
cursor: pointer;
background: var(--g-face);
box-shadow:
inset -1px -1px var(--g-dark),
inset 1px 1px var(--g-light),
inset -2px -2px var(--g-shadow),
inset 2px 2px var(--g-hilite);
}
[data-theme="ninex"] .rb-start-logo {
width: 16px;
height: 16px;
background:
linear-gradient(90deg, #f00 0 50%, #0f0 50% 100%) top / 100% 50% no-repeat,
linear-gradient(90deg, #00f 0 50%, #ff0 50% 100%) bottom / 100% 50% no-repeat;
}
[data-theme="ninex"] .rb-task {
display: flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 8px;
border: 0;
font: 12px "MS Sans Serif", sans-serif;
cursor: default;
background: var(--g-face);
box-shadow:
inset -1px -1px var(--g-dark),
inset 1px 1px var(--g-light),
inset -2px -2px var(--g-shadow),
inset 2px 2px var(--g-hilite);
}
[data-theme="ninex"] .rb-task-active {
font-weight: bold;
background:
repeating-conic-gradient(#c0c0c0 0 25%, #b0b0b0 0 50%) 0 0 / 3px 3px;
box-shadow:
inset -1px -1px var(--g-light),
inset 1px 1px var(--g-shadow),
inset -2px -2px var(--g-hilite),
inset 2px 2px var(--g-dark);
}
[data-theme="ninex"] .rb-task-icon {
width: 14px;
height: 14px;
background: #000080;
}
[data-theme="ninex"] .rb-tray {
padding: 0 4px;
box-shadow:
inset -1px -1px var(--g-light),
inset 1px 1px var(--g-shadow);
}
[data-theme="ninex"] .rb-clock {
font-size: 12px;
padding: 0 8px;
}
/* ---- content typography ---- */
[data-theme="ninex"] .rb-page-title {
margin: 0 0 2px;
font-size: 22px;
}
[data-theme="ninex"] .rb-page-sub {
margin: 0 0 14px;
color: #444;
}
[data-theme="ninex"] .rb-post-card {
padding: 10px 12px;
background: #fff;
box-shadow:
inset -1px -1px var(--g-shadow),
inset 1px 1px var(--g-light),
0 0 0 1px var(--g-shadow);
}
[data-theme="ninex"] .rb-post-card-title a {
color: #00008b;
text-decoration: underline;
}
[data-theme="ninex"] .rb-post-date {
font-size: 11px;
color: #555;
}
[data-theme="ninex"] .rb-tag {
font-size: 10px;
background: #c0c0c0;
padding: 0 5px;
box-shadow:
inset -1px -1px var(--g-shadow),
inset 1px 1px var(--g-light);
}
[data-theme="ninex"] .rb-readmore,
[data-theme="ninex"] .rb-back,
[data-theme="ninex"] .rb-prose a {
color: #00008b;
}
[data-theme="ninex"] .rb-back {
display: inline-block;
margin-bottom: 10px;
font-size: 12px;
}
[data-theme="ninex"] .rb-article-title {
margin: 0 0 6px;
}
[data-theme="ninex"] .rb-prose {
line-height: 1.55;
}
[data-theme="ninex"] .rb-prose code {
background: #c0c0c0;
padding: 0 4px;
font-family: "Courier New", monospace;
}
[data-theme="ninex"] .rb-prose blockquote {
border-left: 3px solid #808080;
margin: 12px 0;
padding: 4px 14px;
background: #efefef;
}
+300
View File
@@ -0,0 +1,300 @@
/* ============================================================
Theme: PlayStation 2 — scoped to [data-theme="ps2"]
The blue BIOS menu: deep navy void, drifting light towers,
thin glowing cyan type.
============================================================ */
[data-theme="ps2"] {
--p-cyan: #9fd6ff;
--p-cyan-dim: #5b9bd6;
--p-panel: rgba(8, 26, 54, 0.72);
--p-line: rgba(120, 190, 255, 0.35);
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-weight: 300;
font-size: 14px;
letter-spacing: 0.3px;
color: #dcefff;
}
[data-theme="ps2"] body {
background:
radial-gradient(120% 80% at 50% -10%, #0b2f63 0%, #051a38 45%, #01060f 100%) fixed;
position: relative;
overflow-x: hidden;
}
/* the drifting translucent "towers" */
[data-theme="ps2"] body::before {
content: "";
position: fixed;
inset: -20% -10% 0;
pointer-events: none;
background: repeating-linear-gradient(
90deg,
transparent 0 60px,
rgba(120, 180, 255, 0.05) 60px 64px,
rgba(150, 200, 255, 0.1) 64px 90px,
transparent 90px 150px
);
filter: blur(1px);
animation: ps2-drift 26s linear infinite;
z-index: 0;
}
@keyframes ps2-drift {
from {
transform: translateX(0) skewX(-2deg);
}
to {
transform: translateX(-150px) skewX(-2deg);
}
}
[data-theme="ps2"] .rb-desktop {
position: relative;
z-index: 1;
}
/* ---- window ---- */
[data-theme="ps2"] .rb-window {
background: var(--p-panel);
border: 1px solid var(--p-line);
border-radius: 2px;
padding: 0;
backdrop-filter: blur(3px);
box-shadow:
0 0 40px rgba(40, 110, 200, 0.25),
inset 0 0 60px rgba(20, 70, 150, 0.15);
}
[data-theme="ps2"] .rb-titlebar {
height: 38px;
padding: 0 14px;
border-bottom: 1px solid var(--p-line);
color: var(--p-cyan);
font-weight: 300;
letter-spacing: 2px;
text-transform: uppercase;
text-shadow: 0 0 8px rgba(120, 190, 255, 0.6);
}
[data-theme="ps2"] .rb-titlebar-icon {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--p-cyan);
box-shadow: 0 0 10px var(--p-cyan);
}
/* PS2 menu has no window buttons — hide them for authenticity */
[data-theme="ps2"] .rb-titlebar-buttons {
display: none;
}
/* ---- menubar ---- */
[data-theme="ps2"] .rb-menubar {
padding: 8px 12px;
border-bottom: 1px solid var(--p-line);
}
[data-theme="ps2"] .rb-menu-link {
text-decoration: none;
padding: 4px 12px;
color: var(--p-cyan-dim);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 12px;
transition: color 0.2s, text-shadow 0.2s;
}
[data-theme="ps2"] .rb-menu-link:hover {
color: #fff;
text-shadow: 0 0 10px var(--p-cyan);
}
[data-theme="ps2"] .rb-switcher-label {
color: var(--p-cyan-dim);
font-size: 12px;
text-transform: uppercase;
}
[data-theme="ps2"] .rb-switcher-select {
background: rgba(4, 18, 40, 0.9);
color: var(--p-cyan);
border: 1px solid var(--p-line);
border-radius: 2px;
padding: 3px 6px;
font: 12px "Segoe UI", sans-serif;
}
/* ---- content ---- */
[data-theme="ps2"] .rb-content {
padding: 24px 28px;
}
[data-theme="ps2"] .rb-statusbar {
border-top: 1px solid var(--p-line);
padding: 6px 14px;
font-size: 11px;
color: var(--p-cyan-dim);
letter-spacing: 1px;
text-transform: uppercase;
}
/* ---- taskbar (thin BIOS bottom rail) ---- */
[data-theme="ps2"] .rb-taskbar {
height: 36px;
background: linear-gradient(180deg, rgba(6, 22, 48, 0.9), rgba(1, 7, 16, 0.95));
border-top: 1px solid var(--p-line);
box-shadow: 0 -4px 20px rgba(30, 90, 180, 0.2);
}
[data-theme="ps2"] .rb-start {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 16px;
border: 1px solid var(--p-line);
border-radius: 2px;
background: transparent;
color: var(--p-cyan);
font: 300 12px "Segoe UI", sans-serif;
letter-spacing: 2px;
text-transform: uppercase;
cursor: pointer;
}
[data-theme="ps2"] .rb-start:hover {
background: rgba(60, 130, 230, 0.2);
text-shadow: 0 0 8px var(--p-cyan);
}
[data-theme="ps2"] .rb-start-logo {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--p-cyan);
box-shadow: 0 0 8px var(--p-cyan);
}
[data-theme="ps2"] .rb-task {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 14px;
border: 1px solid transparent;
background: transparent;
color: var(--p-cyan-dim);
letter-spacing: 1px;
text-transform: uppercase;
font-size: 12px;
cursor: default;
}
[data-theme="ps2"] .rb-task-active {
border-color: var(--p-line);
color: var(--p-cyan);
text-shadow: 0 0 8px var(--p-cyan);
}
[data-theme="ps2"] .rb-task-icon {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--p-cyan-dim);
}
[data-theme="ps2"] .rb-clock {
color: var(--p-cyan);
font-size: 12px;
padding: 0 14px;
letter-spacing: 2px;
text-shadow: 0 0 8px rgba(120, 190, 255, 0.5);
}
/* ---- typography / cards / prose ---- */
[data-theme="ps2"] .rb-page-title {
margin: 0 0 4px;
font-size: 30px;
font-weight: 200;
letter-spacing: 4px;
text-transform: uppercase;
color: #fff;
text-shadow: 0 0 16px rgba(120, 190, 255, 0.55);
}
[data-theme="ps2"] .rb-page-sub {
margin: 0 0 22px;
color: var(--p-cyan-dim);
letter-spacing: 1px;
}
[data-theme="ps2"] .rb-post-card {
border: 1px solid var(--p-line);
border-radius: 2px;
padding: 16px 18px;
background: rgba(10, 32, 66, 0.45);
transition: background 0.2s, box-shadow 0.2s;
}
[data-theme="ps2"] .rb-post-card:hover {
background: rgba(20, 60, 120, 0.5);
box-shadow: 0 0 24px rgba(60, 140, 240, 0.3);
}
[data-theme="ps2"] .rb-post-card-title a {
color: #eaf6ff;
text-decoration: none;
font-weight: 300;
letter-spacing: 1px;
}
[data-theme="ps2"] .rb-post-card-title a:hover {
text-shadow: 0 0 10px var(--p-cyan);
}
[data-theme="ps2"] .rb-post-date {
font-size: 11px;
color: var(--p-cyan-dim);
letter-spacing: 1px;
}
[data-theme="ps2"] .rb-post-excerpt {
color: #c4e4ff;
}
[data-theme="ps2"] .rb-tag {
font-size: 10px;
border: 1px solid var(--p-line);
border-radius: 10px;
padding: 1px 9px;
color: var(--p-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
[data-theme="ps2"] .rb-readmore,
[data-theme="ps2"] .rb-back {
color: var(--p-cyan);
text-decoration: none;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
[data-theme="ps2"] .rb-back {
display: inline-block;
margin-bottom: 14px;
}
[data-theme="ps2"] .rb-article-title {
font-weight: 200;
letter-spacing: 2px;
color: #fff;
text-shadow: 0 0 14px rgba(120, 190, 255, 0.5);
margin: 0 0 8px;
}
[data-theme="ps2"] .rb-prose {
line-height: 1.8;
color: #d4eeff;
}
[data-theme="ps2"] .rb-prose a {
color: var(--p-cyan);
}
[data-theme="ps2"] .rb-prose h2 {
font-weight: 300;
letter-spacing: 1px;
color: #eaf6ff;
}
[data-theme="ps2"] .rb-prose code {
background: rgba(4, 18, 40, 0.8);
border: 1px solid var(--p-line);
border-radius: 3px;
padding: 0 5px;
font-family: "Consolas", monospace;
color: var(--p-cyan);
}
[data-theme="ps2"] .rb-prose blockquote {
border-left: 2px solid var(--p-cyan);
margin: 14px 0;
padding: 6px 16px;
background: rgba(20, 60, 120, 0.25);
color: #cfeaff;
}
+46
View File
@@ -0,0 +1,46 @@
// Central registry of available skins. Adding a new theme = add an entry here,
// create a scoped CSS file under src/themes/, and import it in globals.css.
// Everything else (switcher, persistence, SSR attribute) is data-driven.
export type ThemeId = "xp" | "ninex" | "ps2";
export type ThemeMeta = {
id: ThemeId;
name: string;
/** Short blurb shown in the switcher. */
blurb: string;
/** Era label for flavor. */
era: string;
};
export const THEMES: ThemeMeta[] = [
{
id: "xp",
name: "Windows XP",
blurb: "Luna blue. Bliss wallpaper energy.",
era: "2001",
},
{
id: "ninex",
name: "Windows 9x",
blurb: "Gray bevels and the System font.",
era: "1995",
},
{
id: "ps2",
name: "PlayStation 2",
blurb: "Swaying blue towers boot menu.",
era: "2000",
},
];
export const DEFAULT_THEME: ThemeId = "xp";
export const THEME_COOKIE = "rb_theme";
export function isThemeId(v: string | undefined | null): v is ThemeId {
return !!v && THEMES.some((t) => t.id === v);
}
export function themeMeta(id: ThemeId): ThemeMeta {
return THEMES.find((t) => t.id === id) ?? THEMES[0];
}
+10
View File
@@ -0,0 +1,10 @@
import "server-only";
import { cookies } from "next/headers";
import { DEFAULT_THEME, THEME_COOKIE, isThemeId, type ThemeId } from "./registry";
// Resolve the active theme on the server from the persisted cookie.
export async function getTheme(): Promise<ThemeId> {
const store = await cookies();
const v = store.get(THEME_COOKIE)?.value;
return isThemeId(v) ? v : DEFAULT_THEME;
}
+243
View File
@@ -0,0 +1,243 @@
/* ============================================================
Theme: Windows XP (Luna Blue) — scoped to [data-theme="xp"]
============================================================ */
[data-theme="xp"] {
--xp-blue: #0058ee;
--xp-blue-2: #3f8cf3;
--xp-blue-dk: #003ac5;
--xp-task: #245edb;
--xp-task-2: #1941a5;
font-family: Tahoma, "Segoe UI", Verdana, sans-serif;
font-size: 13px;
color: #000;
}
[data-theme="xp"] body {
/* Bliss-ish sky + grass gradient */
background:
linear-gradient(180deg, #5a8edd 0%, #7aa6e3 22%, #c3d9a8 55%, #5e9b3f 78%, #3f7d2a 100%)
fixed;
}
/* ---- window ---- */
[data-theme="xp"] .rb-window {
background: #ece9d8;
border: 1px solid var(--xp-blue-dk);
border-radius: 8px 8px 0 0;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
padding: 0 4px 4px;
}
[data-theme="xp"] .rb-titlebar {
height: 30px;
margin: 0 -4px;
padding: 0 6px 0 8px;
border-radius: 8px 8px 0 0;
background: linear-gradient(
180deg,
var(--xp-blue-2) 0%,
var(--xp-blue) 8%,
var(--xp-blue) 40%,
var(--xp-blue-dk) 95%
);
color: #fff;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
[data-theme="xp"] .rb-titlebar-icon {
width: 16px;
height: 16px;
border-radius: 3px;
background: radial-gradient(circle at 30% 30%, #ffe27a, #ff9d2e);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
[data-theme="xp"] .rb-tb-btn {
width: 22px;
height: 21px;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 3px;
color: #fff;
font: bold 12px Tahoma, sans-serif;
line-height: 1;
cursor: default;
background: linear-gradient(180deg, #5aa0f6, #2167d8);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
[data-theme="xp"] .rb-tb-close {
background: linear-gradient(180deg, #f7866f, #d24b2a);
}
/* ---- menubar ---- */
[data-theme="xp"] .rb-menubar {
padding: 4px 2px;
border-bottom: 1px solid #aca899;
}
[data-theme="xp"] .rb-menu-link {
text-decoration: none;
padding: 3px 9px;
border-radius: 3px;
color: #000;
}
[data-theme="xp"] .rb-menu-link:hover {
background: #2f6fdb;
color: #fff;
}
[data-theme="xp"] .rb-switcher-select {
font: 12px Tahoma, sans-serif;
border: 1px solid #7f9db9;
border-radius: 2px;
padding: 1px 2px;
background: #fff;
}
/* ---- content area ---- */
[data-theme="xp"] .rb-content {
background: #fff;
border: 1px solid #aca899;
margin: 4px 0;
padding: 18px 22px;
}
[data-theme="xp"] .rb-statusbar {
font-size: 11px;
color: #333;
padding: 2px 4px;
}
[data-theme="xp"] .rb-status-cell {
border: 1px solid #d4d0c8;
border-top-color: #fff;
border-left-color: #fff;
padding: 1px 6px;
background: #ece9d8;
}
/* ---- taskbar ---- */
[data-theme="xp"] .rb-taskbar {
background: linear-gradient(180deg, #3f8cf3 0%, var(--xp-task) 10%, var(--xp-task-2) 100%);
box-shadow: inset 0 1px 0 #4d9dff;
}
[data-theme="xp"] .rb-start {
display: flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 22px 0 12px;
border: none;
border-radius: 0 12px 12px 0 / 0 14px 14px 0;
font: italic bold 15px Tahoma, sans-serif;
color: #fff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
cursor: pointer;
background: linear-gradient(180deg, #57aa3c 0%, #389016 45%, #2c7a10 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
[data-theme="xp"] .rb-start-logo {
width: 16px;
height: 16px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d6e7ff 40%, #6aa6ff 70%, #2c6fd6);
}
[data-theme="xp"] .rb-task {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border: 1px solid #1941a5;
border-radius: 3px;
color: #fff;
cursor: default;
background: linear-gradient(180deg, #4f97f5, #2f6fdb);
}
[data-theme="xp"] .rb-task-active {
background: linear-gradient(180deg, #1c4ba8, #2f6fdb);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
}
[data-theme="xp"] .rb-task-icon {
width: 14px;
height: 14px;
border-radius: 2px;
background: radial-gradient(circle at 30% 30%, #ffe27a, #ff9d2e);
}
[data-theme="xp"] .rb-clock {
color: #fff;
font-size: 12px;
padding: 0 12px;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4);
}
/* ---- typography / cards / prose ---- */
[data-theme="xp"] .rb-page-title {
margin: 0 0 2px;
font-size: 26px;
color: #0a246a;
}
[data-theme="xp"] .rb-page-sub {
margin: 0 0 16px;
color: #555;
}
[data-theme="xp"] .rb-post-card {
border: 1px solid #d4d0c8;
border-radius: 4px;
padding: 12px 14px;
background: linear-gradient(180deg, #fbfbf7, #f1efe4);
}
[data-theme="xp"] .rb-post-card-title a {
color: #0a3fb5;
text-decoration: none;
}
[data-theme="xp"] .rb-post-card-title a:hover {
text-decoration: underline;
}
[data-theme="xp"] .rb-post-date {
font-size: 11px;
color: #777;
}
[data-theme="xp"] .rb-tag {
font-size: 10px;
background: #e3edff;
border: 1px solid #9bc0ff;
border-radius: 8px;
padding: 0 7px;
color: #1b4fb0;
}
[data-theme="xp"] .rb-readmore {
color: #0a3fb5;
text-decoration: none;
font-size: 12px;
}
[data-theme="xp"] .rb-back {
color: #0a3fb5;
font-size: 12px;
display: inline-block;
margin-bottom: 10px;
}
[data-theme="xp"] .rb-article-title {
color: #0a246a;
margin: 0 0 6px;
}
[data-theme="xp"] .rb-prose {
line-height: 1.6;
}
[data-theme="xp"] .rb-prose a {
color: #0a3fb5;
}
[data-theme="xp"] .rb-prose code {
background: #eef1f7;
border: 1px solid #d6dbe6;
border-radius: 3px;
padding: 0 4px;
font-family: "Courier New", monospace;
}
[data-theme="xp"] .rb-prose blockquote {
border-left: 3px solid #9bc0ff;
margin: 12px 0;
padding: 4px 14px;
background: #f3f7ff;
color: #335;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}