feat(ui): three-page console SPA on @unom/ui + the kit's react helpers

Overview (exporter health, Playnite version, counts, sync, live SSE activity,
and a first-run panel that names the exact ingest path the .pext writes to),
Library (covers + per-game include toggles + why-not-synced), Settings
(filters, art delivery, sync cadence, paths).

Data layer is AtomHttpApi atoms derived from the shared contract; the whole
thing runs with zero host against an in-browser mock transport with four
scenarios, and Storybook renders the real pages on the same fixtures. CI now
asserts the production bundle carries no fixture strings.

Also: CI rebuilt for the workspace layout (one root install, per-package
typecheck, publish from plugin/ on a v* tag) with the exporter job and
`publish: needs [build, exporter]` unchanged; README rewritten around the three
workspaces, with the ingest inbox explained as the load-bearing mechanism it is.
This commit is contained in:
2026-07-20 21:05:04 +02:00
parent b81e03685a
commit 6f3a71aa60
33 changed files with 2328 additions and 46 deletions
+32 -13
View File
@@ -1,10 +1,11 @@
# CI for @punktfunk/plugin-playnite (Gitea Actions).
# build — the TS plugin + SPA: lint, typecheck, test, bundle (mirrors the rom-manager plugin CI).
# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on Linux (no
# Windows/MSBuild needed) and uploaded as a workflow artifact.
# publish — npm publish to the Gitea registry on a `v*` tag.
# Installs resolve @punktfunk/* from the Gitea registry via the bunfig scope map + REGISTRY_TOKEN;
# everything else (effect, react, tailwind…) comes from npm.
# CI for the playnite workspace (Gitea Actions).
# build — the three bun workspaces (contract + plugin + ui): one root install, lint,
# per-package typecheck, domain tests, bundle + SPA. Mirrors rom-manager's.
# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on
# Linux (no Windows/MSBuild needed) and uploaded as a workflow artifact.
# publish — npm publish to the Gitea registry on a `v*` tag, from plugin/.
# @punktfunk/* and @unom/* resolve from the Gitea registry via the root bunfig scope maps
# + REGISTRY_TOKEN; everything else (effect, react, tailwind…) comes from npm.
name: CI
on:
@@ -31,22 +32,38 @@ jobs:
run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install
- name: Install (workspace)
run: bun install --frozen-lockfile
- name: Lint & format
run: bunx biome check
- name: Typecheck (backend)
- name: Typecheck (contract)
working-directory: contract
run: bunx tsc --noEmit
- name: Test (engine)
- name: Typecheck (plugin)
working-directory: plugin
run: bunx tsc --noEmit
- name: Test (domain)
working-directory: plugin
run: bun test
- name: Build backend + SPA
- name: Build backend bundle + SPA
working-directory: plugin
run: bun run build:all
- name: Typecheck (UI)
working-directory: ui
run: bunx tsc --noEmit
- name: Sanity — plugin default export is a valid PluginDef
working-directory: plugin
run: |
bun -e 'import p from "./dist/index.js"; const d = p.default ?? p; if (d?.name !== "playnite" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)'
bun -e 'import plugin from "./dist/index.js"; if (plugin?.name !== "playnite" || typeof plugin?.main !== "function") { console.error("bad default export", plugin); process.exit(1); } console.log("ok:", plugin.name)'
- name: Sanity — no fixtures in the production SPA bundle
working-directory: plugin
run: |
for needle in placehold.co mockHttpClientLayer tickerFrames; do
if grep -rq "$needle" dist/ui/assets/*.js; then
echo "fixture string '$needle' leaked into the production bundle"; exit 1
fi
done
echo "ok: production bundle is fixture-free"
exporter:
runs-on: ubuntu-24.04
@@ -90,12 +107,14 @@ jobs:
run: |
test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; }
printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc"
- name: Install
- name: Install (workspace)
run: bun install --frozen-lockfile
- name: Tag matches package version
working-directory: plugin
run: |
TAG="${GITHUB_REF_NAME#v}"
PKG="$(node -p "require('./package.json').version")"
test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; }
- name: Publish (prepublishOnly builds backend + SPA)
working-directory: plugin
run: bun publish
+82 -28
View File
@@ -3,24 +3,25 @@
Sync your **[Playnite](https://playnite.link)** library into the Punktfunk host game library. Every
store and emulator Playnite manages — Steam, GOG, Epic, Xbox, itch, RetroArch, standalone emulators,
manually-added games — shows up in Punktfunk's grid on every client, and launching a title hands it
straight back to Playnite, which performs the real launch.
straight back to Playnite, which performs the real launch. It ships a web UI that lives **inside the
Punktfunk console** (no second password, no second port).
It has two halves, both on the **same Windows box** as the Punktfunk host:
```
┌─────────────────────────────┐ ┌──────────────────────────────────────────┐
┌─────────────────────────────┐ ┌────────────────────────────────────────────
│ Playnite │ │ Punktfunk host (this plugin, in the │
│ └ Punktfunk Sync extension │ JSON │ scripting runner) │
│ writes │ ─────▶ │ reads punktfunk-library.json, embeds art,
│ punktfunk-library.json │ │ PUT /library/provider/playnite
└─────────────────────────────┘ └──────────────────────────────────────────┘
│ writes │ ─────▶ │ reads punktfunk-library.json, maps it to
│ punktfunk-library.json │ │ entries, PUT /library/provider/playnite │
└─────────────────────────────┘ └────────────────────────────────────────────
```
- **The Playnite exporter** (`exporter/`, C#) is a tiny [GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html)
that writes your library to a JSON file whenever it changes. Playnite locks its library database
while running, so reading it from *inside* Playnite is the only robust way — this is that adapter,
and nothing more.
- **This plugin** (TypeScript, on [`@punktfunk/host`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk))
- **This plugin** (TypeScript, on [`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit))
watches that file and reconciles it into the host library as the `playnite` provider, with a
console-hosted web UI. Zero-config auth via the runner.
@@ -37,8 +38,8 @@ bun add @punktfunk/plugin-playnite
Enable-ScheduledTask PunktfunkScripting # enable the scripting runner if it isn't already
```
Open the Punktfunk console — a **Playnite** entry appears in the nav. (Headless box without the
console? The engine is fully functional from a `config.json` alone — see [Headless](#headless--cli).)
Open the Punktfunk console — a **Playnite** entry appears in the nav. It will say it's waiting for
Playnite until you do step 2.
### 2 · The Punktfunk Sync extension (in Playnite)
@@ -46,13 +47,23 @@ Download **`punktfunk-sync.pext`** from the [latest CI build](https://git.unom.i
(the `exporter` job's artifact) and **double-click it** — Playnite installs it like any add-on.
Restart Playnite once.
That's it. The console's **Playnite** page shows "Exporter connected" and your games sync within
seconds of any library change.
That's it. The console's **Playnite** page flips to "Exporter connected" and your games sync within
seconds of any library change. No configuration needed.
> The console page can also **deploy the exporter for you** if you'd rather not handle the `.pext`
> it drops the extension into `%APPDATA%\Playnite\Extensions\` and asks you to restart Playnite.
> (Best-effort: it needs read/write access to the interactive user's `%APPDATA%`; the `.pext` is the
> reliable fallback.)
> **Headless box** without the console? The engine is fully functional from a `config.json` alone
> see [Headless / CLI](#headless--cli).
## The ingest inbox
Punktfunk's plugin runner is **de-privileged** on Windows (it runs as `NT AUTHORITY\LocalService`),
so it cannot read the interactive user's `%APPDATA%\Playnite\ExtensionsData\…` — which is where a
Playnite extension normally writes. So the exporter drops a second copy into the host's **ingest
inbox**, `<config_dir>\ingest\playnite\punktfunk-library.json`, which `punktfunk-host plugins enable`
makes user-writable. The plugin reads that inbox **first**, and falls back to scanning Playnite's own
`ExtensionsData` (which works for a same-user/SYSTEM runner, or on Linux under Wine).
This is why the plugin works at all under the security tiers; if the console says it's reading the
ingest inbox, that's the healthy path.
## Launching
@@ -64,17 +75,21 @@ maintain.
## Box art
Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the
reconcile (which doesn't scale — a large library blows past the host's request-body limit), the plugin
sends the local file **path** and the host serves the cover through its art proxy
reconcile (which doesn't scale — a large library blows past the host's request-body limit), the
default is to send the local file **path** and let the host serve the cover through its art proxy
(`/library/art/…`, exactly like Steam art). The payload stays tiny at any library size.
| `art.mode` | Behaviour |
|--------------|-----------------------------------------------------------------|
| `host` | **Default.** Send the cover's local path; the host serves the bytes. Scales to thousands of titles. Requires a host with the provider-art proxy. |
| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only — a big one exceeds the host's body limit. |
| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only. |
| `off` | Sync titles with no art (lightest payload). |
`art.includeBackground` additionally serves the Playnite background as `hero` art (off by default).
`art.includeBackground` additionally sends the Playnite background as `hero` art (off by default).
The plugin's own SPA can't load a `C:\…\cover.jpg` either, so it fetches covers through
`GET /api/art?path=…` — which serves **only** paths the currently-ingested export references. It is
an allow-list lookup, not an arbitrary file read.
## Filters
@@ -84,13 +99,15 @@ sends the local file **path** and the host serves the cover through its art prox
| `filter.includeHidden` | `false` | Include games flagged Hidden in Playnite. |
| `filter.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. |
| `filter.excludeSources` | `[]` | Drop these sources. |
| `gameOverrides` | `{}` | Per-game `{ "<guid>": { "exclude": true } }` — the Library page's toggle. |
Edit them in the console's **Playnite** page, or in `config.json` directly.
Edit them in the console's **Playnite** page, or in `config.json` directly. Only the keys you author
are stored: defaults live in the schema (`contract/src/config.ts`) and are never baked into your file.
## Headless / CLI
The plugin owns `<config_dir>/playnite/config.json` (see [`config.example.json`](./config.example.json)).
It's fully functional from that file alone. A debug CLI mirrors the engine:
The plugin owns `<config_dir>/plugin-state/playnite/config.json` and is fully functional from that
file alone. A debug CLI mirrors the engine:
```sh
bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths)
@@ -99,23 +116,45 @@ bunx punktfunk-plugin-playnite sync # reconcile into the live library
bunx punktfunk-plugin-playnite uninstall # remove every entry this plugin owns
```
For a host without the console, set `ui.standalone: true` and a password
(`bunx punktfunk-plugin-playnite set-password <pw>`); the UI then serves on `ui.port` (default 47994).
Provider entries are deliberately **left** in the library when the plugin stops, so your games survive
restarts and reboots; `uninstall` is the explicit way to clear them.
## Requirements
- A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`).
- **Playnite** on the same box, with the Punktfunk Sync extension installed.
- The mgmt-API provider reconcile is loopback + token; the runner supplies both automatically.
- `@punktfunk/host` **≥ 0.1.2** + `@punktfunk/plugin-kit` **≥ 0.1.4** (shared with the runner, not
bundled).
## Build from source
## Development — three bun workspaces
This plugin is the second consumer of
[`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit), built on the
same blueprint as [`rom-manager`](https://git.unom.io/unom/punktfunk-plugin-rom-manager) — copy either
repo's structure to start a new plugin.
| Workspace | What it is |
|---|---|
| `contract/` | **The single source of truth**: the cross-process export Schema (the C# exporter's other half, with the schema-version guard as a decode check), the config schema (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` is the pure, injectable, unit-tested core (locate/read, art, reconcile); `src/services` wires it into the kit (ConfigService, CacheStore, SyncEngine, ProviderClient, HttpApi handlers + SSE + the art proxy); `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). Three pages — **Overview**, **Library**, **Settings**. Builds into `plugin/dist/ui`. |
```sh
bun install
bun run build:all # backend (dist/) + SPA (dist/ui/)
bun test
bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes)
bun test # domain tests (plugin/)
bun run typecheck # contract → plugin → ui
bun run build # backend bundle + SPA → plugin/dist
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 6014)
cd ui && bun run screenshots # headless captures of every page story
cd plugin && bun run dev # auth-free dev API on :5886 …
cd ui && bun run dev:live # … and the UI proxied against it
```
`@unom/*` + `@effect/atom-react` are build-time only — the SPA ships as static assets, so the
published plugin carries no UI runtime dependencies.
The exporter (needs the .NET SDK; builds net462 on any OS via reference assemblies):
```sh
@@ -124,6 +163,21 @@ dotnet build exporter/PunktfunkSync.csproj -c Release
( cd exporter/bin/Release && zip -j ../../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll )
```
**The export shape is a contract with the C# side.** `contract/src/export.ts` and
`exporter/Model.cs` describe the same document; change one and you must change the other, and bump
`SCHEMA` if the change is breaking. The reader refuses a document stamped with a higher `SCHEMA` than
it understands rather than mis-reading it.
## Security
- The **ingest inbox is writable by any local user** — treat what it contains as untrusted. Game ids
are validated as .NET Guids before they reach a launch command; the `playnite://` URI is the only
thing ever interpolated, and Playnite performs the launch.
- `config.json` lives in the hardened `<config_dir>/plugin-state`; the plugin refuses a
group/world-writable one.
- `GET /api/art` serves only paths referenced by the current export, and only image extensions.
- No outbound network at all. No telemetry.
## License
MIT OR Apache-2.0.
+8 -1
View File
@@ -7,7 +7,14 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!dist", "!ui/dist", "!**/node_modules", "!exporter"]
"includes": [
"**",
"!**/dist",
"!**/node_modules",
"!exporter",
"!ui/storybook-static",
"!ui/screenshots"
]
},
"formatter": {
"enabled": true,
+14 -1
View File
@@ -62,6 +62,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"playwright": "^1.61.1",
"storybook": "^10.4.6",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
@@ -1109,7 +1110,7 @@
"framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -1413,6 +1414,10 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="],
"playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="],
"pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="],
"postcss": ["postcss@8.5.20", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug=="],
@@ -1713,6 +1718,8 @@
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
@@ -1741,8 +1748,14 @@
"rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="],
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"unplugin/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+1 -1
View File
@@ -17,7 +17,7 @@ On startup, on every `OnLibraryUpdated`, and (debounced) on any per-game add/upd
%APPDATA%\Playnite\ExtensionsData\<plugin-guid>\punktfunk-library.json
```
Shape (kept in lockstep with `../src/export-schema.ts`):
Shape (kept in lockstep with `../contract/src/export.ts`):
```jsonc
{
+12
View File
@@ -0,0 +1,12 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.tsx"],
addons: [],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;
+65
View File
@@ -0,0 +1,65 @@
// Storybook renders the REAL pages on the mock transport: the static import below
// registers the fixture HttpClient layer + status ticker (Storybook bundles are never
// shipped, so fixtures in them are fine), and the decorator gives every story a fresh
// atom registry seeded with mock mode + the story's scenario.
import "../src/styles.css";
import "../src/mocks/http";
import { RegistryProvider } from "@effect/atom-react";
import { definePreview } from "@storybook/react-vite";
import { useEffect } from "react";
import {
apiModeAtom,
type Scenario,
scenarioAtom,
} from "../src/mocks/scenario";
export default definePreview({
addons: [],
// The console pins dark; default the canvas to dark with a toolbar light switch.
initialGlobals: { theme: "dark" },
globalTypes: {
theme: {
description: "Light/dark color scheme",
toolbar: {
title: "Theme",
icon: "circlehollow",
items: [
{ value: "dark", icon: "moon", title: "Dark" },
{ value: "light", icon: "sun", title: "Light" },
],
dynamicTitle: true,
},
},
},
decorators: [
(Story, context) => {
const dark = (context.globals.theme as string) !== "light";
const scenario =
(context.parameters.scenario as Scenario | undefined) ?? "healthy";
const fullscreen = context.parameters.layout === "fullscreen";
// Mirror `.dark` onto <html> so portal-mounted content (selects, dialogs,
// toasts) picks up the palette — the theme keys everything off `html.dark`.
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
}, [dark]);
return (
<RegistryProvider
key={`${scenario}-${dark}`}
initialValues={[
[apiModeAtom, "mock"],
[scenarioAtom, scenario],
]}
>
<div
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
>
<Story />
</div>
</RegistryProvider>
);
},
],
parameters: {
layout: "padded",
},
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<!-- The console pins dark; the standalone dev tab matches it. Removing `dark` yields light. -->
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playnite — Punktfunk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4 -2
View File
@@ -2,14 +2,15 @@
"name": "punktfunk-plugin-playnite-ui",
"private": true,
"type": "module",
"description": "The Playnite plugin SPA Punktfunk console polish (@unom/ui + @unom/app-ui), an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop. Builds into plugin/dist/ui.",
"description": "The Playnite plugin SPA \u2014 Punktfunk console polish (@unom/ui + @unom/app-ui), an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop. Builds into plugin/dist/ui.",
"scripts": {
"dev": "vite",
"dev:live": "VITE_API_MODE=live vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6014",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"screenshots": "node tools/screenshots.mjs"
},
"dependencies": {
"@effect/atom-react": "4.0.0-beta.99",
@@ -32,6 +33,7 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"playwright": "^1.61.1",
"storybook": "^10.4.6",
"tailwindcss": "^4.3.2",
"tw-animate-css": "^1.4.0",
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { App } from "./App";
const meta = {
title: "Shell/App",
component: App,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof App>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = {
parameters: { layout: "fullscreen", scenario: "firstRun" },
};
+52
View File
@@ -0,0 +1,52 @@
// The app frame: kit router (flat routes, console deep-link bridge) inside the shared
// PluginShell chrome. Pages own their data; the shell owns navigation + the toaster.
import { createPluginRouter, useIsEmbedded } from "@punktfunk/plugin-kit/react";
import {
PluginShell,
type PluginShellNavItem,
} from "@unom/app-ui/plugin-shell";
import { LayoutDashboard, LibraryBig, Settings2 } from "lucide-react";
import type { FC } from "react";
import { LibraryPage } from "@/pages/library";
import { OverviewPage } from "@/pages/overview";
import { SettingsPage } from "@/pages/settings";
import { type PageProps, ROUTES, type Route } from "@/routes";
const { usePluginRoute } = createPluginRouter(ROUTES, "overview");
const NAV: ReadonlyArray<PluginShellNavItem> = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
{ id: "library", label: "Library", icon: LibraryBig },
{ id: "settings", label: "Settings", icon: Settings2 },
];
const PAGES: Record<Route, FC<PageProps>> = {
overview: OverviewPage,
library: LibraryPage,
settings: SettingsPage,
};
/** Only shown in a standalone tab — embedded in the console, the console is the chrome. */
const StandaloneHeader = () => (
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<LibraryBig className="size-4 text-primary" />
Playnite
</div>
);
export const App = () => {
const { route, navigate } = usePluginRoute();
const embedded = useIsEmbedded();
const Page = PAGES[route];
return (
<PluginShell
nav={NAV}
active={route}
onNavigate={(id) => navigate(id as Route)}
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
>
<Page navigate={navigate} />
</PluginShell>
);
};
+53
View File
@@ -0,0 +1,53 @@
// The one loading/error convention for every subscription on every page: the kit's
// ResultGate wired to this plugin's skeleton + error visuals. Pages pass their own
// colocated PageSkeleton; failures render a retryable error EmptyState — except the
// "exporter hasn't written anything yet" case, which pages can answer with onboarding
// via the `unavailable` slot.
import { ResultGate } from "@punktfunk/plugin-kit/react";
import { Button } from "@unom/ui/button";
import { EmptyState } from "@unom/ui/empty-state";
import type { AsyncResult } from "effect/unstable/reactivity";
import { TriangleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { errorText, isExportUnavailable } from "@/lib/errors";
export type GateProps<A, E> = {
readonly result: AsyncResult.AsyncResult<A, E>;
/** The page's colocated skeleton, shown while the first value loads. */
readonly skeleton: ReactNode;
/** Usually `useAtomRefresh(theAtom)`. */
readonly retry?: () => void;
/** Rendered instead of the error box when the exporter has produced nothing yet. */
readonly unavailable?: ReactNode;
readonly children: (value: A) => ReactNode;
};
export const Gate = <A, E>(props: GateProps<A, E>): ReactNode => (
<ResultGate
result={props.result}
waiting={props.skeleton}
retry={props.retry}
failure={(error, retry) =>
props.unavailable !== undefined && isExportUnavailable(error) ? (
props.unavailable
) : (
<EmptyState
variant="error"
icon={TriangleAlert}
title="Something went wrong"
description={errorText(error)}
action={
retry === undefined ? undefined : (
<Button variant="outline" onClick={retry}>
Try again
</Button>
)
}
/>
)
}
>
{props.children}
</ResultGate>
);
+47
View File
@@ -0,0 +1,47 @@
// The sticky save bar every draft-editing page shares: slides up when the draft goes
// dirty, pins to the viewport bottom, offers Save + Discard.
import { AnimatedButton, Button } from "@unom/ui/button";
import { Spinner } from "@unom/ui/spinner";
import { AnimatePresence, motion } from "motion/react";
export type SaveBarProps = {
readonly dirty: boolean;
readonly saving: boolean;
readonly onSave: () => void;
readonly onDiscard: () => void;
};
export const SaveBar = ({ dirty, saving, onSave, onDiscard }: SaveBarProps) => (
<AnimatePresence>
{dirty ? (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.3, ease: [0.165, 0.84, 0.44, 1] }}
className="sticky bottom-4 z-20 mt-6"
>
<div className="flex items-center justify-between gap-4 rounded-card border border-border bg-popover/90 px-5 py-3 shadow-lg backdrop-blur">
<span className="text-sm text-muted-foreground">Unsaved changes</span>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onDiscard} disabled={saving}>
Discard
</Button>
{/* variants={{}} neutralizes the button's Section-stagger entry variants —
inside this object-form motion wrapper the `enter` label never
propagates, which would leave the button stuck at opacity 0. */}
<AnimatedButton
onClick={onSave}
disabled={saving}
variants={{}}
whileTap={{ scale: 0.97 }}
>
{saving ? <Spinner className="size-4" /> : null}
Save changes
</AnimatedButton>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
+150
View File
@@ -0,0 +1,150 @@
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
// mutation constants below document which keys each mutation must pass at call time
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@playnite/contract";
import { sseAtom } from "@punktfunk/plugin-kit/react";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { installedMockStatusStream } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
import { Api, prefix } from "./client";
// ── Queries ────────────────────────────────────────────────────────────────────
export const statusAtom = Api.query("status", "get", {
reactivityKeys: ["status"],
});
export const configAtom = Api.query("config", "get", {
reactivityKeys: ["config"],
});
export const previewAtom = Api.query("library", "preview", {
reactivityKeys: ["preview"],
});
// ── Mutations + the keys they invalidate ───────────────────────────────────────
export const saveConfig = Api.mutation("config", "put");
/** A config save changes the desired state everywhere derived from it. */
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
export const runSync = Api.mutation("sync", "run");
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
// ── Live status (SSE with polling fallback) ────────────────────────────────────
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
const liveStatusStream = sseAtom({
url: `${prefix}${EVENTS_PATH}`,
event: EVENTS_EVENT,
schema: EngineStatus,
});
/**
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
* mock mode. `undefined` until the first frame arrives.
*/
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
(get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockStatusStream();
return mock === undefined ? undefined : get(mock);
}
return get(liveStatusStream);
},
);
/**
* What pages actually render: the freshest engine status — the latest SSE frame once one
* has arrived, the plain GET /api/status result before that (and as error/skeleton
* source, since the SSE stream never fails).
*/
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
Atom.make((get) => {
const frame = get(statusStreamAtom);
if (frame !== undefined) {
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
frame,
);
}
return get(statusAtom);
});
// ── Status transitions → activity feed ─────────────────────────────────────────
export type StatusEvent = {
readonly id: string;
readonly at: number;
readonly text: string;
readonly level: "info" | "warn" | "error";
};
const EVENT_LIMIT = 50;
/**
* Folds the status frame stream into a bounded activity list for the Overview feed —
* client-side only, no history endpoint: connect, sync start/finish (with the report's
* count), report warnings, export read errors, and a fresh export appearing.
*/
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
Atom.make((get) => {
let prev: EngineStatus | undefined;
let items: ReadonlyArray<StatusEvent> = [];
let seq = 0;
get.subscribe(
statusStreamAtom,
(frame) => {
if (frame === undefined) return;
const next: Array<StatusEvent> = [];
const push = (text: string, level: StatusEvent["level"] = "info") =>
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
if (prev === undefined) {
push(
frame.location.file === null
? "Engine connected — waiting for the Playnite exporter"
: `Engine connected — reading ${frame.location.viaIngest ? "the ingest inbox" : "Playnite's ExtensionsData"}`,
);
if (frame.exportError !== null) push(frame.exportError, "error");
if (frame.syncing) push("Sync running");
} else {
if (!prev.syncing && frame.syncing) push("Sync started");
if (prev.syncing && !frame.syncing) {
const report = frame.lastReport;
if (report === null) {
push("Sync finished");
} else {
push(
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
);
for (const warning of report.warnings) push(warning, "warn");
}
}
if (
frame.exportError !== null &&
frame.exportError !== prev.exportError
) {
push(frame.exportError, "error");
}
if (prev.exportError !== null && frame.exportError === null) {
push("Exporter output is readable again");
}
if (
frame.location.mtime !== null &&
frame.location.mtime !== prev.location.mtime
) {
push("Playnite wrote a new library export");
}
}
prev = frame;
if (next.length > 0) {
items = [...items, ...next].slice(-EVENT_LIMIT);
get.setSelf(items);
}
},
{ immediate: true },
);
return items;
});
+42
View File
@@ -0,0 +1,42 @@
// The one place the UI talks HTTP: a typed client + atom factory derived from the SHARED
// HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in pages —
// endpoints, payloads, and errors all flow from @playnite/contract.
import { PlayniteApi } from "@playnite/contract";
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
} from "effect/unstable/http";
import { AtomHttpApi } from "effect/unstable/reactivity";
import { installedMockHttpLayer } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
/**
* "/plugin-ui/playnite" behind the console proxy, "" in dev — Vite serves at "/", so
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
*/
export const prefix = resolvePluginBase();
export class Api extends AtomHttpApi.Service<Api>()("PlayniteApi", {
api: PlayniteApi,
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
// import); if it is not installed we fall through to fetch — a loud, honest failure.
httpClient: (get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockHttpLayer(get);
if (mock !== undefined) return mock;
}
return FetchHttpClient.layer;
},
// prependUrl("") breaks URL joining — only install the transform when proxied.
...(prefix !== ""
? {
transformClient: HttpClient.mapRequest(
HttpClientRequest.prependUrl(prefix),
),
}
: {}),
}) {}
+97
View File
@@ -0,0 +1,97 @@
// The config draft: a local raw-config working copy seeded from GET /api/config.
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
// decodes the draft through the SHARED PlayniteConfigSchema so controls can display
// effective values (e.g. pollMinutes 5 when the file omits it).
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { type Config, type RawConfig, resolveConfig } from "@playnite/contract";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState } from "react";
import { errorText } from "@/lib/errors";
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
export type ConfigDraft = {
/** False until GET /api/config has succeeded once (render a skeleton). */
readonly loaded: boolean;
/** The working copy of the authored (raw) config. `{}` until loaded. */
readonly draft: RawConfig;
/** The draft decoded through PlayniteConfigSchema (defaults filled), if valid. */
readonly resolved: Config | undefined;
readonly dirty: boolean;
readonly saving: boolean;
readonly patch: (patch: Partial<RawConfig>) => void;
readonly save: () => void;
readonly discard: () => void;
};
type DraftState = {
readonly baseline: RawConfig;
readonly draft: RawConfig;
};
export const useConfigDraft = (): ConfigDraft => {
const result = useAtomValue(configAtom);
const saveResult = useAtomValue(saveConfig);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
const [state, setState] = useState<DraftState | null>(null);
// Seed once from the first successful load; later refetches (our own saves) must not
// clobber in-progress edits.
useEffect(() => {
if (state === null && AsyncResult.isSuccess(result)) {
const raw = result.value.raw as RawConfig;
setState({ baseline: raw, draft: raw });
}
}, [result, state]);
const draft = state?.draft ?? {};
const dirty =
state !== null &&
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
const resolved = useMemo(() => {
try {
return resolveConfig(draft);
} catch {
return undefined;
}
}, [draft]);
const patch = useCallback((p: Partial<RawConfig>) => {
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
}, []);
const discard = useCallback(() => {
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
}, []);
const save = useCallback(() => {
if (state === null) return;
const raw = state.draft;
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
(exit) => {
if (Exit.isSuccess(exit)) {
setState({ baseline: raw, draft: raw });
toast.success("Configuration saved");
} else {
toast.error("Save failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
},
);
}, [state, write]);
return {
loaded: state !== null,
draft,
resolved,
dirty,
saving: AsyncResult.isWaiting(saveResult),
patch,
save,
discard,
};
};
+24
View File
@@ -0,0 +1,24 @@
// One place to turn a typed API error (or any transport failure) into a sentence.
export const errorText = (error: unknown): string => {
if (typeof error === "object" && error !== null) {
const e = error as {
_tag?: string;
issues?: string;
message?: string;
};
if (e._tag === "ConfigInvalid" && typeof e.issues === "string") {
return e.issues;
}
if (e._tag === "SyncInProgress") return "A sync pass is already running";
if (typeof e.message === "string" && e.message.length > 0) return e.message;
if (typeof e._tag === "string") return e._tag;
}
return String(error);
};
/** True for the "the Playnite exporter hasn't produced anything yet" failure — the one
* error the UI answers with onboarding rather than a red box. */
export const isExportUnavailable = (error: unknown): boolean =>
typeof error === "object" &&
error !== null &&
(error as { _tag?: string })._tag === "ExportUnavailable";
+21
View File
@@ -0,0 +1,21 @@
import "./styles.css";
import { RegistryProvider } from "@effect/atom-react";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { defaultApiMode } from "./mocks/scenario";
// Zero-host dev loop: install the mock transport BEFORE the first render so the very
// first atom reads hit fixtures. The whole branch is dead-code-eliminated from production
// builds (import.meta.env.DEV is false), so no fixture code ships.
if (import.meta.env.DEV && defaultApiMode === "mock") {
await import("./mocks/http");
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RegistryProvider>
<App />
</RegistryProvider>
</StrictMode>,
);
+254
View File
@@ -0,0 +1,254 @@
// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT — if
// the contract moves, this file fails typecheck, exactly like the real server would.
// Never imported by production app code (see mocks/register.ts for the seam).
//
// Covers are `https://` URLs on purpose: Playnite passes a remote art reference through
// verbatim, so this is a shape the real exporter genuinely produces — and it keeps the
// fixtures loadable without the plugin's local-file art proxy.
import type {
EngineStatus,
PreviewResult,
RawConfig,
SyncReport,
} from "@playnite/contract";
import type { Scenario } from "./scenario";
type ProviderEntry = PreviewResult["entries"][number];
// ── Games ──────────────────────────────────────────────────────────────────────
const portrait = (title: string): string =>
`https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`;
const guid = (n: number): string =>
`${n.toString(16).padStart(8, "0")}-1111-4111-8111-111111111111`;
type Fixture = {
readonly id: string;
readonly title: string;
readonly source: string;
};
const FIXTURES: ReadonlyArray<Fixture> = [
{ id: guid(1), title: "Hollow Knight", source: "Steam" },
{ id: guid(2), title: "Disco Elysium", source: "GOG" },
{ id: guid(3), title: "Hades", source: "Steam" },
{ id: guid(4), title: "Outer Wilds", source: "Epic" },
{ id: guid(5), title: "Return of the Obra Dinn", source: "Steam" },
{ id: guid(6), title: "Baldur's Gate 3", source: "GOG" },
{ id: guid(7), title: "Slay the Spire", source: "Steam" },
{ id: guid(8), title: "Forza Horizon 5", source: "Xbox" },
{ id: guid(9), title: "Tunic", source: "Epic" },
{ id: guid(10), title: "Celeste", source: "itch.io" },
{ id: guid(11), title: "Super Metroid", source: "RetroArch" },
{ id: guid(12), title: "Chrono Trigger", source: "RetroArch" },
];
const entryOf = (f: Fixture): ProviderEntry => ({
external_id: f.id,
title: f.title,
art: { portrait: portrait(f.title) },
launch: {
kind: "command",
value: `start "" "playnite://playnite/start/${f.id}"`,
},
});
/** The source each fixture game belongs to, for the report's per-source counts. */
const SOURCE_OF = new Map(FIXTURES.map((f) => [f.id, f.source]));
// ── Config ─────────────────────────────────────────────────────────────────────
const HEALTHY_CONFIG: RawConfig = {
filter: { excludeSources: ["Xbox"] },
art: { mode: "host" },
};
export const configFor = (scenario: Scenario): RawConfig =>
scenario === "firstRun" ? {} : HEALTHY_CONFIG;
// ── Preview + report (derived from the current raw config, so toggles round-trip) ──
const SKIPPED = [
{
id: guid(8),
title: "Forza Horizon 5",
reason: 'source "Xbox" excluded',
},
{
id: guid(20),
title: "Cyberpunk 2077",
reason: "not installed",
},
{
id: guid(21),
title: "A Hidden Thing",
reason: "hidden in Playnite",
},
];
const GENERATED_AT = "2026-07-20T09:14:00Z";
export const previewFor = (
scenario: Scenario,
raw: RawConfig,
): PreviewResult => {
if (scenario === "firstRun") {
return {
entries: [],
report: {
generatedAt: "",
schemaVersion: 1,
considered: 0,
included: 0,
skipped: [],
excluded: [],
warnings: [],
perSource: {},
},
};
}
const overrides = raw.gameOverrides ?? {};
const excludedIds = new Set(
Object.entries(overrides)
.filter(([, o]) => o.exclude === true)
.map(([id]) => id),
);
const skippedIds = new Set(SKIPPED.map((s) => s.id));
const candidates = FIXTURES.filter((f) => !skippedIds.has(f.id));
const kept = candidates.filter((f) => !excludedIds.has(f.id));
const excluded = candidates
.filter((f) => excludedIds.has(f.id))
.map((f) => ({ id: f.id, title: f.title }));
const perSource: Record<string, number> = {};
for (const f of kept) {
const source = SOURCE_OF.get(f.id) ?? "(none)";
perSource[source] = (perSource[source] ?? 0) + 1;
}
const report: SyncReport = {
generatedAt: GENERATED_AT,
schemaVersion: 1,
considered: FIXTURES.length + 2,
included: kept.length,
skipped: SKIPPED,
excluded,
warnings:
scenario === "errors"
? [
'3001 entries with art.mode "dataurl" — inlined covers will exceed the host\'s reconcile body limit. Switch to art.mode "host" (the default), which serves covers by path.',
]
: [],
perSource,
};
return { entries: kept.map(entryOf), report };
};
export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport =>
previewFor(scenario, raw).report;
// ── Engine status + the SSE stand-in ticker ────────────────────────────────────
const INGEST_DIR = "C:\\ProgramData\\punktfunk\\ingest\\playnite";
const APPDATA_DIR = "C:\\Users\\enrico\\AppData\\Roaming\\Playnite";
const PATHS = {
dir: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite",
config: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite\\config.json",
cache: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite\\cache.json",
ingest: `${INGEST_DIR}\\punktfunk-library.json`,
} as const;
const CONNECTED_LOCATION: EngineStatus["location"] = {
dir: INGEST_DIR,
candidates: [INGEST_DIR, APPDATA_DIR],
file: `${INGEST_DIR}\\punktfunk-library.json`,
mtime: Date.parse(GENERATED_AT),
viaIngest: true,
};
const NO_EXPORT_LOCATION: EngineStatus["location"] = {
dir: APPDATA_DIR,
candidates: [INGEST_DIR, APPDATA_DIR],
file: null,
mtime: null,
viaIngest: false,
};
export const statusFor = (scenario: Scenario): EngineStatus => {
const report = reportFor(scenario, configFor(scenario));
const base: EngineStatus = {
platform: "win32",
location: CONNECTED_LOCATION,
exportError: null,
playnite: { version: "10.36", mode: "Desktop" },
artMode: "host",
syncing: false,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 8 * 60 * 1000,
},
lastReport: report,
paths: PATHS,
};
switch (scenario) {
case "firstRun":
return {
...base,
location: NO_EXPORT_LOCATION,
exportError:
"no exporter output under C:\\Users\\enrico\\AppData\\Roaming\\Playnite — is the Punktfunk Sync extension installed in Playnite?",
playnite: null,
lastSync: null,
lastReport: null,
};
case "syncing":
return { ...base, syncing: true };
case "errors":
return {
...base,
exportError:
'C:\\ProgramData\\punktfunk\\ingest\\playnite\\punktfunk-library.json is not a usable Punktfunk library export: SchemaError(Expected a punktfunk-library.json schema this plugin understands (≤ 1), got 2 at ["schema"])',
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 45 * 60 * 1000,
},
};
default:
return base;
}
};
/**
* Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop
* idle → syncing → done so the Overview feed and sync button stay alive without a host.
*/
export const tickerFrames = (
scenario: Scenario,
): ReadonlyArray<EngineStatus> => {
const base = statusFor(scenario);
switch (scenario) {
case "firstRun":
case "syncing":
return [base];
case "errors":
return [base, { ...base, syncing: true }, { ...base, syncing: false }];
default:
return [
base,
{ ...base, syncing: true },
{
...base,
syncing: false,
lastSync: {
fingerprint: "c4d2ba50fe13",
count: base.lastReport?.included ?? 0,
at: Date.now(),
},
},
];
}
};
+124
View File
@@ -0,0 +1,124 @@
// The mock transport: a full in-memory implementation of the PlayniteApi surface as an
// HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS both
// (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and Storybook's
// preview import it, so production app chunks never contain fixtures.
import type { EngineStatus, RawConfig } from "@playnite/contract";
import { Duration, Effect, Layer } from "effect";
import {
HttpClient,
type HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
import * as fx from "./fixtures";
import { registerMocks } from "./register";
import { type Scenario, scenarioAtom } from "./scenario";
/** PUT /api/config lands here so config edits round-trip within a registry.
* keepAlive: written/read outside the reactive graph — see mocks/scenario.ts. */
const mockRawConfigAtom = Atom.keepAlive(Atom.make<RawConfig | null>(null));
const currentRawConfig = (
registry: AtomRegistry.AtomRegistry,
scenario: Scenario,
): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario);
const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
const body = request.body;
if (body._tag === "Uint8Array") {
return JSON.parse(new TextDecoder().decode(body.body));
}
if (body._tag === "Raw") return body.body;
return undefined;
};
/** The 503 the server returns while the Playnite exporter has produced nothing. */
const EXPORT_UNAVAILABLE = {
_tag: "ExportUnavailable",
message:
"no exporter output under C:\\Users\\enrico\\AppData\\Roaming\\Playnite — is the Punktfunk Sync extension installed in Playnite?",
};
export const mockHttpClientLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> => {
const registry = get.registry;
return Layer.succeed(HttpClient.HttpClient)(
HttpClient.make((request, url) =>
Effect.gen(function* () {
// Real-ish latency so skeletons/spinners are honest in dev + Storybook.
yield* Effect.sleep(Duration.millis(150 + Math.random() * 250));
const scenario = registry.get(scenarioAtom);
const json = (body: unknown, status = 200) =>
HttpClientResponse.fromWeb(request, Response.json(body, { status }));
switch (`${request.method} ${url.pathname}`) {
case "GET /api/status":
return json(fx.statusFor(scenario));
case "GET /api/config":
return json({ raw: currentRawConfig(registry, scenario) });
case "PUT /api/config": {
if (scenario === "errors") {
return json(
{
_tag: "ConfigInvalid",
issues:
'art.mode: expected "host" | "dataurl" | "off", got "inline"',
},
400,
);
}
const raw = requestJson(request) as RawConfig;
registry.set(mockRawConfigAtom, raw);
return json({ raw });
}
case "GET /api/preview": {
if (scenario === "firstRun") return json(EXPORT_UNAVAILABLE, 503);
return json(
fx.previewFor(scenario, currentRawConfig(registry, scenario)),
);
}
case "POST /api/sync": {
if (scenario === "syncing") {
return json({ _tag: "SyncInProgress" }, 409);
}
if (scenario === "firstRun") return json(EXPORT_UNAVAILABLE, 503);
return json(
fx.reportFor(scenario, currentRawConfig(registry, scenario)),
);
}
default:
return json(
{ error: `mock: unhandled ${request.method} ${url.pathname}` },
404,
);
}
}),
),
);
};
/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */
export const mockStatusStreamAtom: Atom.Atom<EngineStatus | undefined> =
Atom.make((get) => {
const frames = fx.tickerFrames(get(scenarioAtom));
if (frames.length > 1) {
let i = 0;
const timer = setInterval(() => {
i += 1;
get.setSelf(frames[i % frames.length]);
}, 2000);
get.addFinalizer(() => clearInterval(timer));
}
return frames[0];
});
registerMocks({
httpLayer: mockHttpClientLayer,
statusStream: mockStatusStreamAtom,
});
+34
View File
@@ -0,0 +1,34 @@
// The dev-only seam between the always-shipped data layer and the mock transport.
// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls
// `registerMocks` at import time and is only ever imported by main.tsx behind
// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay free
// of fixture code without any conditional imports in the data layer.
import type { EngineStatus } from "@playnite/contract";
import type { Layer } from "effect";
import type { HttpClient } from "effect/unstable/http";
import type { Atom } from "effect/unstable/reactivity";
export type MockHttpLayerFactory = (
get: Atom.AtomContext,
) => Layer.Layer<HttpClient.HttpClient>;
type Registered = {
readonly httpLayer: MockHttpLayerFactory;
readonly statusStream: Atom.Atom<EngineStatus | undefined>;
};
let registered: Registered | undefined;
export const registerMocks = (mocks: Registered): void => {
registered = mocks;
};
/** The mock HttpClient layer, when mocks/http.ts has been imported. */
export const installedMockHttpLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> | undefined => registered?.httpLayer(get);
/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */
export const installedMockStatusStream = ():
| Atom.Atom<EngineStatus | undefined>
| undefined => registered?.statusStream;
+43
View File
@@ -0,0 +1,43 @@
// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production
// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures.
import { Atom } from "effect/unstable/reactivity";
export type ApiMode = "mock" | "live";
/** The fixture worlds the mock transport can serve (Storybook picks one per story). */
export type Scenario = "healthy" | "firstRun" | "syncing" | "errors";
/**
* Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and
* production builds hit the real API. Storybook seeds this atom to "mock" per registry.
*/
export const defaultApiMode: ApiMode =
import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live"
? "mock"
: "live";
// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does a
// request-time registry.get) — without it, a registry-seeded value on a page that never
// subscribes them is collected and the read falls back to the default.
export const apiModeAtom: Atom.Writable<ApiMode> = Atom.keepAlive(
Atom.make<ApiMode>(defaultApiMode),
);
const SCENARIOS: ReadonlyArray<Scenario> = [
"healthy",
"firstRun",
"syncing",
"errors",
];
const isScenario = (value: unknown): value is Scenario =>
SCENARIOS.includes(value as Scenario);
/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */
const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO)
? import.meta.env.VITE_MOCK_SCENARIO
: "healthy";
export const scenarioAtom: Atom.Writable<Scenario> = Atom.keepAlive(
Atom.make<Scenario>(defaultScenario),
);
+15
View File
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LibraryPage } from "./library";
const meta = {
title: "Pages/Library",
component: LibraryPage,
args: { navigate: () => {} },
} satisfies Meta<typeof LibraryPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
export const Errors: Story = { parameters: { scenario: "errors" } };
+276
View File
@@ -0,0 +1,276 @@
// Library — subscribes previewAtom (the dry-run desired state) + configAtom (for the
// include toggles); mutates saveConfig directly (no draft — a toggle saves immediately).
//
// Covers: Playnite art is usually a LOCAL file on the host, which the browser cannot
// load, so `artUrl` routes those through the plugin's allow-listed /api/art proxy and
// leaves already-loadable http/data references alone.
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import {
artUrl,
type PreviewResult,
type RawConfig,
type SyncReport,
} from "@playnite/contract";
import { Button } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { EmptyState } from "@unom/ui/empty-state";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@unom/ui/table";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { Gamepad2, LibraryBig, PlugZap } from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useState } from "react";
import { Gate } from "@/components/gate";
import {
configAtom,
previewAtom,
SAVE_CONFIG_KEYS,
saveConfig,
} from "@/data/atoms";
import { prefix } from "@/data/client";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
type Entry = PreviewResult["entries"][number];
const PageSkeleton = () => (
<div className="grid grid-cols-2 gap-card md:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }, (_, i) => (
<Skeleton key={i} className="aspect-[2/3] w-full rounded-card" />
))}
</div>
);
const NoExporter = () => (
<EmptyState
icon={PlugZap}
title="No library yet"
description="Install the Punktfunk Sync extension in Playnite and restart it — the games it exports show up here."
className="py-16"
/>
);
/** Toggle helper: rewrites gameOverrides on the CURRENT raw config and saves. */
const useSetIncluded = () => {
const config = useAtomValue(configAtom);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
return useCallback(
(id: string, included: boolean) => {
if (!AsyncResult.isSuccess(config)) return;
const raw = config.value.raw as RawConfig;
const overrides = { ...(raw.gameOverrides ?? {}) };
// `exclude` is an optionalKey — the key must be OMITTED, never set undefined.
const { exclude: _drop, ...rest } = overrides[id] ?? {};
if (included) {
if (Object.keys(rest).length === 0) delete overrides[id];
else overrides[id] = rest;
} else {
overrides[id] = { ...rest, exclude: true };
}
void write({
payload: { ...raw, gameOverrides: overrides },
reactivityKeys: [...SAVE_CONFIG_KEYS],
}).then((exit) => {
if (!Exit.isSuccess(exit)) {
toast.error("Couldn't update the game", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
},
[config, write],
);
};
const GameCard = ({
entry,
index,
onToggle,
}: {
entry: Entry;
index: number;
onToggle: (id: string, included: boolean) => void;
}) => {
const [imgState, setImgState] = useState<"loading" | "ok" | "error">(
"loading",
);
const portrait = artUrl(prefix, entry.art?.portrait);
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
whileHover={{ scale: 1.02 }}
transition={{
duration: 0.4,
ease: [0.165, 0.84, 0.44, 1],
delay: Math.min(index, 24) * 0.04,
}}
>
<Card
padding={false}
className="overflow-hidden ring-0 transition-shadow duration-300 hover:ring-2 hover:ring-accent/40"
>
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gradient-to-br from-secondary to-muted">
{portrait !== undefined && imgState !== "error" ? (
<>
{imgState === "loading" ? (
<Skeleton className="absolute inset-0 rounded-none" />
) : null}
<img
src={portrait}
alt={entry.title}
loading="lazy"
onLoad={() => setImgState("ok")}
onError={() => setImgState("error")}
className="h-full w-full object-cover"
/>
</>
) : (
<div className="flex h-full items-center justify-center">
<Gamepad2 className="size-8 text-muted-foreground/50" />
</div>
)}
</div>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2">
<p className="line-clamp-2 text-sm font-medium leading-tight">
{entry.title}
</p>
<Switch
checked
onCheckedChange={() => onToggle(entry.external_id, false)}
aria-label={`Include ${entry.title}`}
/>
</div>
</div>
</Card>
</motion.div>
);
};
const SkippedCard = ({
report,
onToggle,
}: {
report: SyncReport;
onToggle: (id: string, included: boolean) => void;
}) => {
const [open, setOpen] = useState(false);
const total = report.skipped.length + report.excluded.length;
if (total === 0) return null;
return (
<Card padding={false} className="overflow-hidden">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium hover:bg-secondary/40"
>
<span>Not in the library ({total})</span>
<span className="text-muted-foreground">{open ? "Hide" : "Show"}</span>
</button>
{open ? (
<div className="space-y-5 px-5 pb-5">
{report.skipped.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.skipped.map((skip) => (
<TableRow key={skip.id}>
<TableCell>{skip.title}</TableCell>
<TableCell className="text-muted-foreground">
{skip.reason}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
{report.excluded.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">
Excluded by you
</p>
{report.excluded.map((excluded) => (
<div
key={excluded.id}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="truncate">{excluded.title}</span>
<Button
variant="outline"
size="sm"
onClick={() => onToggle(excluded.id, true)}
>
Re-include
</Button>
</div>
))}
</div>
) : null}
</div>
) : null}
</Card>
);
};
const LibraryContent = ({ preview }: { preview: PreviewResult }) => {
const setIncluded = useSetIncluded();
if (preview.entries.length === 0 && preview.report.excluded.length === 0) {
return (
<EmptyState
icon={LibraryBig}
title="Nothing to show yet"
description="Playnite exported a library, but every game was filtered out. Loosen the filters under Settings — for example, turn off “only installed games”."
/>
);
}
return (
<div className="space-y-6">
<div className="@container">
<div className="grid grid-cols-2 gap-card @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
{preview.entries.map((entry, index) => (
<GameCard
key={entry.external_id}
entry={entry}
index={index}
onToggle={setIncluded}
/>
))}
</div>
</div>
<SkippedCard report={preview.report} onToggle={setIncluded} />
</div>
);
};
export const LibraryPage = (_: PageProps) => {
const preview = useAtomValue(previewAtom);
const retry = useAtomRefresh(previewAtom);
return (
<Gate
result={preview}
skeleton={<PageSkeleton />}
retry={retry}
unavailable={<NoExporter />}
>
{(p) => <LibraryContent preview={p} />}
</Gate>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OverviewPage } from "./overview";
const meta = {
title: "Pages/Overview",
component: OverviewPage,
args: { navigate: () => {} },
} satisfies Meta<typeof OverviewPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
export const Syncing: Story = { parameters: { scenario: "syncing" } };
export const Errors: Story = { parameters: { scenario: "errors" } };
+290
View File
@@ -0,0 +1,290 @@
// Overview — subscribes liveStatusAtom (SSE-fresh engine status) + statusEventsAtom;
// mutates runSync. Before the Playnite exporter has written anything (no export file), it
// renders the two-step onboarding instead: that is the state every new install starts in.
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
import type { EngineStatus } from "@playnite/contract";
import { EventFeed } from "@unom/app-ui/event-feed";
import { StatCard } from "@unom/app-ui/stat-card";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@unom/ui/accordion";
import { AnimatedButton } from "@unom/ui/button";
import Card from "@unom/ui/card";
import { CodeBlock } from "@unom/ui/code-block";
import { EmptyState } from "@unom/ui/empty-state";
import { Skeleton } from "@unom/ui/skeleton";
import { Spinner } from "@unom/ui/spinner";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import {
Image,
Library,
PlugZap,
RefreshCw,
TriangleAlert,
Users,
} from "lucide-react";
import { Gate } from "@/components/gate";
import {
liveStatusAtom,
RUN_SYNC_KEYS,
runSync,
statusAtom,
statusEventsAtom,
} from "@/data/atoms";
import { errorText } from "@/lib/errors";
import type { PageProps } from "@/routes";
const PageSkeleton = () => (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
</div>
<Skeleton className="h-10 w-40" />
<Skeleton className="h-56" />
</div>
);
/** The state a fresh install is in: this plugin runs, the Playnite half does not yet. */
const FirstRun = ({ status }: { status: EngineStatus }) => (
<div className="space-y-6">
<EmptyState
icon={PlugZap}
title="Waiting for Playnite"
description={
"This half of the plugin is running. The other half — the Punktfunk Sync extension — lives inside Playnite and writes your library out on every change. Install it in Playnite (double-click punktfunk-sync.pext) and restart Playnite; your games appear here within seconds, no configuration needed."
}
className="py-16"
/>
<Card>
<h2 className="mb-1 text-lg font-semibold tracking-tight">
Where we're looking
</h2>
<p className="mb-3 text-sm text-muted-foreground">
The exporter drops the library into the first path below — the inbox the
host's de-privileged plugin runner is allowed to read. The rest are
Playnite data directories we also probe.
</p>
<CodeBlock copy>{status.location.candidates.join("\n")}</CodeBlock>
</Card>
</div>
);
const exportAge = (status: EngineStatus): string => {
if (status.location.mtime === null) return "no export yet";
const minutes = Math.max(
0,
Math.round((Date.now() - status.location.mtime) / 60_000),
);
if (minutes === 0) return "written just now";
if (minutes < 60) return `written ${minutes} min ago`;
if (minutes < 60 * 24) return `written ${Math.round(minutes / 60)} h ago`;
return `written ${Math.round(minutes / (60 * 24))} d ago`;
};
const lastSyncHint = (status: EngineStatus): string => {
if (status.lastSync === null) return "never synced";
const minutes = Math.max(
0,
Math.round((Date.now() - status.lastSync.at) / 60_000),
);
if (minutes === 0) return "synced just now";
if (minutes < 60) return `synced ${minutes} min ago`;
return `synced ${Math.round(minutes / 60)} h ago`;
};
const SyncButton = ({ status }: { status: EngineStatus }) => {
const syncResult = useAtomValue(runSync);
const sync = useAtomSet(runSync, { mode: "promiseExit" });
const pending = status.syncing || AsyncResult.isWaiting(syncResult);
const onSync = () => {
void sync({ reactivityKeys: [...RUN_SYNC_KEYS] }).then((exit) => {
if (Exit.isSuccess(exit)) {
toast.success(
`Sync complete — ${exit.value.included} ${exit.value.included === 1 ? "title" : "titles"} in the library`,
);
} else {
toast.error("Sync failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
});
};
return (
<AnimatedButton
onClick={onSync}
disabled={pending}
whileTap={{ scale: 0.97 }}
>
{pending ? (
<Spinner className="size-4" />
) : (
<RefreshCw className="size-4" />
)}
{pending ? "Syncing…" : "Sync now"}
</AnimatedButton>
);
};
/** A readable export that failed to decode — schema skew, truncation, a foreign file. */
const ExportProblem = ({ status }: { status: EngineStatus }) => {
if (status.exportError === null) return null;
return (
<Card className="border-error/40">
<div className="flex items-start gap-3">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-error" />
<div className="space-y-1">
<p className="text-sm font-medium">Can't read the Playnite export</p>
<p className="text-sm text-muted-foreground">{status.exportError}</p>
</div>
</div>
</Card>
);
};
const Warnings = ({ status }: { status: EngineStatus }) => {
const warnings = status.lastReport?.warnings ?? [];
if (warnings.length === 0) return null;
return (
<Card padding={false} className="overflow-hidden">
<Accordion type="single" collapsible>
<AccordionItem value="warnings" className="border-b-0">
<AccordionTrigger className="px-5 py-4 text-warn">
<span className="flex items-center gap-2">
<TriangleAlert className="size-4" />
{warnings.length} {warnings.length === 1 ? "warning" : "warnings"}{" "}
from the last sync
</span>
</AccordionTrigger>
<AccordionContent className="px-5 pb-4">
<ul className="space-y-1.5 text-sm text-muted-foreground">
{warnings.map((warning) => (
<li key={warning} className="flex gap-2">
<span aria-hidden className="text-warn">
</span>
{warning}
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</Card>
);
};
const ActivityFeed = () => {
const events = useAtomValue(statusEventsAtom);
return (
<Card>
<h2 className="mb-3 text-lg font-semibold tracking-tight">Activity</h2>
<EventFeed
items={events}
maxHeight="16rem"
empty="Waiting for engine events…"
/>
</Card>
);
};
const Dashboard = ({ status }: { status: EngineStatus }) => {
const report = status.lastReport;
const inLibrary = status.lastSync?.count ?? report?.included ?? 0;
const skipped = report?.skipped.length ?? 0;
const sources = Object.keys(report?.perSource ?? {}).length;
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
<StatCard
label="In library"
value={inLibrary}
icon={Library}
hint={lastSyncHint(status)}
/>
<StatCard
label="Sources"
value={sources}
icon={Users}
hint={
sources === 0
? "nothing synced yet"
: Object.entries(report?.perSource ?? {})
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([source, n]) => `${source} ${n}`)
.join(" · ")
}
/>
<StatCard
label="Skipped"
value={skipped}
icon={TriangleAlert}
tone={skipped > 0 ? "warn" : "default"}
hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"}
/>
<StatCard
label="Cover art"
value={status.artMode}
icon={Image}
tone={status.artMode === "off" ? "default" : "success"}
hint={
status.artMode === "host"
? "served by the host"
: status.artMode === "dataurl"
? "inlined in the payload"
: "covers disabled"
}
/>
</div>
<Card>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium">
Exporter connected
{status.playnite?.version !== null &&
status.playnite?.version !== undefined
? ` — Playnite ${status.playnite.version}`
: ""}
</p>
<p className="text-sm text-muted-foreground">
{status.location.viaIngest
? "Reading the host ingest inbox"
: "Reading Playnite's ExtensionsData"}{" "}
· {exportAge(status)}
{report !== null ? ` · ${report.considered} games exported` : ""}
</p>
</div>
<SyncButton status={status} />
</div>
</Card>
<ExportProblem status={status} />
<Warnings status={status} />
<ActivityFeed />
</div>
);
};
export const OverviewPage = (_: PageProps) => {
const status = useAtomValue(liveStatusAtom);
const retry = useAtomRefresh(statusAtom);
return (
<Gate result={status} skeleton={<PageSkeleton />} retry={retry}>
{(s) =>
s.location.file === null ? (
<FirstRun status={s} />
) : (
<Dashboard status={s} />
)
}
</Gate>
);
};
+14
View File
@@ -0,0 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SettingsPage } from "./settings";
const meta = {
title: "Pages/Settings",
component: SettingsPage,
args: { navigate: () => {} },
} satisfies Meta<typeof SettingsPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
+301
View File
@@ -0,0 +1,301 @@
// Settings — subscribes the config draft (raw edits, resolved display values) +
// liveStatusAtom (the Files paths); mutates saveConfig via the sticky SaveBar.
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
import type { ArtMode, RawConfig } from "@playnite/contract";
import { PathListEditor } from "@unom/app-ui/path-list-editor";
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
import { CodeBlock } from "@unom/ui/code-block";
import { InputNumber } from "@unom/ui/form/input-number";
import { InputText } from "@unom/ui/form/input-text";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@unom/ui/form/select";
import { Switch } from "@unom/ui/form/switch";
import { Skeleton } from "@unom/ui/skeleton";
import { Gate } from "@/components/gate";
import { SaveBar } from "@/components/save-bar";
import { configAtom, liveStatusAtom, statusAtom } from "@/data/atoms";
import { useConfigDraft } from "@/data/drafts";
import type { PageProps } from "@/routes";
type ArtRaw = NonNullable<RawConfig["art"]>;
type SyncRaw = NonNullable<RawConfig["sync"]>;
type FilterRaw = NonNullable<RawConfig["filter"]>;
const PageSkeleton = () => (
<div className="space-y-6">
<Skeleton className="h-72" />
<Skeleton className="h-64" />
<Skeleton className="h-52" />
<Skeleton className="h-40" />
</div>
);
const ART_MODES: ReadonlyArray<{
readonly value: ArtMode;
readonly label: string;
readonly hint: string;
}> = [
{
value: "host",
label: "Served by the host",
hint: "Send each cover's local path and let the host serve the bytes. Scales to any library size — the recommended mode.",
},
{
value: "dataurl",
label: "Inlined in the payload",
hint: "Embed each cover in the library payload. Self-contained, but only fit for small libraries.",
},
{ value: "off", label: "No cover art", hint: "Sync titles with no art." },
];
/** A string list editor over one of the source filters (`sources` / `excludeSources`). */
const SourceList = ({
values,
onChange,
placeholder,
addLabel,
}: {
values: ReadonlyArray<string>;
onChange: (next: ReadonlyArray<string>) => void;
placeholder: string;
addLabel: string;
}) => (
<PathListEditor<string>
items={values}
onChange={onChange}
path={{ get: (s) => s, set: (_, value) => value, placeholder }}
makeItem={(s) => s}
addLabel={addLabel}
className="w-full"
/>
);
export const SettingsPage = (_: PageProps) => {
const config = useAtomValue(configAtom);
const retry = useAtomRefresh(configAtom);
const status = useAtomValue(liveStatusAtom);
const retryStatus = useAtomRefresh(statusAtom);
const draft = useConfigDraft();
const { resolved } = draft;
const patchArt = (patch: Partial<ArtRaw>) =>
draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } });
const patchSync = (patch: Partial<SyncRaw>) =>
draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } });
const patchFilter = (patch: Partial<FilterRaw>) =>
draft.patch({ filter: { ...(draft.draft.filter ?? {}), ...patch } });
const artMode = resolved?.art.mode ?? "host";
return (
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
{() => (
<div className="space-y-6">
<SettingsGroup
title="Which games sync"
description="Playnite exports your whole library; these filters decide what reaches the Punktfunk library."
>
<SettingsRow
label="Only installed games"
description="Skip titles Playnite knows about but hasn't installed."
>
<Switch
checked={resolved?.filter.installedOnly ?? true}
onCheckedChange={(checked) =>
patchFilter({ installedOnly: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Include hidden games"
description="Titles you flagged Hidden in Playnite."
>
<Switch
checked={resolved?.filter.includeHidden ?? false}
onCheckedChange={(checked) =>
patchFilter({ includeHidden: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Only these sources"
description='Leave empty for every source. Names as Playnite shows them ("Steam", "GOG", "Epic"…); matching ignores case.'
vertical
>
<SourceList
values={resolved?.filter.sources ?? []}
onChange={(sources) => patchFilter({ sources: [...sources] })}
placeholder="Steam"
addLabel="Add source"
/>
</SettingsRow>
<SettingsRow
label="Never these sources"
description="Applied after the list above."
vertical
>
<SourceList
values={resolved?.filter.excludeSources ?? []}
onChange={(excludeSources) =>
patchFilter({ excludeSources: [...excludeSources] })
}
placeholder="Xbox"
addLabel="Exclude source"
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Cover art"
description="Playnite keeps cover art as files on this machine; this decides how it reaches your clients."
>
<SettingsRow
label="Delivery"
description={
ART_MODES.find((m) => m.value === artMode)?.hint ?? ""
}
>
<Select
value={artMode}
onValueChange={(value) => patchArt({ mode: value as ArtMode })}
>
<SelectTrigger size="sm" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ART_MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsRow>
{artMode !== "off" ? (
<SettingsRow
label="Also send backgrounds"
description="Playnite's background image becomes hero art. Usually large."
>
<Switch
checked={resolved?.art.includeBackground ?? false}
onCheckedChange={(checked) =>
patchArt({ includeBackground: checked === true })
}
/>
</SettingsRow>
) : null}
{artMode === "dataurl" ? (
<SettingsRow
label="Size cap per image"
description="Bytes. A cover larger than this is dropped rather than inlined."
>
<InputNumber
className="w-36"
min={1}
step={100_000}
value={resolved?.art.maxBytes ?? 1_500_000}
onChange={(maxBytes) => patchArt({ maxBytes })}
/>
</SettingsRow>
) : null}
</SettingsGroup>
<SettingsGroup
title="Sync"
description="When the plugin re-reads Playnite's export and reconciles the library."
>
<SettingsRow
label="Watch for changes"
description="React to a new export immediately (polling stays on as the fallback)."
>
<Switch
checked={resolved?.sync.watch ?? true}
onCheckedChange={(checked) =>
patchSync({ watch: checked === true })
}
/>
</SettingsRow>
<SettingsRow
label="Poll interval"
description="Minutes between re-reads."
>
<InputNumber
className="w-28"
min={1}
value={resolved?.sync.pollMinutes ?? 5}
onChange={(pollMinutes) => patchSync({ pollMinutes })}
/>
</SettingsRow>
<SettingsRow
label="Debounce"
description="Milliseconds to wait after a change before re-reading."
>
<InputNumber
className="w-28"
min={0}
step={100}
value={resolved?.sync.debounceMs ?? 1500}
onChange={(debounceMs) => patchSync({ debounceMs })}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Advanced"
description="Only needed when Playnite isn't where the plugin expects it."
>
<SettingsRow
label="Playnite data directory"
description="Overrides the auto-detected %APPDATA%\Playnite — for a portable install. The host ingest inbox is still read first."
>
<InputText
className="w-72"
spellCheck={false}
value={draft.draft.playniteDir ?? ""}
placeholder="C:\Playnite"
onChange={(event) => {
const dir = event.target.value;
// `playniteDir` is an optionalKey: undefined is dropped by
// JSON.stringify, so the PUT body omits it entirely.
draft.patch({
playniteDir: dir.length > 0 ? dir : undefined,
});
}}
/>
</SettingsRow>
</SettingsGroup>
<SettingsGroup
title="Files"
description="Where this plugin keeps its state, and where Playnite drops the library."
>
<Gate
result={status}
skeleton={<Skeleton className="h-24" />}
retry={retryStatus}
>
{(s) => (
<CodeBlock copy>
{`config: ${s.paths.config}\ncache: ${s.paths.cache}\ninbox: ${s.paths.ingest}\nexport: ${s.location.file ?? "(not written yet)"}`}
</CodeBlock>
)}
</Gate>
</SettingsGroup>
<SaveBar
dirty={draft.dirty}
saving={draft.saving}
onSave={draft.save}
onDiscard={draft.discard}
/>
</div>
)}
</Gate>
);
};
+9
View File
@@ -0,0 +1,9 @@
// The flat route list, shared by App.tsx and the pages (kept out of App.tsx so pages
// can type their `navigate` prop without importing the shell).
export const ROUTES = ["overview", "library", "settings"] as const;
export type Route = (typeof ROUTES)[number];
export type PageProps = {
readonly navigate: (route: Route) => void;
};
+19
View File
@@ -0,0 +1,19 @@
/* The plugin UI's single stylesheet entry. The kit theme.css carries the console's
* violet identity (shadcn + @unom token vocabularies, Penner easings, `.dark` palette)
* so the embedded iframe is indistinguishable from the console around it. Do NOT import
* @unom/ui/styles/theme.css — that is the library's own neutral palette. */
@import "tailwindcss";
@import "tw-animate-css";
@import "@punktfunk/plugin-kit/theme.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
/* Scan the design system's built output so its class names survive Tailwind's purge.
* Bare directory globs on purpose — rebased file globs proved flaky. */
@source "../node_modules/@unom/ui/dist";
@source "../node_modules/@unom/app-ui/dist";
body {
@apply bg-background font-sans text-foreground antialiased;
}
+144
View File
@@ -0,0 +1,144 @@
// Capture page screenshots from the built Storybook — the same harness the console
// uses (punktfunk/web/tools/screenshots.mjs): headless Chromium over storybook-static,
// every Pages/* + Shell/* story, rendered entirely from fixtures. No host required.
//
// bun run build-storybook # produce ./storybook-static
// bun run screenshots # → ./screenshots/<story-id>.png
//
// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the
// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x),
// ONLY (comma-separated story-id substring filter).
import { existsSync } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
import { chromium } from "playwright";
const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static");
const OUT = resolve(process.env.OUT ?? "screenshots");
const SETTLE = Number(process.env.SETTLE ?? 600);
const WIDTH = Number(process.env.WIDTH ?? 1440);
const HEIGHT = Number(process.env.HEIGHT ?? 900);
const SCALE = Number(process.env.SCALE ?? 2);
const ONLY = (process.env.ONLY ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Only the page-level + shell stories make sense as screenshots.
const TITLE_PREFIXES = ["Pages/", "Shell/"];
const MIME = {
".html": "text/html",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json",
".ico": "image/x-icon",
};
function staticServer(rootDir) {
return createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://localhost");
let path = decodeURIComponent(url.pathname);
if (path.endsWith("/")) path += "index.html";
// Contain the path to rootDir (no traversal).
const filePath = normalize(join(rootDir, path));
if (!filePath.startsWith(rootDir)) {
res.writeHead(403).end();
return;
}
const body = await readFile(filePath);
res.writeHead(200, {
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
}
async function listStories(rootDir) {
const indexPath = join(rootDir, "index.json");
if (!existsSync(indexPath)) {
throw new Error(
`${indexPath} not found — run \`bun run build-storybook\` first`,
);
}
const index = JSON.parse(await readFile(indexPath, "utf8"));
const entries = Object.values(index.entries ?? index.stories ?? {});
return entries
.filter((e) => e.type === "story" || e.type === undefined)
.filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p)))
.filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f)))
.sort((a, b) => a.id.localeCompare(b.id));
}
async function main() {
if (!existsSync(ROOT)) {
throw new Error(
`${ROOT} not found — run \`bun run build-storybook\` first`,
);
}
const stories = await listStories(ROOT);
if (stories.length === 0)
throw new Error("no Pages/* or Shell/* stories found");
await mkdir(OUT, { recursive: true });
const server = staticServer(ROOT);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
const browser = await chromium.launch({
args: ["--force-color-profile=srgb"],
});
const context = await browser.newContext({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: SCALE,
colorScheme: "dark",
});
let ok = 0;
for (const story of stories) {
const page = await context.newPage();
const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent(
story.id,
)}&viewMode=story`;
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
// Story root mounted with real content.
await page.waitForSelector("#storybook-root > *", { timeout: 20_000 });
// Web fonts settled (else text reflows / falls back in the shot).
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(SETTLE);
const file = join(OUT, `${story.id}.png`);
await page.screenshot({ path: file });
console.log(`ok ${story.id} -> ${file}`);
ok++;
} catch (e) {
console.warn(`FAIL ${story.id}: ${e.message}`);
} finally {
await page.close();
}
}
await browser.close();
await new Promise((r) => server.close(r));
console.log(`\n${ok}/${stories.length} stories captured -> ${OUT}`);
if (ok === 0) process.exit(1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", ".storybook", "vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
// Vite config for the plugin SPA. `base: "./"` because the console serves the built
// bundle from /plugin-ui/playnite/ — asset URLs must stay relative to survive the proxy.
// Dev has two modes (see src/mocks/): plain `bun run dev` runs entirely on fixtures,
// `bun run dev:live` proxies /api to the plugin's dev server (plugin/src/dev.ts, :5886).
import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
base: "./",
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
// Scan only the app entry — without this the dev dep-scanner also crawls any
// storybook-static/ build output sitting in the workspace and errors out.
optimizeDeps: { entries: ["index.html"] },
build: {
outDir: "../plugin/dist/ui",
emptyOutDir: true,
},
server: {
port: 5601,
...(process.env.VITE_API_MODE === "live"
? {
proxy: {
"/api": {
target: process.env.PLAYNITE_URL ?? "http://127.0.0.1:5886",
},
},
}
: {}),
},
});