docs: README for the blueprint structure; drop standalone-UI sections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:07:36 +02:00
parent 899028e20f
commit e6144773d7
22 changed files with 34 additions and 3131 deletions
+34 -39
View File
@@ -15,7 +15,7 @@ even uses the same art source (SteamGridDB) when you give it an API key.
- A Punktfunk host (Linux or Windows) with the **scripting runner** (`punktfunk-scripting`) enabled. - A Punktfunk host (Linux or Windows) with the **scripting runner** (`punktfunk-scripting`) enabled.
- [Bun](https://bun.sh) (the runner is Bun). - [Bun](https://bun.sh) (the runner is Bun).
- `@punktfunk/host` **≥ 0.1.1** — it provides the plugin-UI surface (`servePluginUi`) and discovers - `@punktfunk/host` ** 0.1.2** + `@punktfunk/plugin-kit` ** 0.1.1** — it provides the plugin-UI surface (`servePluginUi`) and discovers
scoped `@punktfunk/plugin-*` packages. scoped `@punktfunk/plugin-*` packages.
## Install ## Install
@@ -39,12 +39,12 @@ Enable-ScheduledTask PunktfunkScripting # Windows
Open the Punktfunk console — a **ROM Manager** entry appears in the nav. That's it. Open the Punktfunk console — a **ROM Manager** entry appears in the nav. That's it.
> **Headless / host-only boxes** without the console can use the [standalone UI](#standalone-ui) or > **Headless / host-only boxes** without the console can just drop a `config.json` (below) and use
> just drop a `config.json` (below) — the sync engine is fully functional from the file alone. > the CLI — the sync engine is fully functional from the file alone.
## Quick start (config file) ## Quick start (config file)
The plugin owns `<config_dir>/rom-manager/config.json`. A minimal example: The plugin owns `<config_dir>/plugin-state/rom-manager/config.json`. A minimal example:
```jsonc ```jsonc
{ {
@@ -56,7 +56,8 @@ The plugin owns `<config_dir>/rom-manager/config.json`. A minimal example:
} }
``` ```
See [`config.example.json`](./config.example.json) for every option. Then, from the plugin dir: Only the keys you author are stored — defaults live in the schema (`contract/src/config.ts`) and are
never baked into your file. Then, from the plugin dir:
```sh ```sh
bunx punktfunk-plugin-rom-manager scan # what the scanner finds bunx punktfunk-plugin-rom-manager scan # what the scanner finds
@@ -95,27 +96,12 @@ sheets hide their tracks, `.chd` stands alone.
## The UI ## The UI
### Console-hosted (default)
`servePluginUi` serves the SPA on a loopback ephemeral port behind a per-boot secret and registers it `servePluginUi` serves the SPA on a loopback ephemeral port behind a per-boot secret and registers it
with the host; the console reverse-proxies it and gates it with your existing console sign-in. Four with the host; the console reverse-proxies it and gates it with your existing console sign-in. Five
pages: **Setup** (ROM roots), **Emulators** (detected + per-platform overrides), **Games** (scan pages: **Overview** (health, stats, live sync activity), **Library** (scan preview with covers
preview with covers + per-title include toggles), **Sync** (status, art settings, sync options). + include toggles), **Sources** (ROM roots), **Emulators** (detection + per-platform overrides),
**Settings** (art, sync, file paths). The console surface is the only UI — the old standalone
### Standalone UI password server was removed in 0.3.0 (headless boxes drive the plugin via the CLI).
For host-only installs, set `ui.standalone: true` (config). It serves the same SPA on a fixed port:
```jsonc
"ui": { "standalone": true, "port": 47993, "bind": "127.0.0.1" }
```
A **non-loopback bind requires a password** (fail-closed — the UI can edit launch commands, i.e.
host-user code):
```sh
bunx punktfunk-plugin-rom-manager set-password 'your-password'
```
## Security ## Security
@@ -125,24 +111,33 @@ bunx punktfunk-plugin-rom-manager set-password 'your-password'
- `config.json` lives in the hardened `<config_dir>`; the plugin refuses a group/world-writable one. - `config.json` lives in the hardened `<config_dir>`; the plugin refuses a group/world-writable one.
- Outbound network in v1: SteamGridDB (if keyed) and libretro-thumbnails only. No telemetry. - Outbound network in v1: SteamGridDB (if keyed) and libretro-thumbnails only. No telemetry.
## Development ## Development — this repo is the plugin blueprint
rom-manager is the reference consumer of
[`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit) — copy this
repo's structure to start a new plugin. Three bun workspaces:
| Workspace | What it is |
|---|---|
| `contract/` | **The single source of truth**: Effect Schemas for the config (raw↔resolved in one schema), domain DTOs, the `HttpApi` contract, API errors. Never published — bundled into the plugin, imported source-level by the UI. No hand-mirrored types anywhere. |
| `plugin/` | The published package. `src/domain` + `src/art` are the pure, injectable, unit-tested core; `src/services` wires them into the kit (ConfigService, CacheStore, SyncEngine, ProviderClient, HttpApi handlers + SSE); `src/index.ts` is the `definePluginKit` entry (async at the runner boundary, Effect inside). |
| `ui/` | The SPA: React 19 + `@unom/ui` + `@unom/app-ui` + the kit's `/react` helpers + `/theme.css`, with an Effect-native data layer (`AtomHttpApi` atoms derived from the contract). Builds into `plugin/dist/ui`. |
```sh ```sh
bun install # backend deps (@punktfunk/host + effect) from the Gitea registry bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes)
bun test # engine unit tests (pure core: quoting, scanner, reconcile, art) bun test # domain + service tests (plugin/)
bun run typecheck bun run typecheck # contract → plugin → ui
bun run build:all # backend → dist/ (tsc), SPA → dist/ui/ (Vite) bun run build # backend bundle + SPA → plugin/dist
cd ui && bun run dev # SPA dev server (point it at a running standalone instance)
cd ui && bun run dev # UI with ZERO host running (in-browser mock layer + scenarios)
cd ui && bun run storybook # per-page stories on the same fixtures (port 6013)
cd plugin && bun run dev # auth-free dev API on :5885 …
cd ui && bun run dev:live # … and the UI proxied against it
``` ```
- The backend is plain `tsc` output — it depends on `@punktfunk/host` + `effect` at runtime (shared Requires `@punktfunk/host` ≥ 0.1.2 and `@punktfunk/plugin-kit` ≥ 0.1.1 at runtime (shared with the
with the runner and other plugins, not bundled). runner, not bundled). `@unom/*` + `@effect/atom-react` are build-time only — the SPA ships as static
- The **SPA** (`ui/`) uses the console's design system (`@unom/ui` + `@unom/style` + Tailwind v4) so it assets, so the published plugin carries no UI runtime dependencies.
reads as family. Those are **build-time only** — the SPA compiles to static assets in `dist/ui`, so
the published plugin carries no `@unom` runtime dependency. Installing them needs the `@unom` scope in
your `~/.npmrc` (with the Gitea token); CI needs the same token as a secret.
Requires `@punktfunk/host` ≥ 0.1.1.
## License ## License
-1373
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
# The @unom design-system packages (@unom/ui, @unom/style) live on the Gitea registry. Auth comes
# from your ~/.npmrc (`//git.unom.io/api/packages/unom/npm/:_authToken`); CI needs the same token as a
# secret. These are BUILD-time only — the SPA compiles to static assets in ../dist/ui, so the published
# plugin has no @unom runtime dependency.
[install.scopes]
"@unom" = "https://git.unom.io/api/packages/unom/npm/"
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ROM Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-34
View File
@@ -1,34 +0,0 @@
{
"name": "punktfunk-plugin-rom-manager-ui",
"private": true,
"type": "module",
"description": "The ROM Manager plugin SPA — built into ../dist/ui and served by servePluginUi. Uses @unom/ui so it reads as family with the punktfunk console.",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"motion": "^12.42.2",
"radix-ui": "^1.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^2.6.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.0"
}
}
-82
View File
@@ -1,82 +0,0 @@
import { Gamepad2 } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "./lib/utils.js";
import { Emulators } from "./pages/Emulators.js";
import { Games } from "./pages/Games.js";
import { Setup } from "./pages/Setup.js";
import { Sync } from "./pages/Sync.js";
const TABS = [
{ id: "setup", label: "Setup", Page: Setup },
{ id: "emulators", label: "Emulators", Page: Emulators },
{ id: "games", label: "Games", Page: Games },
{ id: "sync", label: "Sync", Page: Sync },
] as const;
type TabId = (typeof TABS)[number]["id"];
const tabFromHash = (): TabId => {
const h = window.location.hash.replace(/^#\/?/, "");
return (TABS.find((t) => t.id === h)?.id ?? "setup") as TabId;
};
export const App = () => {
const [tab, setTab] = useState<TabId>(tabFromHash);
useEffect(() => {
const onHash = () => setTab(tabFromHash());
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
const go = (id: TabId) => {
window.location.hash = `/${id}`;
setTab(id);
// Best-effort deep-link sync with the console shell (plugin-ui-surface §5; ignored elsewhere).
try {
window.parent?.postMessage({ type: "pf-ui:navigate", path: id }, "*");
} catch {
// not embedded
}
};
const Page = TABS.find((t) => t.id === tab)?.Page ?? Setup;
return (
<div className="mx-auto max-w-5xl px-6 pb-16 pt-6">
<header className="flex items-center gap-3">
<span className="grid size-9 place-items-center rounded-card bg-primary/15 text-primary">
<Gamepad2 className="size-5" />
</span>
<div>
<h1 className="text-lg font-semibold leading-tight">ROM Manager</h1>
<p className="text-sm text-muted-foreground">
Scan ROMs into your Punktfunk library
</p>
</div>
</header>
<nav className="mt-6 flex flex-wrap gap-1 border-b border-border">
{TABS.map((t) => (
<button
type="button"
key={t.id}
onClick={() => go(t.id)}
className={cn(
"-mb-px border-b-2 px-4 py-2 text-sm font-medium transition-colors",
t.id === tab
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t.label}
</button>
))}
</nav>
<main className="mt-6 flex flex-col gap-6">
<Page />
</main>
</div>
);
};
-165
View File
@@ -1,165 +0,0 @@
// Typed client for the plugin-local API (relative paths — the SPA is mounted under the console proxy
// prefix, so `api/...` resolves to `/plugin-ui/rom-manager/api/...`). Types mirror the backend.
import { useEffect, useState } from "react";
export interface Platform {
id: string;
name: string;
extensions: string[];
libretroSystem?: string;
defaultLaunch: { emulator: string; core?: string; extraArgs?: string };
disc?: boolean;
}
export interface EmulatorDef {
id: string;
name: string;
template: string;
supportsArchives: boolean;
contested?: boolean;
detect?: { linux: string[]; windows: string[] };
}
export interface Detected {
id: string;
name: string;
via: "path" | "file" | "flatpak";
exeToken: string;
contested?: boolean;
coresDir?: string;
cores?: string[];
}
export interface RomRoot {
dir: string;
platform: string;
excludes?: string[];
}
export interface GameOverride {
exclude?: boolean;
emulator?: string;
core?: string;
extraArgs?: string;
title?: string;
art?: string;
}
export interface Config {
roots: RomRoot[];
platformLaunch: Record<
string,
{ emulator: string; core?: string; extraArgs?: string }
>;
gameOverrides: Record<string, GameOverride>;
/** Operator-added / overriding emulator defs (custom launchers). */
emulators?: EmulatorDef[];
sync: {
pollMinutes: number;
watch: boolean;
debounceMs: number;
dedupeRegions: string[];
regionPriority: string[];
warnEntries: number;
maxEntries: number;
closeOnEnd: boolean;
};
art: {
enabled: boolean;
provider: "auto" | "steamgriddb" | "libretro";
steamGridDbKey?: string;
};
ui: {
standalone: boolean;
port: number;
bind: string;
passwordHash?: string;
};
devEntry: boolean;
}
export interface Artwork {
portrait?: string | null;
hero?: string | null;
logo?: string | null;
header?: string | null;
}
export interface Entry {
external_id: string;
title: string;
launch?: { kind: string; value: string } | null;
art?: Artwork;
prep?: { do: string; undo?: string | null }[];
}
export interface Skipped {
external_id: string;
title: string;
reason: string;
}
export interface Report {
considered: number;
included: number;
skipped: Skipped[];
excluded: { external_id: string; title: string }[];
warnings: string[];
truncated: number;
perPlatform: Record<string, number>;
overWarn: boolean;
}
export interface Preview {
entries: Entry[];
report: Report;
detected: Detected[];
}
export interface Status {
rootsConfigured: number;
os: "linux" | "windows";
artProvider: string | null;
lastSync?: { fingerprint: string; count: number; at: number };
lastReport?: Report;
detected?: { at: number; emulators: Detected[] };
paths: { dir: string; config: string; cache: string; relConfig: string };
syncing: boolean;
}
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
const res = await fetch(path, {
headers: { "content-type": "application/json" },
...opts,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
};
export const getStatus = () => api<Status>("api/status");
export const getConfig = () => api<Config>("api/config");
export const putConfig = (config: Config) =>
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
export const getPreview = () => api<Preview>("api/preview");
export const runDetect = () =>
api<Detected[]>("api/detect", { method: "POST" });
export const runSync = () => api<Report>("api/sync", { method: "POST" });
export const getPlatforms = () => api<Platform[]>("api/platforms");
export const getEmulators = () =>
api<{ defs: EmulatorDef[]; detected: Detected[] }>("api/emulators");
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
export const useStatusStream = (): Status | undefined => {
const [status, setStatus] = useState<Status>();
useEffect(() => {
getStatus()
.then(setStatus)
.catch(() => {});
try {
const es = new EventSource("api/events");
es.addEventListener("status", (e) => {
try {
setStatus(JSON.parse((e as MessageEvent).data));
} catch {
// ignore malformed frame
}
});
return () => es.close();
} catch {
return;
}
}, []);
return status;
};
-28
View File
@@ -1,28 +0,0 @@
// A small shadcn-style badge on the shared tokens (pill, brand/muted/state variants).
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-secondary text-secondary-foreground",
brand: "border-transparent bg-primary/15 text-primary",
outline: "text-muted-foreground",
success: "border-success/40 text-success",
warn: "border-amber-500/40 text-amber-400",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export const Badge = ({ className, variant, ...props }: BadgeProps) => (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
);
-52
View File
@@ -1,52 +0,0 @@
// Button — the console's exact variant/size vocabulary + brand tokens (pill shape, `--primary` fill),
// but a plain <button> rather than @unom/ui's AnimatedButton: the animated one statically imports ~7 MB
// of UI click/hover sound assets (fine for the full console, absurd for an embedded iframe). Same look,
// no audio. `buttonVariants` here mirrors @unom/ui's so it reads identically.
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-secondary hover:text-secondary-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-secondary hover:text-secondary-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = ({
className,
variant,
size,
type = "button",
...props
}: ButtonProps) => (
<button
type={type}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
-56
View File
@@ -1,56 +0,0 @@
// Card — the console's card surface (bg-card, brand-violet ring, rounded-card) as a plain element set,
// on the shared @unom tokens. We skip @unom/ui's AnimatedCard (motion + material) to keep the bundle
// lean; the look matches because the tokens (colour/radius/border) are identical.
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
export const Card = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"rounded-card border border-border bg-card text-card-foreground shadow-sm ring-1 ring-accent/30",
className,
)}
{...props}
/>
);
export const CardHeader = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
);
export const CardTitle = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<h2
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
export const CardDescription = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
export const CardContent = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("p-6 pt-0", className)} {...props} />
);
export const CardFooter = ({
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
-23
View File
@@ -1,23 +0,0 @@
// shadcn-style input + select on the shared tokens, so form controls match the console's inputs.
import type { InputHTMLAttributes, SelectHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
const base =
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50";
export const Input = ({
className,
...props
}: InputHTMLAttributes<HTMLInputElement>) => (
<input className={cn(base, className)} {...props} />
);
export const Select = ({
className,
children,
...props
}: SelectHTMLAttributes<HTMLSelectElement>) => (
<select className={cn(base, "cursor-pointer", className)} {...props}>
{children}
</select>
);
-36
View File
@@ -1,36 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { type Config, getConfig, putConfig } from "./api.js";
/** Load + save the plugin config, with a local editable copy. */
export const useConfig = () => {
const [config, setConfig] = useState<Config | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const reload = useCallback(() => {
getConfig()
.then(setConfig)
.catch((e) => setError(String(e)));
}, []);
useEffect(reload, [reload]);
const save = useCallback(async (next: Config): Promise<boolean> => {
setSaving(true);
setError(undefined);
try {
setConfig(await putConfig(next));
return true;
} catch (e) {
setError(String(e));
return false;
} finally {
setSaving(false);
}
}, []);
return { config, setConfig, reload, save, saving, error };
};
/** The platform id out of an `external_id` (`snes/Foo.sfc` → `snes`; `snes/0/Foo.sfc` → `snes`). */
export const platformOf = (externalId: string): string =>
externalId.split("/")[0] ?? "";
-5
View File
@@ -1,5 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/** shadcn/ui's class combiner: merge conditional classes, dedupe Tailwind conflicts (same as the console). */
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs));
-13
View File
@@ -1,13 +0,0 @@
import "@fontsource-variable/geist";
import { Toaster } from "@unom/ui/toast";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<Toaster />
</StrictMode>,
);
-381
View File
@@ -1,381 +0,0 @@
import { toast } from "@unom/ui/toast";
import { Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import {
type Detected,
type EmulatorDef,
getEmulators,
getPlatforms,
type Platform,
runDetect,
} from "../api.js";
import { Badge } from "../components/ui/badge.js";
import { Button } from "../components/ui/button.js";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import { Input, Select } from "../components/ui/input.js";
import { useConfig } from "../hooks.js";
export const Emulators = () => {
const { config, setConfig, save, saving } = useConfig();
const [defs, setDefs] = useState<EmulatorDef[]>([]);
const [detected, setDetected] = useState<Detected[]>([]);
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [detecting, setDetecting] = useState(false);
const load = () =>
getEmulators().then((e) => {
setDefs(e.defs);
setDetected(e.detected);
});
useEffect(() => {
load();
getPlatforms()
.then(setPlatforms)
.catch(() => {});
}, []);
const detectedById = new Map(detected.map((d) => [d.id, d]));
const onSave = async (next = config) => {
if (next && (await save(next))) toast.success("Saved — syncing library");
};
return (
<>
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>Detected emulators</CardTitle>
<CardDescription>
Best-effort detection: PATH, Flatpak, then known install paths.
</CardDescription>
</div>
<Button
size="sm"
variant="outline"
disabled={detecting}
onClick={async () => {
setDetecting(true);
try {
setDetected(await runDetect());
toast.success("Re-detected emulators");
} finally {
setDetecting(false);
}
}}
>
{detecting ? "Detecting…" : "Re-detect"}
</Button>
</CardHeader>
<CardContent>
<div className="divide-y divide-border">
{defs.map((def) => {
const d = detectedById.get(def.id);
return (
<div
key={def.id}
className="flex items-center gap-3 py-2 text-sm"
>
<span className="font-medium">{def.name}</span>
<span className="font-mono text-xs text-muted-foreground">
{def.id}
</span>
{def.contested && <Badge variant="warn">contested</Badge>}
<span className="ml-auto flex items-center gap-3">
{d?.cores?.length ? (
<span className="text-xs text-muted-foreground">
{d.cores.length} cores
</span>
) : null}
{d ? (
<Badge variant="success">detected · {d.via}</Badge>
) : (
<Badge variant="outline">not found</Badge>
)}
</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{config && (
<CustomEmulators
config={config}
setConfig={setConfig}
onSave={onSave}
saving={saving}
reloadDefs={load}
/>
)}
{config && (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>Per-platform emulator</CardTitle>
<CardDescription>
The default emulator + core for each platform. Override to
prefer a different emulator (including a custom one) or
RetroArch core.
</CardDescription>
</div>
<Button size="sm" disabled={saving} onClick={() => onSave()}>
{saving ? "Saving…" : "Save & sync"}
</Button>
</CardHeader>
<CardContent className="max-h-[420px] overflow-auto">
<div className="divide-y divide-border">
{platforms.map((p) => {
const override = config.platformLaunch[p.id];
const emulator = override?.emulator ?? p.defaultLaunch.emulator;
const core = override?.core ?? p.defaultLaunch.core ?? "";
const detCores = detectedById.get(emulator)?.cores ?? [];
const setLaunch = (patch: {
emulator?: string;
core?: string;
}) =>
setConfig({
...config,
platformLaunch: {
...config.platformLaunch,
[p.id]: {
emulator: patch.emulator ?? emulator,
core: patch.core ?? core,
},
},
});
return (
<div
key={p.id}
className="grid grid-cols-[1fr_180px_180px] items-center gap-3 py-2 text-sm"
>
<span>{p.name}</span>
<Select
value={emulator}
onChange={(e) => setLaunch({ emulator: e.target.value })}
>
{defs.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
{detectedById.has(d.id) ? " ✓" : ""}
</option>
))}
</Select>
{emulator === "retroarch" && detCores.length ? (
<Select
value={core}
onChange={(e) => setLaunch({ core: e.target.value })}
>
{!detCores.includes(core) && (
<option value={core}>{core || "—"}</option>
)}
{detCores.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
) : emulator === "retroarch" ? (
<Input
value={core}
placeholder="snes9x"
onChange={(e) => setLaunch({ core: e.target.value })}
/>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</>
);
};
// ── Custom emulator editor (design §6 escape hatch) ────────────────────────────────────────────
interface CustomProps {
config: import("../api.js").Config;
setConfig: (c: import("../api.js").Config) => void;
onSave: (c?: import("../api.js").Config) => Promise<void>;
saving: boolean;
reloadDefs: () => Promise<void>;
}
const blankDraft = () => ({
id: "",
name: "",
template: "{exe} {rom}",
supportsArchives: false,
detectLinux: "",
detectWindows: "",
});
const CustomEmulators = ({
config,
setConfig,
onSave,
saving,
reloadDefs,
}: CustomProps) => {
const emulators = config.emulators ?? [];
const [draft, setDraft] = useState(blankDraft());
const add = async () => {
const id = draft.id.trim();
if (!/^[a-z][a-z0-9-]*$/.test(id)) {
toast.error("Emulator id must be kebab-case (e.g. custom-dosbox)");
return;
}
if (emulators.some((e) => e.id === id)) {
toast.error(`An emulator with id "${id}" already exists`);
return;
}
if (!draft.template.includes("{rom}")) {
toast.error("Template must include {rom}");
return;
}
const def: EmulatorDef = {
id,
name: draft.name.trim() || id,
template: draft.template.trim(),
supportsArchives: draft.supportsArchives,
detect: {
linux: draft.detectLinux
.split(",")
.map((s) => s.trim())
.filter(Boolean),
windows: draft.detectWindows
.split(",")
.map((s) => s.trim())
.filter(Boolean),
},
};
const next = { ...config, emulators: [...emulators, def] };
setConfig(next);
await onSave(next);
await reloadDefs();
setDraft(blankDraft());
};
const remove = async (id: string) => {
const next = { ...config, emulators: emulators.filter((e) => e.id !== id) };
setConfig(next);
await onSave(next);
await reloadDefs();
};
return (
<Card>
<CardHeader className="space-y-1.5">
<CardTitle>Custom emulators</CardTitle>
<CardDescription>
Add an emulator Punktfunk doesn't ship a definition for. The template
is trusted, run as the host user — use{" "}
<code className="font-mono">{"{exe}"}</code>,{" "}
<code className="font-mono">{"{rom}"}</code>, optionally{" "}
<code className="font-mono">{"{core}"}</code>. Leave detection blank
and bake the executable into the template if it isn't on PATH.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{emulators.length > 0 && (
<div className="divide-y divide-border rounded-md border border-border">
{emulators.map((e) => (
<div key={e.id} className="flex items-center gap-3 p-2.5 text-sm">
<span className="font-medium">{e.name}</span>
<span className="font-mono text-xs text-muted-foreground">
{e.id}
</span>
<span className="truncate font-mono text-xs text-muted-foreground">
{e.template}
</span>
<Button
variant="ghost"
size="icon"
className="ml-auto shrink-0"
onClick={() => remove(e.id)}
aria-label={`Remove ${e.id}`}
>
<X className="size-4" />
</Button>
</div>
))}
</div>
)}
<div className="grid grid-cols-2 gap-3 rounded-md border border-dashed border-border p-3">
<label className="space-y-1 text-xs text-muted-foreground">
id
<Input
value={draft.id}
placeholder="custom-dosbox"
onChange={(e) => setDraft({ ...draft, id: e.target.value })}
/>
</label>
<label className="space-y-1 text-xs text-muted-foreground">
Name
<Input
value={draft.name}
placeholder="DOSBox"
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</label>
<label className="col-span-2 space-y-1 text-xs text-muted-foreground">
Template
<Input
className="font-mono"
value={draft.template}
placeholder="dosbox {rom} -fullscreen"
onChange={(e) => setDraft({ ...draft, template: e.target.value })}
/>
</label>
<label className="space-y-1 text-xs text-muted-foreground">
Detect · Linux (comma)
<Input
value={draft.detectLinux}
placeholder="dosbox, flatpak:io.dosbox"
onChange={(e) =>
setDraft({ ...draft, detectLinux: e.target.value })
}
/>
</label>
<label className="space-y-1 text-xs text-muted-foreground">
Detect · Windows (comma)
<Input
value={draft.detectWindows}
placeholder="C:\DOSBox\dosbox.exe"
onChange={(e) =>
setDraft({ ...draft, detectWindows: e.target.value })
}
/>
</label>
<label className="col-span-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={draft.supportsArchives}
onChange={(e) =>
setDraft({ ...draft, supportsArchives: e.target.checked })
}
/>
Can load .zip / .7z archives directly
</label>
<div className="col-span-2">
<Button size="sm" disabled={saving} onClick={add}>
<Plus className="size-4" /> Add emulator
</Button>
</div>
</div>
</CardContent>
</Card>
);
};
-189
View File
@@ -1,189 +0,0 @@
import { toast } from "@unom/ui/toast";
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { getConfig, getPreview, type Preview, putConfig } from "../api.js";
import { Badge } from "../components/ui/badge.js";
import { Button } from "../components/ui/button.js";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import { platformOf } from "../hooks.js";
export const Games = () => {
const [preview, setPreview] = useState<Preview>();
const [loading, setLoading] = useState(false);
const [showSkipped, setShowSkipped] = useState(false);
const refresh = useCallback(() => {
setLoading(true);
getPreview()
.then(setPreview)
.catch((e) => toast.error(String(e)))
.finally(() => setLoading(false));
}, []);
useEffect(refresh, [refresh]);
const setExcluded = async (externalId: string, exclude: boolean) => {
const config = await getConfig();
const overrides = { ...config.gameOverrides };
const current = overrides[externalId] ?? {};
if (exclude) overrides[externalId] = { ...current, exclude: true };
else {
const { exclude: _drop, ...rest } = current;
if (Object.keys(rest).length) overrides[externalId] = rest;
else delete overrides[externalId];
}
await putConfig({ ...config, gameOverrides: overrides });
toast.success(exclude ? "Excluded" : "Included");
refresh();
};
if (!preview) {
return (
<Card className="p-6 text-muted-foreground">
{loading ? "Scanning…" : "Loading…"}
</Card>
);
}
const { entries, report } = preview;
const excluded = report.excluded ?? [];
return (
<>
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>Games ({entries.length})</CardTitle>
<CardDescription>
What will be reconciled into the library. Untick a title to
exclude it; covers come from your art provider.
</CardDescription>
</div>
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={refresh}
>
<RefreshCw className="size-4" /> {loading ? "Scanning…" : "Rescan"}
</Button>
</CardHeader>
<CardContent>
{entries.length === 0 && excluded.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No games found. Add ROM roots in Setup, then rescan.
</p>
) : (
<div className="max-h-[520px] space-y-1 overflow-auto">
{entries.map((e) => (
<GameRow
key={e.external_id}
cover={e.art?.portrait ?? undefined}
title={e.title}
platform={platformOf(e.external_id)}
detail={e.launch?.value}
included
onToggle={() => setExcluded(e.external_id, true)}
/>
))}
{excluded.map((x) => (
<GameRow
key={x.external_id}
title={x.title}
platform={platformOf(x.external_id)}
detail="excluded"
included={false}
muted
onToggle={() => setExcluded(x.external_id, false)}
/>
))}
</div>
)}
</CardContent>
</Card>
{report.skipped.length > 0 && (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Skipped ({report.skipped.length})</CardTitle>
<Button
size="sm"
variant="ghost"
onClick={() => setShowSkipped((s) => !s)}
>
{showSkipped ? "Hide" : "Show"}
</Button>
</CardHeader>
{showSkipped && (
<CardContent className="max-h-[320px] space-y-1 overflow-auto text-sm">
{report.skipped.map((s) => (
<div
key={s.external_id}
className="flex justify-between gap-4 border-b border-border py-1.5"
>
<span>{s.title}</span>
<span className="text-muted-foreground">{s.reason}</span>
</div>
))}
</CardContent>
)}
</Card>
)}
</>
);
};
const GameRow = ({
cover,
title,
platform,
detail,
included,
muted,
onToggle,
}: {
cover?: string;
title: string;
platform: string;
detail?: string;
included: boolean;
muted?: boolean;
onToggle: () => void;
}) => (
<div
className={`flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-secondary/40 ${muted ? "opacity-55" : ""}`}
>
{cover ? (
<img
src={cover}
alt={`${title} cover`}
loading="lazy"
className="h-14 w-10 shrink-0 rounded object-cover ring-1 ring-border"
/>
) : (
<span className="h-14 w-10 shrink-0 rounded bg-secondary ring-1 ring-border" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title}</span>
<span
className="block truncate font-mono text-xs text-muted-foreground"
title={detail}
>
{detail}
</span>
</span>
<Badge variant="outline">{platform}</Badge>
<input
type="checkbox"
className="ml-1"
checked={included}
onChange={onToggle}
aria-label={included ? "Exclude" : "Include"}
/>
</div>
);
-115
View File
@@ -1,115 +0,0 @@
import { toast } from "@unom/ui/toast";
import { Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { getPlatforms, type Platform, type RomRoot } from "../api.js";
import { Button } from "../components/ui/button.js";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import { Input, Select } from "../components/ui/input.js";
import { useConfig } from "../hooks.js";
export const Setup = () => {
const { config, setConfig, save, saving } = useConfig();
const [platforms, setPlatforms] = useState<Platform[]>([]);
useEffect(() => {
getPlatforms()
.then(setPlatforms)
.catch(() => {});
}, []);
if (!config)
return <Card className="p-6 text-muted-foreground">Loading</Card>;
const roots = config.roots;
const setRoots = (next: RomRoot[]) => setConfig({ ...config, roots: next });
const update = (i: number, patch: Partial<RomRoot>) =>
setRoots(roots.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const onSave = async () => {
if (await save(config)) toast.success("Saved — syncing library");
else toast.error("Save failed");
};
return (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>ROM roots</CardTitle>
<CardDescription>
Point each folder at the console platform its files belong to.
Shared extensions (.iso, .bin) are resolved by the folder's
platform.
</CardDescription>
</div>
<Button size="sm" disabled={saving} onClick={onSave}>
{saving ? "Saving…" : "Save & sync"}
</Button>
</CardHeader>
<CardContent className="space-y-3">
{roots.length === 0 && (
<p className="text-sm text-muted-foreground">
No roots yet. Add a folder of ROMs to get started.
</p>
)}
{roots.map((root, i) => (
<div
key={i}
className="grid grid-cols-[1fr_180px_1fr_auto] items-center gap-2"
>
<Input
value={root.dir}
placeholder="/home/you/roms/snes"
onChange={(e) => update(i, { dir: e.target.value })}
/>
<Select
value={root.platform}
onChange={(e) => update(i, { platform: e.target.value })}
>
<option value=""> platform </option>
{platforms.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
<Input
value={(root.excludes ?? []).join(", ")}
placeholder="*.sav, bios/**"
onChange={(e) =>
update(i, {
excludes: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => setRoots(roots.filter((_, j) => j !== i))}
aria-label="Remove root"
>
<X className="size-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setRoots([...roots, { dir: "", platform: "" }])}
>
<Plus className="size-4" /> Add root
</Button>
</CardContent>
</Card>
);
};
-279
View File
@@ -1,279 +0,0 @@
import { toast } from "@unom/ui/toast";
import { RefreshCw } from "lucide-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { type Config, runSync, useStatusStream } from "../api.js";
import { Badge } from "../components/ui/badge.js";
import { Button } from "../components/ui/button.js";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card.js";
import { Input, Select } from "../components/ui/input.js";
import { useConfig } from "../hooks.js";
const Stat = ({ n, label }: { n: ReactNode; label: string }) => (
<div className="rounded-card bg-secondary/40 px-4 py-3">
<div className="text-2xl font-semibold">{n}</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
);
export const Sync = () => {
const status = useStatusStream();
const { config, setConfig, save, saving } = useConfig();
const [syncing, setSyncing] = useState(false);
const report = status?.lastReport;
return (
<>
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>Status</CardTitle>
<CardDescription>
{status?.lastSync
? `Last sync ${new Date(status.lastSync.at).toLocaleString()} · ${status.os}`
: "Not synced yet."}
</CardDescription>
</div>
<Button
size="sm"
disabled={syncing || status?.syncing}
onClick={async () => {
setSyncing(true);
try {
await runSync();
toast.success("Synced");
} catch (e) {
toast.error(String(e));
} finally {
setSyncing(false);
}
}}
>
<RefreshCw className="size-4" />
{syncing || status?.syncing ? "Syncing…" : "Sync now"}
</Button>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat n={status?.rootsConfigured ?? "—"} label="ROM roots" />
<Stat n={status?.lastSync?.count ?? "—"} label="in library" />
<Stat n={report?.skipped.length ?? "—"} label="skipped" />
<Stat
n={
status?.artProvider ? (
<Badge variant="brand">{status.artProvider}</Badge>
) : (
<span className="text-base text-muted-foreground">off</span>
)
}
label="art provider"
/>
</div>
{report?.overWarn && (
<p className="text-sm text-amber-400">
Large library ({report.included} entries) reconcile is one
full-body PUT; watch host memory.
</p>
)}
{report && report.warnings.length > 0 && (
<details>
<summary className="cursor-pointer text-sm text-muted-foreground">
{report.warnings.length} warning(s)
</summary>
<ul className="mt-1 list-disc pl-5 text-xs text-muted-foreground">
{report.warnings.slice(0, 20).map((w) => (
<li key={w}>{w}</li>
))}
</ul>
</details>
)}
</CardContent>
</Card>
{config && (
<>
<ArtCard
config={config}
setConfig={setConfig}
save={save}
saving={saving}
/>
<SyncOptionsCard
config={config}
setConfig={setConfig}
save={save}
saving={saving}
/>
{status && (
<Card>
<CardHeader className="space-y-1.5">
<CardTitle>Files</CardTitle>
<CardDescription className="font-mono text-xs">
{status.paths.config}
<br />
{status.paths.cache}
</CardDescription>
</CardHeader>
</Card>
)}
</>
)}
</>
);
};
interface CardProps {
config: Config;
setConfig: (c: Config) => void;
save: (c: Config) => Promise<boolean>;
saving: boolean;
}
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<label className="space-y-1 text-xs text-muted-foreground">
<span>{label}</span>
{children}
</label>
);
const ArtCard = ({ config, setConfig, save, saving }: CardProps) => {
const art = config.art;
const set = (patch: Partial<Config["art"]>) =>
setConfig({ ...config, art: { ...art, ...patch } });
return (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>Box art</CardTitle>
<CardDescription>
SteamGridDB (like Steam ROM Manager) gives portrait + hero + logo +
header with a free API key. Without a key, the keyless
libretro-thumbnails fallback provides box art only.
</CardDescription>
</div>
<Button
size="sm"
disabled={saving}
onClick={async () => (await save(config)) && toast.success("Saved")}
>
{saving ? "Saving…" : "Save"}
</Button>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={art.enabled}
onChange={(e) => set({ enabled: e.target.checked })}
/>
Enabled
</label>
<Field label="Provider">
<Select
className="w-56"
value={art.provider}
onChange={(e) =>
set({ provider: e.target.value as Config["art"]["provider"] })
}
>
<option value="auto">auto (SteamGridDB if key set)</option>
<option value="steamgriddb">SteamGridDB</option>
<option value="libretro">libretro-thumbnails</option>
</Select>
</Field>
<Field label="SteamGridDB API key">
<Input
className="w-80"
type="password"
value={art.steamGridDbKey ?? ""}
placeholder="steamgriddb.com profile preferences"
onChange={(e) =>
set({ steamGridDbKey: e.target.value || undefined })
}
/>
</Field>
</CardContent>
</Card>
);
};
const SyncOptionsCard = ({ config, setConfig, save, saving }: CardProps) => {
const sync = config.sync;
const set = (patch: Partial<Config["sync"]>) =>
setConfig({ ...config, sync: { ...sync, ...patch } });
return (
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<CardTitle>Sync options</CardTitle>
<Button
size="sm"
disabled={saving}
onClick={async () =>
(await save(config)) && toast.success("Saved — syncing")
}
>
{saving ? "Saving…" : "Save & sync"}
</Button>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-4">
<Field label="Poll (minutes)">
<Input
className="w-24"
type="number"
min={1}
value={sync.pollMinutes}
onChange={(e) => set({ pollMinutes: Number(e.target.value) || 15 })}
/>
</Field>
<Field label="Max entries">
<Input
className="w-28"
type="number"
min={1}
value={sync.maxEntries}
onChange={(e) =>
set({ maxEntries: Number(e.target.value) || 5000 })
}
/>
</Field>
<label className="flex items-center gap-2 pb-1.5 text-sm">
<input
type="checkbox"
checked={sync.watch}
onChange={(e) => set({ watch: e.target.checked })}
/>
Watch filesystem
</label>
<label className="flex items-center gap-2 pb-1.5 text-sm">
<input
type="checkbox"
checked={sync.closeOnEnd}
onChange={(e) => set({ closeOnEnd: e.target.checked })}
/>
Close emulator on stream end
</label>
<Field label="Region-dedupe platforms (comma ids)">
<Input
className="w-64"
value={sync.dedupeRegions.join(", ")}
placeholder="snes, genesis"
onChange={(e) =>
set({
dedupeRegions: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
/>
</Field>
</CardContent>
</Card>
);
};
-187
View File
@@ -1,187 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./timing-functions.css";
@custom-variant dark (&:is(.dark *));
/* Pull @unom/ui's compiled component classes (bg-neutral, rounded-card, p-padding-card, ring-accent,
h-input-height, material…) into the Tailwind 4 scan so their utilities aren't purged. */
@source "../node_modules/@unom/ui/dist/**/*.{js,mjs}";
/* ── punktfunk brand · violet product chrome (copied from the console so the plugin reads as family) ──
LIGHT (:root) + DARK (.dark) on one violet identity. The plugin pins <html class="dark"> since it is
embedded on the console's dark canvas. The token set feeds BOTH @unom/ui's semantic contract
(--brand/--primary/--accent/--neutral/--main…) and the shadcn-style tokens. */
:root {
--radius: 0.625rem;
--pf-brand: #6c5bf3;
--pf-brand-light: #a79ff8;
--pf-highlight: #d2c9fb;
--background: #ffffff;
--foreground: #1b1430;
--card: #f6f2ff;
--card-foreground: #1b1430;
--popover: #ffffff;
--popover-foreground: #1b1430;
--muted: #f1ecfd;
--muted-foreground: #6f6a86;
--secondary: #ece6fb;
--secondary-foreground: #1b1430;
--accent: var(--pf-brand);
--accent-foreground: #ffffff;
--border: #e4dcf7;
--input: #e4dcf7;
--ring: var(--pf-brand);
--primary: var(--pf-brand);
--primary-foreground: #ffffff;
--success: oklch(0.6 0.14 160);
--destructive: oklch(0.55 0.22 18);
--destructive-foreground: #ffffff;
--main: var(--foreground);
--brand: var(--pf-brand);
--brand-light: var(--pf-brand-light);
--highlight: var(--pf-highlight);
--neutral: var(--card);
--neutral-accent: var(--secondary);
--neutral-highlight: var(--border);
--error: var(--destructive);
--font-display: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
--radius-card-min: var(--radius);
}
.dark {
--background: #141019;
--foreground: oklch(0.985 0 0);
--card: #1c1530;
--card-foreground: oklch(0.985 0 0);
--popover: #1c1530;
--popover-foreground: oklch(0.985 0 0);
--muted: #1f1830;
--muted-foreground: oklch(0.728 0.03 286);
--secondary: #241c3d;
--secondary-foreground: oklch(0.985 0 0);
--border: #2a2148;
--input: #2a2148;
--ring: var(--pf-brand-light);
--primary: var(--pf-brand-light);
--primary-foreground: #141019;
--success: oklch(0.7 0.15 160);
--destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-button: 9999px;
--radius-card: calc(var(--radius) * 2);
--radius-main: calc(var(--radius) * 2);
--spacing-input-height: 3rem;
--spacing-padding-card: 1.25rem;
--spacing-card: 1.5rem;
--spacing-main: 15px;
--font-sans: var(--font-sans);
--font-display: var(--font-display);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-main: var(--main);
--color-brand: var(--brand);
--color-brand-light: var(--brand-light);
--color-neutral: var(--neutral);
--color-neutral-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-highlight);
--color-highlight: var(--highlight);
--color-error: var(--error);
}
@theme {
--animate-accordion-down: accordion-down 0.4s var(--ease-out-quart);
--animate-accordion-up: accordion-up 0.4s var(--ease-out-quart);
--animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart);
--animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes collapsible-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
}
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
-25
View File
@@ -1,25 +0,0 @@
/* Penner easing tokens — shared with the punktfunk console + @unom/ui.
@unom/ui's accordion/collapsible/material animations resolve these by name. */
@theme {
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
}
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"noEmit": true,
"types": []
},
"include": ["src", "vite.config.ts"]
}
-16
View File
@@ -1,16 +0,0 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// `base: "./"` (relative asset URLs) + hash routing is the plugin-ui-surface §6 contract: the SPA is
// mounted under `/plugin-ui/rom-manager/` behind the console proxy. Tailwind v4 + @unom/ui give it the
// same look as the console. Built into `../dist/ui`, which `servePluginUi`'s staticDir points at.
export default defineConfig({
base: "./",
plugins: [react(), tailwindcss()],
build: {
outDir: "../dist/ui",
emptyOutDir: true,
},
server: { port: 5599 },
});