-
{title} — RetroBlog
+
+ {title} — {settings.title}
+
_
@@ -41,16 +55,15 @@ export default function Shell({
About
-
- Links
-
+ {admin && (
+
+ Admin
+
+ )}
-
+ {showSwitcher && (
+
+ )}
{children}
@@ -58,7 +71,7 @@ export default function Shell({
Ready
- RetroBlog v0.1
+ {settings.footer}
@@ -71,7 +84,7 @@ export default function Shell({
- RetroBlog
+ {settings.title}
diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx
index 5105edf..be1315c 100644
--- a/src/components/ThemeSwitcher.tsx
+++ b/src/components/ThemeSwitcher.tsx
@@ -1,9 +1,21 @@
"use client";
import { useState } from "react";
-import { THEMES, THEME_COOKIE, type ThemeId } from "@/themes/registry";
+import {
+ THEMES,
+ THEME_COOKIE,
+ type ThemeId,
+ type ThemeMeta,
+} from "@/themes/registry";
-export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
+export default function ThemeSwitcher({
+ initial,
+ themes = THEMES,
+}: {
+ initial: ThemeId;
+ /** Skins offered in the dropdown. Defaults to all registered themes. */
+ themes?: ThemeMeta[];
+}) {
const [theme, setTheme] = useState(initial);
function apply(id: ThemeId) {
@@ -22,7 +34,7 @@ export default function ThemeSwitcher({ initial }: { initial: ThemeId }) {
onChange={(e) => apply(e.target.value as ThemeId)}
aria-label="Theme selector"
>
- {THEMES.map((t) => (
+ {themes.map((t) => (
{t.name} ({t.era})
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..435d689
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,30 @@
+import "server-only";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { ADMIN_COOKIE, verifySessionToken } from "./session";
+
+// The admin password. Set ADMIN_PASSWORD in the environment for production; the
+// dev fallback exists only so the panel works out of the box locally.
+export function adminPassword(): string {
+ return process.env.ADMIN_PASSWORD || "admin";
+}
+
+export function checkPassword(input: string): boolean {
+ const expected = adminPassword();
+ if (input.length !== expected.length) return false;
+ let diff = 0;
+ for (let i = 0; i < input.length; i++)
+ diff |= input.charCodeAt(i) ^ expected.charCodeAt(i);
+ return diff === 0;
+}
+
+// True if the current request carries a valid admin session cookie.
+export async function isAdmin(): Promise {
+ const store = await cookies();
+ return verifySessionToken(store.get(ADMIN_COOKIE)?.value);
+}
+
+// Guard for admin server components: bounce to login when unauthenticated.
+export async function requireAdmin(): Promise {
+ if (!(await isAdmin())) redirect("/admin/login");
+}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 04a86ac..a375f9e 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -35,6 +35,12 @@ function migrate(db: Database.Database) {
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+
+ -- Single-row key/value blob holding the site settings JSON.
+ CREATE TABLE IF NOT EXISTS settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ data TEXT NOT NULL
+ );
`);
}
diff --git a/src/lib/posts.ts b/src/lib/posts.ts
index f652f9c..dbfc720 100644
--- a/src/lib/posts.ts
+++ b/src/lib/posts.ts
@@ -51,6 +51,122 @@ export function getPostBySlug(slug: string): Post | null {
return row ? toPost(row) : null;
}
+export function getPostById(id: number): Post | null {
+ const row = getDb()
+ .prepare("SELECT * FROM posts WHERE id = ?")
+ .get(id) as Row | undefined;
+ return row ? toPost(row) : null;
+}
+
+export type PostInput = {
+ slug?: string;
+ title: string;
+ excerpt?: string;
+ body?: string;
+ author?: string;
+ tags?: string; // comma-separated
+ createdAt?: string;
+};
+
+// Turn a title (or supplied slug) into a URL-safe, unique slug. When an id is
+// given, that row is allowed to keep its own slug (used during edits).
+export function slugify(input: string): string {
+ return input
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80);
+}
+
+function uniqueSlug(base: string, ignoreId?: number): string {
+ const db = getDb();
+ let slug = base || "post";
+ let n = 1;
+ for (;;) {
+ const clash = db
+ .prepare("SELECT id FROM posts WHERE slug = ?")
+ .get(slug) as { id: number } | undefined;
+ if (!clash || clash.id === ignoreId) return slug;
+ slug = `${base}-${++n}`;
+ }
+}
+
+export function createPost(input: PostInput): Post {
+ const slug = uniqueSlug(slugify(input.slug?.trim() || input.title));
+ const info = getDb()
+ .prepare(
+ `INSERT INTO posts (slug, title, excerpt, body, author, tags, created_at)
+ VALUES (@slug, @title, @excerpt, @body, @author, @tags, @created_at)`
+ )
+ .run({
+ slug,
+ title: input.title.trim(),
+ excerpt: input.excerpt?.trim() ?? "",
+ body: input.body ?? "",
+ author: input.author?.trim() || "webmaster",
+ tags: normalizeTags(input.tags),
+ created_at: input.createdAt?.trim() || nowStamp(),
+ });
+ return getPostById(Number(info.lastInsertRowid))!;
+}
+
+export function updatePost(id: number, input: PostInput): Post | null {
+ const existing = getPostById(id);
+ if (!existing) return null;
+ const slug = uniqueSlug(
+ slugify(input.slug?.trim() || input.title),
+ id
+ );
+ getDb()
+ .prepare(
+ `UPDATE posts
+ SET slug = @slug, title = @title, excerpt = @excerpt,
+ body = @body, author = @author, tags = @tags,
+ created_at = @created_at
+ WHERE id = @id`
+ )
+ .run({
+ id,
+ slug,
+ title: input.title.trim(),
+ excerpt: input.excerpt?.trim() ?? "",
+ body: input.body ?? "",
+ author: input.author?.trim() || "webmaster",
+ tags: normalizeTags(input.tags),
+ created_at: input.createdAt?.trim() || existing.createdAt,
+ });
+ return getPostById(id);
+}
+
+export function deletePost(id: number): void {
+ getDb().prepare("DELETE FROM posts WHERE id = ?").run(id);
+}
+
+// Replace every post with the supplied set (used by import "replace" mode).
+export function replaceAllPosts(posts: PostInput[]): number {
+ const db = getDb();
+ const tx = db.transaction((rows: PostInput[]) => {
+ db.prepare("DELETE FROM posts").run();
+ for (const r of rows) createPost(r);
+ });
+ tx(posts);
+ return posts.length;
+}
+
+function normalizeTags(tags: string | undefined): string {
+ if (!tags) return "";
+ return tags
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean)
+ .join(",");
+}
+
+function nowStamp(): string {
+ return new Date().toISOString().slice(0, 19).replace("T", " ");
+}
+
export function renderMarkdown(md: string): string {
return marked.parse(md, { async: false }) as string;
}
diff --git a/src/lib/session.ts b/src/lib/session.ts
new file mode 100644
index 0000000..92ba202
--- /dev/null
+++ b/src/lib/session.ts
@@ -0,0 +1,61 @@
+// Stateless signed-session helpers built on the Web Crypto API so the same code
+// runs in both the Edge middleware and Node server actions. A session token is
+// `${issuedAt}.${hmac(issuedAt)}`; we recompute the HMAC to verify and reject
+// anything older than MAX_AGE. No DB round-trip, no per-session storage.
+
+export const ADMIN_COOKIE = "rb_admin";
+const MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
+
+function secret(): string {
+ return (
+ process.env.ADMIN_SESSION_SECRET ||
+ process.env.ADMIN_PASSWORD ||
+ "retroblog-insecure-dev-secret"
+ );
+}
+
+async function hmacHex(message: string): Promise {
+ const enc = new TextEncoder();
+ const key = await crypto.subtle.importKey(
+ "raw",
+ enc.encode(secret()),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
+ return Array.from(new Uint8Array(sig))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+// Constant-time-ish comparison to avoid leaking via early exit.
+function safeEqual(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
+ return diff === 0;
+}
+
+export async function createSessionToken(): Promise {
+ const issued = Date.now().toString();
+ const sig = await hmacHex(issued);
+ return `${issued}.${sig}`;
+}
+
+export async function verifySessionToken(
+ token: string | undefined | null
+): Promise {
+ if (!token) return false;
+ const dot = token.indexOf(".");
+ if (dot <= 0) return false;
+ const issued = token.slice(0, dot);
+ const sig = token.slice(dot + 1);
+ const issuedAt = Number(issued);
+ if (!Number.isFinite(issuedAt)) return false;
+ if (Date.now() - issuedAt > MAX_AGE_SECONDS * 1000) return false;
+ const expected = await hmacHex(issued);
+ return safeEqual(sig, expected);
+}
+
+export const SESSION_MAX_AGE = MAX_AGE_SECONDS;
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
new file mode 100644
index 0000000..c553953
--- /dev/null
+++ b/src/lib/settings.ts
@@ -0,0 +1,86 @@
+import "server-only";
+import { getDb } from "./db";
+import {
+ DEFAULT_THEME,
+ THEMES,
+ isThemeId,
+ type ThemeId,
+} from "@/themes/registry";
+
+// Site-wide settings, editable from the admin panel and persisted as a single
+// JSON row in SQLite. Adding a field = extend the type, the defaults, and the
+// admin form; reads everywhere go through getSettings().
+export type Settings = {
+ /** Branding */
+ title: string;
+ subtitle: string;
+ footer: string;
+ version: string;
+ /** Theme that new visitors get before they pick one. */
+ defaultTheme: ThemeId;
+ /** Whether the theme switcher is shown to the public (admins always see it). */
+ publicThemeToggle: boolean;
+ /** Which skins appear in the public switcher. */
+ allowedThemes: ThemeId[];
+};
+
+export const DEFAULT_SETTINGS: Settings = {
+ title: "RetroBlog",
+ subtitle: "Words from the past, rendered in the chrome of the past.",
+ footer: "RetroBlog v0.1",
+ version: "v0.1",
+ defaultTheme: DEFAULT_THEME,
+ publicThemeToggle: true,
+ allowedThemes: THEMES.map((t) => t.id),
+};
+
+// Coerce arbitrary parsed JSON into a valid Settings object, falling back to
+// defaults field-by-field so a partial or hand-edited import can't corrupt the
+// site. Returns a clean, fully-populated Settings.
+export function normalizeSettings(input: unknown): Settings {
+ const o = (input ?? {}) as Record;
+ const allowed = Array.isArray(o.allowedThemes)
+ ? (o.allowedThemes.filter(isThemeId) as ThemeId[])
+ : DEFAULT_SETTINGS.allowedThemes;
+ return {
+ title: str(o.title, DEFAULT_SETTINGS.title),
+ subtitle: str(o.subtitle, DEFAULT_SETTINGS.subtitle),
+ footer: str(o.footer, DEFAULT_SETTINGS.footer),
+ version: str(o.version, DEFAULT_SETTINGS.version),
+ defaultTheme: isThemeId(o.defaultTheme as string)
+ ? (o.defaultTheme as ThemeId)
+ : DEFAULT_SETTINGS.defaultTheme,
+ publicThemeToggle:
+ typeof o.publicThemeToggle === "boolean"
+ ? o.publicThemeToggle
+ : DEFAULT_SETTINGS.publicThemeToggle,
+ allowedThemes: allowed.length ? allowed : DEFAULT_SETTINGS.allowedThemes,
+ };
+}
+
+function str(v: unknown, fallback: string): string {
+ return typeof v === "string" && v.trim().length ? v : fallback;
+}
+
+export function getSettings(): Settings {
+ const row = getDb()
+ .prepare("SELECT data FROM settings WHERE id = 1")
+ .get() as { data: string } | undefined;
+ if (!row) return DEFAULT_SETTINGS;
+ try {
+ return normalizeSettings(JSON.parse(row.data));
+ } catch {
+ return DEFAULT_SETTINGS;
+ }
+}
+
+export function saveSettings(next: Settings): Settings {
+ const clean = normalizeSettings(next);
+ getDb()
+ .prepare(
+ `INSERT INTO settings (id, data) VALUES (1, @data)
+ ON CONFLICT(id) DO UPDATE SET data = @data`
+ )
+ .run({ data: JSON.stringify(clean) });
+ return clean;
+}
diff --git a/src/proxy.ts b/src/proxy.ts
new file mode 100644
index 0000000..67ef91b
--- /dev/null
+++ b/src/proxy.ts
@@ -0,0 +1,23 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { ADMIN_COOKIE, verifySessionToken } from "@/lib/session";
+
+// Gate every /admin route behind a valid session, except the login page and its
+// POST action. Runs on the Edge runtime, so it relies only on Web Crypto
+// (see lib/session.ts) — no Node APIs.
+export async function proxy(req: NextRequest) {
+ const { pathname } = req.nextUrl;
+
+ if (pathname === "/admin/login") return NextResponse.next();
+
+ const token = req.cookies.get(ADMIN_COOKIE)?.value;
+ if (await verifySessionToken(token)) return NextResponse.next();
+
+ const url = req.nextUrl.clone();
+ url.pathname = "/admin/login";
+ url.searchParams.set("next", pathname);
+ return NextResponse.redirect(url);
+}
+
+export const config = {
+ matcher: ["/admin/:path*"],
+};
diff --git a/src/themes/dreamcast.css b/src/themes/dreamcast.css
new file mode 100644
index 0000000..aaa5c14
--- /dev/null
+++ b/src/themes/dreamcast.css
@@ -0,0 +1,249 @@
+/* ============================================================
+ Theme: Sega Dreamcast — scoped to [data-theme="dreamcast"]
+ Cream BIOS calm, the orange swirl, friendly blue type, bubbly
+ rounded VMU-flavored chrome.
+ ============================================================ */
+
+[data-theme="dreamcast"] {
+ --dc-orange: #f47b20;
+ --dc-orange-dk: #d85f0a;
+ --dc-blue: #143d8c;
+ --dc-blue-lt: #2f6ad0;
+ --dc-cream: #f3efe6;
+ --dc-ink: #2a2f3a;
+ font-family: "Futura", "Century Gothic", "Trebuchet MS", sans-serif;
+ font-size: 14px;
+ color: var(--dc-ink);
+}
+
+[data-theme="dreamcast"] body {
+ background: linear-gradient(180deg, #fbf9f3 0%, #e9e3d6 100%) fixed;
+}
+
+/* reusable swirl */
+[data-theme="dreamcast"] .rb-titlebar-icon,
+[data-theme="dreamcast"] .rb-start-logo,
+[data-theme="dreamcast"] .rb-task-icon {
+ background: conic-gradient(
+ from 0deg,
+ var(--dc-orange),
+ #ffd0a0,
+ var(--dc-orange),
+ var(--dc-orange-dk),
+ var(--dc-orange)
+ );
+ border-radius: 50%;
+}
+
+[data-theme="dreamcast"] .rb-window {
+ background: #fff;
+ border: 1px solid #ded7c8;
+ border-radius: 20px;
+ padding: 0;
+ box-shadow: 0 10px 30px rgba(212, 95, 10, 0.15);
+}
+
+[data-theme="dreamcast"] .rb-titlebar {
+ height: 44px;
+ padding: 0 20px;
+ border-radius: 20px 20px 0 0;
+ background: linear-gradient(180deg, #ffffff, var(--dc-cream));
+ border-bottom: 2px solid var(--dc-orange);
+ color: var(--dc-blue);
+ font-weight: bold;
+ text-transform: lowercase;
+ letter-spacing: 0.5px;
+}
+[data-theme="dreamcast"] .rb-titlebar-icon {
+ width: 18px;
+ height: 18px;
+}
+[data-theme="dreamcast"] .rb-titlebar-buttons {
+ display: none;
+}
+
+[data-theme="dreamcast"] .rb-menubar {
+ padding: 10px 16px;
+ border-bottom: 1px solid #ece6d8;
+}
+[data-theme="dreamcast"] .rb-menu-link {
+ text-decoration: none;
+ padding: 6px 16px;
+ border-radius: 18px;
+ color: var(--dc-blue);
+ font-weight: bold;
+ text-transform: lowercase;
+ transition: all 0.15s;
+}
+[data-theme="dreamcast"] .rb-menu-link:hover {
+ background: var(--dc-orange);
+ color: #fff;
+}
+[data-theme="dreamcast"] .rb-switcher-label {
+ color: var(--dc-orange-dk);
+ font-weight: bold;
+ font-size: 12px;
+ text-transform: lowercase;
+}
+[data-theme="dreamcast"] .rb-switcher-select {
+ font: 13px "Trebuchet MS", sans-serif;
+ border: 2px solid var(--dc-orange);
+ border-radius: 18px;
+ padding: 4px 12px;
+ background: #fff;
+ color: var(--dc-blue);
+}
+
+[data-theme="dreamcast"] .rb-content {
+ padding: 26px 28px;
+}
+
+[data-theme="dreamcast"] .rb-statusbar {
+ border-top: 1px solid #ece6d8;
+ padding: 8px 20px;
+ font-size: 12px;
+ color: #8a8270;
+ text-transform: lowercase;
+}
+
+[data-theme="dreamcast"] .rb-taskbar {
+ height: 40px;
+ background: linear-gradient(180deg, #fffdf8, var(--dc-cream));
+ border-top: 2px solid var(--dc-orange);
+}
+[data-theme="dreamcast"] .rb-start {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 28px;
+ padding: 0 18px;
+ border: 2px solid var(--dc-orange);
+ border-radius: 18px;
+ background: #fff;
+ color: var(--dc-blue);
+ font: bold 13px "Trebuchet MS", sans-serif;
+ text-transform: lowercase;
+ cursor: pointer;
+}
+[data-theme="dreamcast"] .rb-start:hover {
+ background: var(--dc-orange);
+ color: #fff;
+}
+[data-theme="dreamcast"] .rb-start-logo {
+ width: 14px;
+ height: 14px;
+}
+[data-theme="dreamcast"] .rb-task {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 28px;
+ padding: 0 16px;
+ border: 1px solid #e3dccb;
+ border-radius: 18px;
+ background: #fff;
+ color: var(--dc-blue);
+ font-weight: bold;
+ font-size: 13px;
+ text-transform: lowercase;
+ cursor: default;
+}
+[data-theme="dreamcast"] .rb-task-active {
+ border-color: var(--dc-orange);
+ box-shadow: 0 0 0 2px rgba(244, 123, 32, 0.25);
+}
+[data-theme="dreamcast"] .rb-task-icon {
+ width: 12px;
+ height: 12px;
+}
+[data-theme="dreamcast"] .rb-clock {
+ color: var(--dc-blue);
+ font-weight: bold;
+ font-size: 13px;
+ padding: 0 16px;
+}
+
+[data-theme="dreamcast"] .rb-page-title {
+ margin: 0 0 4px;
+ font-size: 30px;
+ font-weight: bold;
+ text-transform: lowercase;
+ color: var(--dc-orange);
+}
+[data-theme="dreamcast"] .rb-page-sub {
+ margin: 0 0 20px;
+ color: var(--dc-blue-lt);
+}
+[data-theme="dreamcast"] .rb-post-card {
+ border: 1px solid #ece6d8;
+ border-radius: 16px;
+ padding: 18px 20px;
+ background: linear-gradient(180deg, #ffffff, #fbf8f1);
+ transition: box-shadow 0.2s, transform 0.2s;
+}
+[data-theme="dreamcast"] .rb-post-card:hover {
+ box-shadow: 0 6px 20px rgba(244, 123, 32, 0.25);
+ transform: translateY(-2px);
+}
+[data-theme="dreamcast"] .rb-post-card-title a {
+ color: var(--dc-blue);
+ text-decoration: none;
+ font-weight: bold;
+}
+[data-theme="dreamcast"] .rb-post-card-title a:hover {
+ color: var(--dc-orange-dk);
+}
+[data-theme="dreamcast"] .rb-post-date {
+ font-size: 12px;
+ color: #a89c84;
+ text-transform: lowercase;
+}
+[data-theme="dreamcast"] .rb-tag {
+ font-size: 11px;
+ background: #fff0e3;
+ border: 1px solid #ffc99c;
+ border-radius: 12px;
+ padding: 1px 10px;
+ color: var(--dc-orange-dk);
+ font-weight: bold;
+ text-transform: lowercase;
+}
+[data-theme="dreamcast"] .rb-readmore,
+[data-theme="dreamcast"] .rb-back {
+ color: var(--dc-orange-dk);
+ text-decoration: none;
+ font-weight: bold;
+ font-size: 13px;
+ text-transform: lowercase;
+}
+[data-theme="dreamcast"] .rb-back {
+ display: inline-block;
+ margin-bottom: 14px;
+}
+[data-theme="dreamcast"] .rb-article-title {
+ color: var(--dc-orange);
+ font-weight: bold;
+ text-transform: lowercase;
+ margin: 0 0 8px;
+}
+[data-theme="dreamcast"] .rb-prose {
+ line-height: 1.75;
+}
+[data-theme="dreamcast"] .rb-prose a {
+ color: var(--dc-blue-lt);
+}
+[data-theme="dreamcast"] .rb-prose code {
+ background: #fff0e3;
+ border: 1px solid #ffc99c;
+ border-radius: 6px;
+ padding: 0 5px;
+ font-family: "Courier New", monospace;
+ color: var(--dc-orange-dk);
+}
+[data-theme="dreamcast"] .rb-prose blockquote {
+ border-left: 4px solid var(--dc-orange);
+ margin: 14px 0;
+ padding: 6px 16px;
+ background: #fdf6ef;
+ border-radius: 0 10px 10px 0;
+}
diff --git a/src/themes/jv2002.css b/src/themes/jv2002.css
new file mode 100644
index 0000000..fa227a1
--- /dev/null
+++ b/src/themes/jv2002.css
@@ -0,0 +1,284 @@
+/* ============================================================
+ Theme: Jeuxvideo.com circa 2002 — scoped to [data-theme="jv2002"]
+ Early-2000s French gaming portal: dense boxy tables, Verdana
+ 11px, hard gray borders, gradient section bars, blue links,
+ a loud red/orange masthead. Webpage, not an OS window.
+ ============================================================ */
+
+[data-theme="jv2002"] {
+ --jv-red: #c4161c;
+ --jv-orange: #f08000;
+ --jv-blue: #003399;
+ --jv-link: #0033cc;
+ --jv-bar1: #e9e9e9;
+ --jv-bar2: #c4c4c4;
+ --jv-border: #9a9a9a;
+ font-family: Verdana, Geneva, Arial, sans-serif;
+ font-size: 11px;
+ line-height: 1.4;
+ color: #222;
+}
+
+[data-theme="jv2002"] body {
+ background: #6b6b6b url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='4' height='4' fill='%23707070'/%3E%3Crect width='2' height='2' fill='%23686868'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23686868'/%3E%3C/svg%3E");
+}
+
+/* the page = a centered bordered table */
+[data-theme="jv2002"] .rb-window {
+ width: min(800px, calc(100% - 16px));
+ margin: 10px auto;
+ background: #fff;
+ border: 1px solid #000;
+ border-radius: 0;
+ padding: 0;
+ box-shadow: 0 0 0 1px #fff, 0 4px 12px rgba(0, 0, 0, 0.5);
+}
+
+/* masthead */
+[data-theme="jv2002"] .rb-titlebar {
+ height: 46px;
+ padding: 0 12px;
+ background: linear-gradient(180deg, #e2231a 0%, var(--jv-red) 50%, #8e0f12 100%);
+ color: #fff;
+ font-weight: bold;
+ font-size: 18px;
+ font-style: italic;
+ letter-spacing: -0.5px;
+ text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.5);
+ border-bottom: 2px solid var(--jv-orange);
+}
+[data-theme="jv2002"] .rb-titlebar-icon {
+ width: 18px;
+ height: 18px;
+ background: var(--jv-orange);
+ transform: rotate(45deg);
+ box-shadow: inset 0 0 0 2px #fff;
+}
+[data-theme="jv2002"] .rb-titlebar-buttons {
+ display: none;
+}
+
+/* nav tabs */
+[data-theme="jv2002"] .rb-menubar {
+ padding: 3px 6px;
+ background: linear-gradient(180deg, var(--jv-blue), #001f66);
+ gap: 2px;
+}
+[data-theme="jv2002"] .rb-menu-link {
+ text-decoration: none;
+ padding: 3px 10px;
+ color: #fff;
+ font-weight: bold;
+ background: linear-gradient(180deg, #3a5fb8, #14307e);
+ border: 1px solid #6f8fd0;
+ border-bottom: none;
+}
+[data-theme="jv2002"] .rb-menu-link:hover {
+ background: var(--jv-orange);
+ color: #000;
+}
+[data-theme="jv2002"] .rb-switcher-label {
+ color: #fff;
+ font-weight: bold;
+}
+[data-theme="jv2002"] .rb-switcher-select {
+ font: 11px Verdana, sans-serif;
+ border: 1px solid #000;
+ border-radius: 0;
+ padding: 0 2px;
+ background: #fff;
+}
+
+[data-theme="jv2002"] .rb-content {
+ background: #fff;
+ padding: 10px 12px;
+}
+
+[data-theme="jv2002"] .rb-statusbar {
+ padding: 3px 8px;
+ font-size: 10px;
+ color: #444;
+ background: var(--jv-bar1);
+ border-top: 1px solid var(--jv-border);
+}
+
+/* footer rail */
+[data-theme="jv2002"] .rb-taskbar {
+ height: 28px;
+ background: linear-gradient(180deg, #4a4a4a, #2a2a2a);
+ border-top: 2px solid var(--jv-orange);
+ gap: 4px;
+}
+[data-theme="jv2002"] .rb-start {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ height: 20px;
+ padding: 0 10px;
+ border: 1px solid #888;
+ border-radius: 0;
+ background: linear-gradient(180deg, #f08000, #c46400);
+ color: #fff;
+ font: bold 11px Verdana, sans-serif;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+[data-theme="jv2002"] .rb-start-logo {
+ width: 9px;
+ height: 9px;
+ background: #fff;
+ transform: rotate(45deg);
+}
+[data-theme="jv2002"] .rb-task {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ height: 20px;
+ padding: 0 8px;
+ border: 1px solid #666;
+ background: linear-gradient(180deg, #5a5a5a, #3a3a3a);
+ color: #ddd;
+ font-size: 11px;
+ cursor: default;
+}
+[data-theme="jv2002"] .rb-task-active {
+ background: linear-gradient(180deg, #707070, #4a4a4a);
+ color: #fff;
+ font-weight: bold;
+}
+[data-theme="jv2002"] .rb-task-icon {
+ width: 8px;
+ height: 8px;
+ background: var(--jv-orange);
+}
+[data-theme="jv2002"] .rb-clock {
+ color: #fff;
+ font-size: 11px;
+ font-weight: bold;
+ padding: 0 8px;
+}
+
+/* ---- content: dense boxy article list ---- */
+[data-theme="jv2002"] .rb-page-head {
+ background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
+ border: 1px solid var(--jv-border);
+ padding: 6px 10px;
+ margin-bottom: 10px;
+}
+[data-theme="jv2002"] .rb-page-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: bold;
+ color: var(--jv-red);
+}
+[data-theme="jv2002"] .rb-page-sub {
+ margin: 2px 0 0;
+ font-size: 11px;
+ color: #555;
+}
+[data-theme="jv2002"] .rb-post-list {
+ gap: 8px;
+}
+[data-theme="jv2002"] .rb-post-card {
+ border: 1px solid var(--jv-border);
+ border-radius: 0;
+ padding: 0;
+ background: #fff;
+}
+[data-theme="jv2002"] .rb-post-card-head {
+ background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
+ border-bottom: 1px solid var(--jv-border);
+ padding: 4px 8px;
+ margin: 0;
+}
+[data-theme="jv2002"] .rb-post-card-title {
+ font-size: 12px;
+}
+[data-theme="jv2002"] .rb-post-card-title a {
+ color: var(--jv-blue);
+ text-decoration: none;
+ font-weight: bold;
+}
+[data-theme="jv2002"] .rb-post-card-title a:hover {
+ color: var(--jv-red);
+ text-decoration: underline;
+}
+[data-theme="jv2002"] .rb-post-date {
+ font-size: 10px;
+ color: #666;
+}
+[data-theme="jv2002"] .rb-post-excerpt {
+ padding: 6px 8px;
+ margin: 0;
+}
+[data-theme="jv2002"] .rb-post-meta {
+ padding: 4px 8px 6px;
+ font-size: 10px;
+}
+[data-theme="jv2002"] .rb-tag {
+ font-size: 9px;
+ background: #ffe9cc;
+ border: 1px solid var(--jv-orange);
+ padding: 0 5px;
+ color: #a55400;
+}
+[data-theme="jv2002"] .rb-readmore,
+[data-theme="jv2002"] .rb-back,
+[data-theme="jv2002"] .rb-prose a {
+ color: var(--jv-link);
+ text-decoration: underline;
+ font-size: 11px;
+}
+[data-theme="jv2002"] .rb-back {
+ display: inline-block;
+ margin-bottom: 8px;
+ font-weight: bold;
+}
+[data-theme="jv2002"] .rb-article {
+ border: 1px solid var(--jv-border);
+ padding: 0;
+}
+[data-theme="jv2002"] .rb-article-title {
+ background: linear-gradient(180deg, var(--jv-bar1), var(--jv-bar2));
+ border-bottom: 1px solid var(--jv-border);
+ margin: 0;
+ padding: 6px 10px;
+ font-size: 15px;
+ color: var(--jv-red);
+}
+[data-theme="jv2002"] .rb-article-meta {
+ padding: 5px 10px;
+ font-size: 10px;
+ color: #555;
+ background: #f6f6f6;
+ border-bottom: 1px solid #ddd;
+}
+[data-theme="jv2002"] .rb-prose {
+ padding: 10px;
+ line-height: 1.5;
+ font-size: 11px;
+}
+[data-theme="jv2002"] .rb-prose h2 {
+ font-size: 13px;
+ color: var(--jv-blue);
+ border-bottom: 1px solid #ccc;
+ padding-bottom: 2px;
+}
+[data-theme="jv2002"] .rb-prose code {
+ background: #eee;
+ border: 1px solid #ccc;
+ padding: 0 3px;
+ font-family: "Courier New", monospace;
+}
+[data-theme="jv2002"] .rb-prose blockquote {
+ border-left: 3px solid var(--jv-orange);
+ margin: 8px 0;
+ padding: 4px 10px;
+ background: #fff7ec;
+}
+
+/* JV2002 didn't do back-link inside article box nicely; keep it outside */
+[data-theme="jv2002"] .rb-article > .rb-back {
+ display: block;
+ padding: 6px 10px 0;
+}
diff --git a/src/themes/nds.css b/src/themes/nds.css
new file mode 100644
index 0000000..a2351e8
--- /dev/null
+++ b/src/themes/nds.css
@@ -0,0 +1,232 @@
+/* ============================================================
+ Theme: Nintendo DS — scoped to [data-theme="nds"]
+ The DS firmware menu: brushed silver shell, glossy cyan
+ gradients, dual-screen bezel framing, chunky rounded touch
+ targets.
+ ============================================================ */
+
+[data-theme="nds"] {
+ --d-cyan: #1ba1c4;
+ --d-cyan-lt: #7fd6ec;
+ --d-silver: #c9d2d8;
+ --d-silver-dk: #9aa6ae;
+ --d-ink: #2b3a42;
+ font-family: "Trebuchet MS", "Segoe UI", Tahoma, sans-serif;
+ font-size: 14px;
+ color: var(--d-ink);
+}
+
+[data-theme="nds"] body {
+ background: linear-gradient(160deg, #aeb9c0 0%, #7e8b93 100%) fixed;
+}
+
+/* the window is the silver shell; content is the touch screen */
+[data-theme="nds"] .rb-window {
+ background: linear-gradient(180deg, #e7edf0, var(--d-silver));
+ border: 1px solid #fff;
+ border-radius: 14px;
+ padding: 6px;
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.4),
+ inset 0 0 0 1px var(--d-silver-dk);
+}
+
+[data-theme="nds"] .rb-titlebar {
+ height: 38px;
+ padding: 0 16px;
+ border-radius: 10px 10px 0 0;
+ background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
+ color: #fff;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
+}
+[data-theme="nds"] .rb-titlebar-icon {
+ width: 15px;
+ height: 15px;
+ border-radius: 4px;
+ background: linear-gradient(180deg, #fff, var(--d-cyan-lt));
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
+}
+[data-theme="nds"] .rb-titlebar-buttons {
+ display: none;
+}
+
+[data-theme="nds"] .rb-menubar {
+ padding: 8px 10px;
+ background: linear-gradient(180deg, #eef3f5, var(--d-silver));
+}
+[data-theme="nds"] .rb-menu-link {
+ text-decoration: none;
+ padding: 5px 14px;
+ border-radius: 16px;
+ color: var(--d-ink);
+ font-weight: bold;
+ background: linear-gradient(180deg, #ffffff, #dde5e9);
+ border: 1px solid var(--d-silver-dk);
+ transition: all 0.15s;
+}
+[data-theme="nds"] .rb-menu-link:hover {
+ background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
+ color: #fff;
+ border-color: var(--d-cyan);
+}
+[data-theme="nds"] .rb-switcher-label {
+ font-weight: bold;
+ font-size: 12px;
+ color: var(--d-cyan);
+}
+[data-theme="nds"] .rb-switcher-select {
+ font: 13px "Trebuchet MS", sans-serif;
+ border: 1px solid var(--d-silver-dk);
+ border-radius: 14px;
+ padding: 4px 10px;
+ background: #fff;
+}
+
+/* the lower touch screen */
+[data-theme="nds"] .rb-content {
+ background: #f7fbfc;
+ margin: 6px 2px;
+ padding: 22px 24px;
+ border-radius: 10px;
+ border: 3px solid #2b3a42;
+ box-shadow: inset 0 0 18px rgba(27, 161, 196, 0.12);
+}
+
+[data-theme="nds"] .rb-statusbar {
+ padding: 5px 12px;
+ font-size: 11px;
+ color: #5a6a72;
+}
+
+[data-theme="nds"] .rb-taskbar {
+ height: 38px;
+ background: linear-gradient(180deg, #b9c4cb, #8e9aa2);
+ border-top: 1px solid #fff;
+}
+[data-theme="nds"] .rb-start {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 28px;
+ padding: 0 16px;
+ border: 1px solid var(--d-silver-dk);
+ border-radius: 16px;
+ background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
+ color: #fff;
+ font: bold 13px "Trebuchet MS", sans-serif;
+ cursor: pointer;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
+}
+[data-theme="nds"] .rb-start-logo {
+ width: 13px;
+ height: 13px;
+ border-radius: 4px;
+ background: linear-gradient(180deg, #fff, var(--d-cyan-lt));
+}
+[data-theme="nds"] .rb-task {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 28px;
+ padding: 0 14px;
+ border: 1px solid var(--d-silver-dk);
+ border-radius: 16px;
+ background: linear-gradient(180deg, #ffffff, #dde5e9);
+ color: var(--d-ink);
+ font-weight: bold;
+ font-size: 13px;
+ cursor: default;
+}
+[data-theme="nds"] .rb-task-active {
+ background: linear-gradient(180deg, #2fb6d8, var(--d-cyan));
+ color: #fff;
+}
+[data-theme="nds"] .rb-task-icon {
+ width: 11px;
+ height: 11px;
+ border-radius: 3px;
+ background: var(--d-cyan-lt);
+}
+[data-theme="nds"] .rb-clock {
+ color: var(--d-ink);
+ font-weight: bold;
+ font-size: 13px;
+ padding: 0 14px;
+}
+
+[data-theme="nds"] .rb-page-title {
+ margin: 0 0 4px;
+ font-size: 26px;
+ font-weight: bold;
+ color: var(--d-cyan);
+}
+[data-theme="nds"] .rb-page-sub {
+ margin: 0 0 18px;
+ color: #5a6a72;
+}
+[data-theme="nds"] .rb-post-card {
+ border: 1px solid var(--d-silver-dk);
+ border-radius: 12px;
+ padding: 16px 18px;
+ background: linear-gradient(180deg, #ffffff, #eef4f6);
+ transition: box-shadow 0.2s, transform 0.2s;
+}
+[data-theme="nds"] .rb-post-card:hover {
+ box-shadow: 0 5px 16px rgba(27, 161, 196, 0.25);
+ transform: translateY(-2px);
+}
+[data-theme="nds"] .rb-post-card-title a {
+ color: var(--d-cyan);
+ text-decoration: none;
+ font-weight: bold;
+}
+[data-theme="nds"] .rb-post-date {
+ font-size: 12px;
+ color: #8a98a0;
+}
+[data-theme="nds"] .rb-tag {
+ font-size: 11px;
+ background: #e4f4f9;
+ border: 1px solid var(--d-cyan-lt);
+ border-radius: 12px;
+ padding: 1px 9px;
+ color: var(--d-cyan);
+ font-weight: bold;
+}
+[data-theme="nds"] .rb-readmore,
+[data-theme="nds"] .rb-back {
+ color: var(--d-cyan);
+ text-decoration: none;
+ font-weight: bold;
+ font-size: 13px;
+}
+[data-theme="nds"] .rb-back {
+ display: inline-block;
+ margin-bottom: 12px;
+}
+[data-theme="nds"] .rb-article-title {
+ color: var(--d-cyan);
+ font-weight: bold;
+ margin: 0 0 8px;
+}
+[data-theme="nds"] .rb-prose {
+ line-height: 1.7;
+}
+[data-theme="nds"] .rb-prose a {
+ color: var(--d-cyan);
+}
+[data-theme="nds"] .rb-prose code {
+ background: #e4f4f9;
+ border: 1px solid var(--d-cyan-lt);
+ border-radius: 6px;
+ padding: 0 5px;
+ font-family: "Courier New", monospace;
+}
+[data-theme="nds"] .rb-prose blockquote {
+ border-left: 4px solid var(--d-cyan);
+ margin: 12px 0;
+ padding: 6px 14px;
+ background: #eef7fa;
+ border-radius: 0 8px 8px 0;
+}
diff --git a/src/themes/ps1.css b/src/themes/ps1.css
new file mode 100644
index 0000000..12ef986
--- /dev/null
+++ b/src/themes/ps1.css
@@ -0,0 +1,228 @@
+/* ============================================================
+ Theme: PlayStation 1 — scoped to [data-theme="ps1"]
+ The gray BIOS shell: cool charcoal gradient, soft gray
+ panels, blocky understated type. Memory-card-manager calm.
+ ============================================================ */
+
+[data-theme="ps1"] {
+ --p1-bg1: #3a4351;
+ --p1-bg2: #1c222c;
+ --p1-panel: #c7ccd4;
+ --p1-panel-dk: #9aa1ad;
+ --p1-ink: #2a2f38;
+ --p1-accent: #6b7585;
+ font-family: "Verdana", Geneva, Tahoma, sans-serif;
+ font-size: 13px;
+ color: var(--p1-ink);
+}
+
+[data-theme="ps1"] body {
+ background: linear-gradient(160deg, var(--p1-bg1) 0%, var(--p1-bg2) 100%) fixed;
+}
+
+[data-theme="ps1"] .rb-window {
+ background: var(--p1-panel);
+ border: 2px solid #fff;
+ border-radius: 3px;
+ padding: 0;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+}
+
+[data-theme="ps1"] .rb-titlebar {
+ height: 30px;
+ padding: 0 12px;
+ background: linear-gradient(180deg, #5b6473, #424b59);
+ color: #eef1f6;
+ letter-spacing: 3px;
+ text-transform: uppercase;
+ font-size: 12px;
+}
+[data-theme="ps1"] .rb-titlebar-icon {
+ width: 14px;
+ height: 14px;
+ background: #eef1f6;
+ /* tiny CD glint */
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 4px #5b6473, inset 0 0 0 5px #eef1f6;
+}
+[data-theme="ps1"] .rb-titlebar-buttons {
+ display: none;
+}
+
+[data-theme="ps1"] .rb-menubar {
+ padding: 6px 10px;
+ background: var(--p1-panel-dk);
+ border-bottom: 1px solid #fff;
+}
+[data-theme="ps1"] .rb-menu-link {
+ text-decoration: none;
+ padding: 3px 10px;
+ color: var(--p1-ink);
+ text-transform: uppercase;
+ font-size: 11px;
+ letter-spacing: 1px;
+}
+[data-theme="ps1"] .rb-menu-link:hover {
+ background: var(--p1-ink);
+ color: #fff;
+}
+[data-theme="ps1"] .rb-switcher-label {
+ font-size: 11px;
+ text-transform: uppercase;
+}
+[data-theme="ps1"] .rb-switcher-select {
+ font: 12px Verdana, sans-serif;
+ background: #eef1f6;
+ border: 1px solid #fff;
+ border-radius: 2px;
+ padding: 2px 4px;
+}
+
+[data-theme="ps1"] .rb-content {
+ background: #e6e9ee;
+ margin: 8px;
+ padding: 20px 22px;
+ border-radius: 2px;
+ box-shadow: inset 0 0 0 1px #fff, inset 0 0 0 2px var(--p1-panel-dk);
+}
+
+[data-theme="ps1"] .rb-statusbar {
+ padding: 4px 12px;
+ font-size: 11px;
+ color: #4a525f;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+}
+
+[data-theme="ps1"] .rb-taskbar {
+ height: 34px;
+ background: linear-gradient(180deg, #424b59, var(--p1-bg2));
+ border-top: 1px solid #5b6473;
+}
+[data-theme="ps1"] .rb-start {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 24px;
+ padding: 0 14px;
+ border: 1px solid #6b7585;
+ border-radius: 2px;
+ background: #4a525f;
+ color: #eef1f6;
+ font: 11px Verdana, sans-serif;
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+[data-theme="ps1"] .rb-start-logo {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #eef1f6;
+ box-shadow: inset 0 0 0 3px #4a525f, inset 0 0 0 4px #eef1f6;
+}
+[data-theme="ps1"] .rb-task {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 24px;
+ padding: 0 12px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: #aab2c0;
+ font-size: 11px;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ cursor: default;
+}
+[data-theme="ps1"] .rb-task-active {
+ border-color: #6b7585;
+ background: #4a525f;
+ color: #eef1f6;
+}
+[data-theme="ps1"] .rb-task-icon {
+ width: 10px;
+ height: 10px;
+ background: #aab2c0;
+}
+[data-theme="ps1"] .rb-clock {
+ color: #cfd5de;
+ font-size: 11px;
+ padding: 0 14px;
+ letter-spacing: 2px;
+}
+
+[data-theme="ps1"] .rb-page-title {
+ margin: 0 0 4px;
+ font-size: 24px;
+ letter-spacing: 3px;
+ text-transform: uppercase;
+ color: var(--p1-ink);
+}
+[data-theme="ps1"] .rb-page-sub {
+ margin: 0 0 18px;
+ color: #5a626f;
+}
+[data-theme="ps1"] .rb-post-card {
+ background: #f1f3f6;
+ border: 1px solid #fff;
+ border-radius: 2px;
+ padding: 14px 16px;
+ box-shadow: 0 1px 0 var(--p1-panel-dk), inset 0 0 0 1px #d2d7df;
+}
+[data-theme="ps1"] .rb-post-card-title a {
+ color: var(--p1-ink);
+ text-decoration: none;
+ letter-spacing: 1px;
+}
+[data-theme="ps1"] .rb-post-card-title a:hover {
+ color: #000;
+ text-decoration: underline;
+}
+[data-theme="ps1"] .rb-post-date {
+ font-size: 11px;
+ color: #6b7585;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+[data-theme="ps1"] .rb-tag {
+ font-size: 10px;
+ background: #c7ccd4;
+ border: 1px solid #fff;
+ padding: 1px 7px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+[data-theme="ps1"] .rb-readmore,
+[data-theme="ps1"] .rb-back {
+ color: #3a4351;
+ text-decoration: none;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+[data-theme="ps1"] .rb-back {
+ display: inline-block;
+ margin-bottom: 12px;
+}
+[data-theme="ps1"] .rb-article-title {
+ letter-spacing: 2px;
+ text-transform: uppercase;
+ margin: 0 0 8px;
+}
+[data-theme="ps1"] .rb-prose {
+ line-height: 1.7;
+}
+[data-theme="ps1"] .rb-prose code {
+ background: #c7ccd4;
+ border: 1px solid #fff;
+ border-radius: 2px;
+ padding: 0 4px;
+ font-family: "Courier New", monospace;
+}
+[data-theme="ps1"] .rb-prose blockquote {
+ border-left: 3px solid var(--p1-accent);
+ margin: 12px 0;
+ padding: 4px 14px;
+ background: #dde1e8;
+}
diff --git a/src/themes/ps3.css b/src/themes/ps3.css
new file mode 100644
index 0000000..7cd695e
--- /dev/null
+++ b/src/themes/ps3.css
@@ -0,0 +1,279 @@
+/* ============================================================
+ Theme: PlayStation 3 — scoped to [data-theme="ps3"]
+ XMB era: deep gradient that drifts through the day, a flowing
+ light wave, glossy black-glass panels, hair-thin white type.
+ ============================================================ */
+
+[data-theme="ps3"] {
+ --p3-ink: #f4f8ff;
+ --p3-dim: #9fb4cf;
+ --p3-line: rgba(255, 255, 255, 0.16);
+ --p3-glass: rgba(8, 14, 28, 0.55);
+ font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
+ font-weight: 200;
+ font-size: 14px;
+ color: var(--p3-ink);
+}
+
+[data-theme="ps3"] body {
+ background: linear-gradient(120deg, #04122e, #0a2a5e, #04122e, #1a0a3e);
+ background-size: 400% 400%;
+ animation: ps3-sky 40s ease infinite;
+}
+@keyframes ps3-sky {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+/* the flowing XMB wave */
+[data-theme="ps3"] body::before {
+ content: "";
+ position: fixed;
+ left: -10%;
+ right: -10%;
+ top: 38%;
+ height: 220px;
+ pointer-events: none;
+ background: radial-gradient(
+ 60% 100% at 50% 50%,
+ rgba(150, 200, 255, 0.22),
+ transparent 70%
+ );
+ filter: blur(8px);
+ transform: skewY(-4deg);
+ animation: ps3-wave 14s ease-in-out infinite alternate;
+ z-index: 0;
+}
+@keyframes ps3-wave {
+ from {
+ transform: skewY(-5deg) translateY(-30px);
+ }
+ to {
+ transform: skewY(3deg) translateY(40px);
+ }
+}
+[data-theme="ps3"] .rb-desktop {
+ position: relative;
+ z-index: 1;
+}
+
+[data-theme="ps3"] .rb-window {
+ background: var(--p3-glass);
+ border: 1px solid var(--p3-line);
+ border-radius: 4px;
+ padding: 0;
+ backdrop-filter: blur(10px);
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+[data-theme="ps3"] .rb-titlebar {
+ height: 40px;
+ padding: 0 18px;
+ border-bottom: 1px solid var(--p3-line);
+ color: var(--p3-ink);
+ font-weight: 200;
+ letter-spacing: 1px;
+}
+[data-theme="ps3"] .rb-titlebar-icon {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
+ box-shadow: 0 0 12px rgba(120, 180, 255, 0.8);
+}
+[data-theme="ps3"] .rb-titlebar-buttons {
+ display: none;
+}
+
+[data-theme="ps3"] .rb-menubar {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--p3-line);
+}
+[data-theme="ps3"] .rb-menu-link {
+ text-decoration: none;
+ padding: 5px 14px;
+ color: var(--p3-dim);
+ font-size: 13px;
+ transition: color 0.25s, text-shadow 0.25s;
+}
+[data-theme="ps3"] .rb-menu-link:hover {
+ color: #fff;
+ text-shadow: 0 0 12px rgba(150, 200, 255, 0.9);
+}
+[data-theme="ps3"] .rb-switcher-label {
+ color: var(--p3-dim);
+ font-size: 12px;
+}
+[data-theme="ps3"] .rb-switcher-select {
+ background: rgba(4, 12, 28, 0.85);
+ color: var(--p3-ink);
+ border: 1px solid var(--p3-line);
+ border-radius: 3px;
+ padding: 4px 8px;
+ font: 12px "Segoe UI", sans-serif;
+}
+
+[data-theme="ps3"] .rb-content {
+ padding: 26px 30px;
+}
+
+[data-theme="ps3"] .rb-statusbar {
+ border-top: 1px solid var(--p3-line);
+ padding: 7px 18px;
+ font-size: 11px;
+ color: var(--p3-dim);
+ letter-spacing: 1px;
+}
+
+[data-theme="ps3"] .rb-taskbar {
+ height: 38px;
+ background: linear-gradient(180deg, rgba(8, 18, 40, 0.75), rgba(2, 6, 16, 0.9));
+ border-top: 1px solid var(--p3-line);
+ backdrop-filter: blur(8px);
+}
+[data-theme="ps3"] .rb-start {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ height: 26px;
+ padding: 0 18px;
+ border: 1px solid var(--p3-line);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--p3-ink);
+ font: 200 12px "Segoe UI", sans-serif;
+ letter-spacing: 1px;
+ cursor: pointer;
+}
+[data-theme="ps3"] .rb-start:hover {
+ background: rgba(120, 180, 255, 0.18);
+}
+[data-theme="ps3"] .rb-start-logo {
+ width: 11px;
+ height: 11px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #fff, #6fa8ff);
+ box-shadow: 0 0 10px rgba(120, 180, 255, 0.8);
+}
+[data-theme="ps3"] .rb-task {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 26px;
+ padding: 0 14px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--p3-dim);
+ font-size: 12px;
+ cursor: default;
+}
+[data-theme="ps3"] .rb-task-active {
+ border-color: var(--p3-line);
+ border-radius: 14px;
+ color: #fff;
+ background: rgba(255, 255, 255, 0.06);
+}
+[data-theme="ps3"] .rb-task-icon {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #6fa8ff;
+}
+[data-theme="ps3"] .rb-clock {
+ color: var(--p3-ink);
+ font-size: 12px;
+ padding: 0 16px;
+ font-weight: 200;
+ letter-spacing: 1px;
+}
+
+[data-theme="ps3"] .rb-page-title {
+ margin: 0 0 4px;
+ font-size: 34px;
+ font-weight: 100;
+ letter-spacing: 1px;
+ color: #fff;
+ text-shadow: 0 2px 20px rgba(120, 180, 255, 0.5);
+}
+[data-theme="ps3"] .rb-page-sub {
+ margin: 0 0 24px;
+ color: var(--p3-dim);
+}
+[data-theme="ps3"] .rb-post-card {
+ border: 1px solid var(--p3-line);
+ border-radius: 4px;
+ padding: 18px 20px;
+ background: rgba(255, 255, 255, 0.04);
+ transition: background 0.25s, box-shadow 0.25s, transform 0.25s;
+}
+[data-theme="ps3"] .rb-post-card:hover {
+ background: rgba(120, 180, 255, 0.12);
+ box-shadow: 0 0 30px rgba(80, 150, 255, 0.3);
+ transform: translateX(4px);
+}
+[data-theme="ps3"] .rb-post-card-title a {
+ color: #fff;
+ text-decoration: none;
+ font-weight: 200;
+}
+[data-theme="ps3"] .rb-post-card-title a:hover {
+ text-shadow: 0 0 12px rgba(150, 200, 255, 0.9);
+}
+[data-theme="ps3"] .rb-post-date {
+ font-size: 11px;
+ color: var(--p3-dim);
+}
+[data-theme="ps3"] .rb-post-excerpt {
+ color: #d3e2f7;
+}
+[data-theme="ps3"] .rb-tag {
+ font-size: 10px;
+ border: 1px solid var(--p3-line);
+ border-radius: 12px;
+ padding: 1px 10px;
+ color: #bcd4f5;
+}
+[data-theme="ps3"] .rb-readmore,
+[data-theme="ps3"] .rb-back {
+ color: #9fc6ff;
+ text-decoration: none;
+ font-size: 12px;
+}
+[data-theme="ps3"] .rb-back {
+ display: inline-block;
+ margin-bottom: 14px;
+}
+[data-theme="ps3"] .rb-article-title {
+ font-weight: 100;
+ color: #fff;
+ text-shadow: 0 2px 18px rgba(120, 180, 255, 0.45);
+ margin: 0 0 8px;
+}
+[data-theme="ps3"] .rb-prose {
+ line-height: 1.85;
+ color: #dce8f9;
+ font-weight: 300;
+}
+[data-theme="ps3"] .rb-prose a {
+ color: #9fc6ff;
+}
+[data-theme="ps3"] .rb-prose code {
+ background: rgba(4, 12, 28, 0.7);
+ border: 1px solid var(--p3-line);
+ border-radius: 3px;
+ padding: 0 5px;
+ font-family: "Consolas", monospace;
+}
+[data-theme="ps3"] .rb-prose blockquote {
+ border-left: 2px solid #6fa8ff;
+ margin: 14px 0;
+ padding: 6px 16px;
+ background: rgba(120, 180, 255, 0.1);
+}
diff --git a/src/themes/registry.ts b/src/themes/registry.ts
index b140025..302cebc 100644
--- a/src/themes/registry.ts
+++ b/src/themes/registry.ts
@@ -2,7 +2,16 @@
// 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 ThemeId =
+ | "xp"
+ | "ninex"
+ | "ps2"
+ | "ps1"
+ | "ps3"
+ | "wii"
+ | "nds"
+ | "dreamcast"
+ | "jv2002";
export type ThemeMeta = {
id: ThemeId;
@@ -32,6 +41,42 @@ export const THEMES: ThemeMeta[] = [
blurb: "Swaying blue towers boot menu.",
era: "2000",
},
+ {
+ id: "ps1",
+ name: "PlayStation 1",
+ blurb: "Gray BIOS. Memory card and CD player.",
+ era: "1994",
+ },
+ {
+ id: "ps3",
+ name: "PlayStation 3",
+ blurb: "XMB wave. Glossy black glass.",
+ era: "2006",
+ },
+ {
+ id: "wii",
+ name: "Nintendo Wii",
+ blurb: "White channels and soft blue glow.",
+ era: "2006",
+ },
+ {
+ id: "nds",
+ name: "Nintendo DS",
+ blurb: "Dual-screen firmware, silver and cyan.",
+ era: "2004",
+ },
+ {
+ id: "dreamcast",
+ name: "Dreamcast",
+ blurb: "Orange swirl. Bubbly VMU vibes.",
+ era: "1998",
+ },
+ {
+ id: "jv2002",
+ name: "Jeuxvideo.com 2002",
+ blurb: "Dense tables, Verdana 11px, blue links.",
+ era: "2002",
+ },
];
export const DEFAULT_THEME: ThemeId = "xp";
diff --git a/src/themes/server.ts b/src/themes/server.ts
index 3892762..5ed54a2 100644
--- a/src/themes/server.ts
+++ b/src/themes/server.ts
@@ -1,10 +1,13 @@
import "server-only";
import { cookies } from "next/headers";
-import { DEFAULT_THEME, THEME_COOKIE, isThemeId, type ThemeId } from "./registry";
+import { THEME_COOKIE, isThemeId, type ThemeId } from "./registry";
+import { getSettings } from "@/lib/settings";
-// Resolve the active theme on the server from the persisted cookie.
+// Resolve the active theme on the server: the visitor's persisted cookie wins,
+// otherwise fall back to the admin-configured default theme.
export async function getTheme(): Promise {
const store = await cookies();
const v = store.get(THEME_COOKIE)?.value;
- return isThemeId(v) ? v : DEFAULT_THEME;
+ if (isThemeId(v)) return v;
+ return getSettings().defaultTheme;
}
diff --git a/src/themes/wii.css b/src/themes/wii.css
new file mode 100644
index 0000000..c88cc78
--- /dev/null
+++ b/src/themes/wii.css
@@ -0,0 +1,230 @@
+/* ============================================================
+ Theme: Nintendo Wii — scoped to [data-theme="wii"]
+ The Wii Menu: clean white, soft gray gradients, rounded
+ channel tiles, a gentle blue glow on everything.
+ ============================================================ */
+
+[data-theme="wii"] {
+ --w-blue: #009ac7;
+ --w-blue-lt: #4fc6e8;
+ --w-ink: #4a4a4a;
+ --w-line: #d6dde2;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ color: var(--w-ink);
+}
+
+[data-theme="wii"] body {
+ background: linear-gradient(180deg, #fafdff 0%, #e8eef3 100%) fixed;
+}
+
+[data-theme="wii"] .rb-window {
+ background: #fff;
+ border: 1px solid var(--w-line);
+ border-radius: 16px;
+ padding: 0;
+ box-shadow: 0 8px 30px rgba(0, 120, 170, 0.12);
+}
+
+[data-theme="wii"] .rb-titlebar {
+ height: 44px;
+ padding: 0 20px;
+ border-bottom: 1px solid var(--w-line);
+ border-radius: 16px 16px 0 0;
+ background: linear-gradient(180deg, #ffffff, #f1f6f9);
+ color: var(--w-blue);
+ font-weight: 700;
+ letter-spacing: 0.5px;
+}
+[data-theme="wii"] .rb-titlebar-icon {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #fff, var(--w-blue-lt) 60%, var(--w-blue));
+ box-shadow: 0 0 10px rgba(0, 154, 199, 0.5);
+}
+[data-theme="wii"] .rb-titlebar-buttons {
+ display: none;
+}
+
+[data-theme="wii"] .rb-menubar {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--w-line);
+}
+[data-theme="wii"] .rb-menu-link {
+ text-decoration: none;
+ padding: 6px 16px;
+ border-radius: 20px;
+ color: var(--w-ink);
+ font-weight: 600;
+ transition: background 0.2s, color 0.2s;
+}
+[data-theme="wii"] .rb-menu-link:hover {
+ background: var(--w-blue);
+ color: #fff;
+ box-shadow: 0 0 12px rgba(0, 154, 199, 0.5);
+}
+[data-theme="wii"] .rb-switcher-label {
+ color: var(--w-blue);
+ font-weight: 700;
+ font-size: 12px;
+}
+[data-theme="wii"] .rb-switcher-select {
+ font: 13px "Helvetica Neue", sans-serif;
+ border: 1px solid var(--w-line);
+ border-radius: 20px;
+ padding: 5px 12px;
+ background: #fff;
+ color: var(--w-ink);
+}
+
+[data-theme="wii"] .rb-content {
+ padding: 26px 28px;
+}
+
+[data-theme="wii"] .rb-statusbar {
+ border-top: 1px solid var(--w-line);
+ padding: 8px 20px;
+ font-size: 12px;
+ color: #8a99a3;
+}
+
+[data-theme="wii"] .rb-taskbar {
+ height: 40px;
+ background: linear-gradient(180deg, #ffffff, #e6edf2);
+ border-top: 1px solid var(--w-line);
+}
+[data-theme="wii"] .rb-start {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 28px;
+ padding: 0 18px;
+ border: 1px solid var(--w-line);
+ border-radius: 20px;
+ background: #fff;
+ color: var(--w-blue);
+ font: 700 13px "Helvetica Neue", sans-serif;
+ cursor: pointer;
+ box-shadow: 0 1px 4px rgba(0, 120, 170, 0.15);
+}
+[data-theme="wii"] .rb-start:hover {
+ background: var(--w-blue);
+ color: #fff;
+}
+[data-theme="wii"] .rb-start-logo {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 35% 30%, #fff, var(--w-blue-lt) 60%, var(--w-blue));
+}
+[data-theme="wii"] .rb-task {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 28px;
+ padding: 0 16px;
+ border: 1px solid var(--w-line);
+ border-radius: 20px;
+ background: #fff;
+ color: var(--w-ink);
+ font-weight: 600;
+ font-size: 13px;
+ cursor: default;
+}
+[data-theme="wii"] .rb-task-active {
+ background: var(--w-blue);
+ color: #fff;
+ box-shadow: 0 0 12px rgba(0, 154, 199, 0.5);
+}
+[data-theme="wii"] .rb-task-icon {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--w-blue-lt);
+}
+[data-theme="wii"] .rb-clock {
+ color: var(--w-blue);
+ font-weight: 700;
+ font-size: 13px;
+ padding: 0 16px;
+}
+
+[data-theme="wii"] .rb-page-title {
+ margin: 0 0 4px;
+ font-size: 28px;
+ font-weight: 800;
+ color: var(--w-blue);
+}
+[data-theme="wii"] .rb-page-sub {
+ margin: 0 0 20px;
+ color: #7a8a93;
+}
+[data-theme="wii"] .rb-post-list {
+ gap: 18px;
+}
+[data-theme="wii"] .rb-post-card {
+ border: 1px solid var(--w-line);
+ border-radius: 16px;
+ padding: 18px 20px;
+ background: linear-gradient(180deg, #ffffff, #f6fafc);
+ transition: box-shadow 0.2s, transform 0.2s;
+}
+[data-theme="wii"] .rb-post-card:hover {
+ box-shadow: 0 6px 22px rgba(0, 154, 199, 0.22);
+ transform: translateY(-2px);
+}
+[data-theme="wii"] .rb-post-card-title a {
+ color: var(--w-blue);
+ text-decoration: none;
+ font-weight: 700;
+}
+[data-theme="wii"] .rb-post-date {
+ font-size: 12px;
+ color: #9aa8b0;
+}
+[data-theme="wii"] .rb-tag {
+ font-size: 11px;
+ background: #eef6fa;
+ border: 1px solid #c5e6f1;
+ border-radius: 12px;
+ padding: 1px 10px;
+ color: var(--w-blue);
+ font-weight: 600;
+}
+[data-theme="wii"] .rb-readmore,
+[data-theme="wii"] .rb-back {
+ color: var(--w-blue);
+ text-decoration: none;
+ font-weight: 600;
+ font-size: 13px;
+}
+[data-theme="wii"] .rb-back {
+ display: inline-block;
+ margin-bottom: 14px;
+}
+[data-theme="wii"] .rb-article-title {
+ color: var(--w-blue);
+ font-weight: 800;
+ margin: 0 0 8px;
+}
+[data-theme="wii"] .rb-prose {
+ line-height: 1.75;
+}
+[data-theme="wii"] .rb-prose a {
+ color: var(--w-blue);
+}
+[data-theme="wii"] .rb-prose code {
+ background: #eef6fa;
+ border: 1px solid #c5e6f1;
+ border-radius: 6px;
+ padding: 0 6px;
+ font-family: "Courier New", monospace;
+}
+[data-theme="wii"] .rb-prose blockquote {
+ border-left: 4px solid var(--w-blue-lt);
+ margin: 14px 0;
+ padding: 6px 16px;
+ background: #f3fafd;
+ border-radius: 0 8px 8px 0;
+}