feat(web): one Game sources surface, launcher rail, and the migration nudge

M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2.

WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They
were two cards because they were two different things: scanners were compiled
into the host, plugins were an afterthought. After the extraction they are the
same thing — the host reports ONE list of sources whose ids match whether they
came from a built-in scanner or the plugin replacing it — so one surface is both
simpler and the only honest presentation. Each row carries its toggle, a
running/stopped badge for plugin sources, an entry count, filter, settings and
an uninstall that offers to remove the games too. An "Add a source" rail lists
uncatalogued library plugins with a "Detected" badge; `detected` is deliberately
tri-state, so only a POSITIVE probe badges — an entry with no probes for this
platform is unknown, and calling that "not installed" would be a lie.

The settings drawer (SourceSettings.tsx) renders a generic form from the
plugin's own JSON Schema over GET/PUT /__config, through the existing
session-gated /plugin-ui/<id>/ proxy — zero new host surface, and the browser
never learns the plugin's port or secret. It flattens allOf branches (effect
nests a checked schema's annotations there, so a form reading only the top level
silently loses every title and default) and falls back to a JSON editor when any
field is a shape it cannot express — partial rendering would be worse than none,
because a field missing from the form is a setting the operator cannot change.

WP4.2 — uiPlugins() now excludes category "library", which covers both the
sidebar and the mobile overflow since they share the selector. The
/plugins/$pluginId/$ route still resolves, so existing deep links keep working;
library plugins are just not advertised.

WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid,
and the empty state points at the sources surface rather than leaving a bare
grid (after extraction, "no games" is the expected first-run state).

WP6.2 — a migration banner offering one install per still-built-in scanner whose
plugin is catalogued. One button per scanner, never a single "migrate
everything" and never a silent auto-install: installing code stays an explicit
operator act, and per-scanner is what makes it safe to repeat (the claim
suppresses the built-in idempotently, so a half-finished migration is a valid
state).

WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than
minting a third "Plugins"), Storybook stories for the sources card in three
states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite
build clean, check-i18n green at 595 messages for both locales.

Still owed: the browser click-through (the store's Tabs-theme bug shipped
through green types and lint), and an AppShell nav story — that one needs the
plugins query mocked, which does not exist in this Storybook setup yet.
This commit is contained in:
2026-08-05 10:03:24 +02:00
parent 8728d90e01
commit bd383f1820
11 changed files with 991 additions and 231 deletions
+24 -2
View File
@@ -29,8 +29,18 @@ export interface PluginSummary {
version?: string;
/** Present iff the plugin serves a UI (and thus gets a nav entry). */
ui?: PluginUiSummary;
/**
* What kind of plugin this is. The console knows one value — `"library"` — and keeps those OUT
* of the nav: a scanner's entry point is the Library section's Game sources surface, and six
* installed scanners would otherwise flood the sidebar (design D5). Absent on an older host, and
* absent by choice for a plugin that wants its own page anyway (rom-manager).
*/
category?: string;
}
/** The one category the console treats specially. */
export const LIBRARY_CATEGORY = "library";
// A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat
// tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle.
const ICONS: Record<string, LucideIcon> = {
@@ -97,6 +107,18 @@ export function usePlugins() {
});
}
/** Only the plugins that surface a UI — the ones that get a nav entry. */
/**
* The plugins that get a **nav entry**: those serving a UI, minus the library-category ones.
*
* A library plugin still serves a UI port (that is how `__config` is reached) and its
* `/plugins/$pluginId/$` route still resolves, so an existing deep link keeps working — it simply
* isn't advertised in the sidebar.
*/
export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] =>
(list ?? []).filter((p) => p.ui);
(list ?? []).filter((p) => p.ui && p.category !== LIBRARY_CATEGORY);
/** The installed library-category plugins — the Game sources surface's own list. */
export const libraryPlugins = (
list: PluginSummary[] | undefined,
): PluginSummary[] =>
(list ?? []).filter((p) => p.category === LIBRARY_CATEGORY);
+11
View File
@@ -69,6 +69,17 @@ export interface StoreEntry {
installed_version?: string;
update_available: boolean;
blocked?: string;
/**
* What kind of plugin this is. Browse filters on these, and the Library section's "Add a source"
* rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them.
*/
categories?: string[];
/**
* Whether the launcher this plugin scans looks installed on this host, from the index's own
* existence probes (design D8). `undefined` = the entry declares no probes for this platform,
* which is "unknown" and must render differently from "not installed".
*/
detected?: boolean;
}
export interface StoreCatalog {