Two commits: the bug fixes behind a field report on .21, then the per-title hide the library never had.
What was reported
Three symptoms after installing the newest host plus the lutris and heroic plugins: both plugins showed up in the console nav although they ship no UI, lutris's settings were unreachable from the Library screen, and lutris + steam logged sync (startup) failed: HostRequestError.
Three symptoms, two defects — and the second pairing is not the obvious one.
The nav — a publish gap
The console was never wrong: uiPlugins (web/src/api/plugins.ts:118) already keeps category: "library" plugins out of the sidebar. But the live host reports no category at all for them. defineLibraryPlugin sets it and sdk/src/ui.ts:198 forwards it — what ships does not. @punktfunk/host was bumped to 0.1.2 on 2026-07-20; category landed 2026-08-05 (3d4a6599) with no bump, so the registry's 0.1.2 is the pre-category build and every installed scanner registers without one.
Bumps the SDK to 0.1.3. ⚠️Inert until it is published — the fix reaches nobody until a sdk-v0.1.3 tag goes out and the plugins are reinstalled.
Because the field rides the untyped pf.request seam so an older host ignores it rather than rejecting the registration, dropping it is silent by design. serveUi now reads its own directory entry back and warns once when a requested category did not land — the same shape as the store-claim warning defineLibraryPlugin already has. That is what turns the next occurrence into a log line instead of a bug report.
The missing settings and the failed sync — one defect
local_art_bytes decodes a file:// value before testing containment. validate_art_paths handed the raw value to Path::new, where file:///home/u/c.jpg is a relative path whose first component is file:. It canonicalized against the cwd, failed, and read as "outside every art root". So the host refused every cover the kit's own fileUrl helper emits — the documented way for a plugin to publish local art — while the read path would have served those same files.
That the two symptoms share a cause is why they are one commit: the Library screen's settings control renders only for origin: "plugin", and a source becomes plugin only once it holds a store claim, taken during a successful reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed, both stayed origin: "builtin", and neither got a settings button. Heroic reconciled (its art is http(s)) and has had its settings all along; rom-manager was never affected because zero entries meant it never applied.
art_path_is_servable now decodes first, so both halves of the confinement judge the same string. Confinement is unchanged — an out-of-root path is still refused in file:// clothing, which the test asserts alongside the accept case.
Diagnosing this took the host's journal, because both surfaces that should have explained it lied. HostRequestError stringified to its bare tag, discarding the method, the path and the host's message; it now renders all three. And the host logged "payload carries a field this lane may not set" for both refusals in check_entry_fields, so a 400 about an art path read as an auth problem.
Hiding one title
The library had one visibility control and it was all-or-nothing: turn a source off and every one of its games goes. No way to drop a single title.
Not a field on the entry. Scanner and plugin titles are rebuilt on every scan and reconcile, so a flag written there would be erased by the next sync — silently, minutes later. library-hidden.json holds the ids instead, mirroring library-scanners.json. The id is stable by construction (D2), so a hide survives a re-scan, a plugin restart, and a store's built-in→plugin migration.
Applied in all_games, the one place every play surface already funnels through — client grid, native clients, the GameStream app list, launch resolution. A per-surface filter is a rule someone has to remember, and forgetting one is exactly the bug class the file:// asymmetry above is.
The console still sees them, or a hidden title could never be brought back — and that exception is a type, not a flag. GET /library answers Vec<GameEntry> on every lane but the operator's and Vec<OperatorGameEntry> on theirs, so a hidden entry cannot reach a paired client by someone forgetting a filter. hidden is skipped when false, so the response is byte-identical to today's for a library with nothing hidden.
PUT /library/hidden/{id} is operator-only — neither the plugin lane nor a paired cert, unlike the scanner toggle.
Curation, not access control: nothing is deleted and un-hiding is instant.
Verification
All on .21 (Linux) — the host's test target does not build on macOS.
The art test fails without the fix and passes with it — checked both directions rather than trusting a green run.
The hide routing test earns its keep: every library id contains a colon and Heroic's contain two, so a router that split on it would 404 the console against ids the host itself produced.
Console: tsc clean, production build clean, i18n 633 messages across en + de, biome clean on the touched files. Two pre-existing biome findings (SourceSettings.tsx accumulating spread, an unused entries in index.tsx) were left alone.
plugin-kit 71 and SDK 72 tests pass, both typecheck clean.
api/openapi.json was regenerated from the built host; the generated web client and paraglide output are gitignored and rebuilt at build time.
Before this helps anyone
Publish @punktfunk/host@0.1.3 (tag sdk-v0.1.3) and reinstall the plugins — nothing else fixes the nav.
Build a host canary — the art fix is host-side, so lutris/steam keep failing to claim until one ships.
@punktfunk/plugin-kit@0.3.3 (tag) carries the readable HostRequestError and the category warning.
Right now .21 has no Steam or Lutris games at all: those two built-in scanners are toggled off and their plugins cannot claim. Re-enabling the two built-in toggles restores the games today, with no release.
Two commits: the bug fixes behind a field report on `.21`, then the per-title hide the library never had.
## What was reported
Three symptoms after installing the newest host plus the lutris and heroic plugins: both plugins showed up in the console **nav** although they ship no UI, lutris's **settings were unreachable** from the Library screen, and lutris + steam logged `sync (startup) failed: HostRequestError`.
Three symptoms, **two** defects — and the second pairing is not the obvious one.
## The nav — a publish gap
The console was never wrong: `uiPlugins` (`web/src/api/plugins.ts:118`) already keeps `category: "library"` plugins out of the sidebar. But the live host reports **no category at all** for them. `defineLibraryPlugin` sets it and `sdk/src/ui.ts:198` forwards it — what *ships* does not. `@punktfunk/host` was bumped to 0.1.2 on **2026-07-20**; `category` landed **2026-08-05** (`3d4a6599`) with no bump, so the registry's 0.1.2 is the pre-`category` build and every installed scanner registers without one.
Bumps the SDK to **0.1.3**. ⚠️ **Inert until it is published** — the fix reaches nobody until a `sdk-v0.1.3` tag goes out and the plugins are reinstalled.
Because the field rides the untyped `pf.request` seam so an older host ignores it rather than rejecting the registration, dropping it is **silent by design**. `serveUi` now reads its own directory entry back and warns once when a requested category did not land — the same shape as the store-claim warning `defineLibraryPlugin` already has. That is what turns the next occurrence into a log line instead of a bug report.
## The missing settings *and* the failed sync — one defect
`local_art_bytes` decodes a `file://` value before testing containment. `validate_art_paths` handed the **raw** value to `Path::new`, where `file:///home/u/c.jpg` is a *relative* path whose first component is `file:`. It canonicalized against the cwd, failed, and read as "outside every art root". So the host refused every cover the kit's own `fileUrl` helper emits — the documented way for a plugin to publish local art — while the read path would have served those same files.
That the two symptoms share a cause is why they are one commit: the Library screen's settings control renders only for `origin: "plugin"`, and a source becomes `plugin` only once it holds a store **claim**, taken during a *successful* reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed, both stayed `origin: "builtin"`, and neither got a settings button. Heroic reconciled (its art is http(s)) and has had its settings all along; rom-manager was never affected because zero entries meant it never applied.
`art_path_is_servable` now decodes first, so both halves of the confinement judge the same string. **Confinement is unchanged** — an out-of-root path is still refused in `file://` clothing, which the test asserts alongside the accept case.
Diagnosing this took the host's journal, because both surfaces that should have explained it lied. `HostRequestError` stringified to its bare tag, discarding the method, the path and the host's message; it now renders all three. And the host logged *"payload carries a field this lane may not set"* for **both** refusals in `check_entry_fields`, so a 400 about an art path read as an auth problem.
## Hiding one title
The library had one visibility control and it was all-or-nothing: turn a **source** off and every one of its games goes. No way to drop a single title.
- **Not a field on the entry.** Scanner and plugin titles are rebuilt on every scan and reconcile, so a flag written there would be erased by the next sync — silently, minutes later. `library-hidden.json` holds the ids instead, mirroring `library-scanners.json`. The id is stable by construction (D2), so a hide survives a re-scan, a plugin restart, and a store's built-in→plugin migration.
- **Applied in `all_games`**, the one place every play surface already funnels through — client grid, native clients, the GameStream app list, launch resolution. A per-surface filter is a rule someone has to remember, and forgetting one is exactly the bug class the `file://` asymmetry above *is*.
- **The console still sees them**, or a hidden title could never be brought back — and that exception is a **type**, not a flag. `GET /library` answers `Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on theirs, so a hidden entry cannot reach a paired client by someone forgetting a filter. `hidden` is skipped when false, so the response is byte-identical to today's for a library with nothing hidden.
- `PUT /library/hidden/{id}` is **operator-only** — neither the plugin lane nor a paired cert, unlike the scanner toggle.
Curation, not access control: nothing is deleted and un-hiding is instant.
## Verification
All on `.21` (Linux) — the host's test target does not build on macOS.
- **469 host tests pass**, 0 failed (5 new); `clippy -D warnings` clean; `cargo fmt --all --check` clean.
- The art test **fails without the fix and passes with it** — checked both directions rather than trusting a green run.
- The hide routing test earns its keep: every library id contains a colon and Heroic's contain two, so a router that split on it would 404 the console against ids the host itself produced.
- Console: `tsc` clean, production build clean, i18n 633 messages across en + de, biome clean on the touched files. Two pre-existing biome findings (`SourceSettings.tsx` accumulating spread, an unused `entries` in `index.tsx`) were left alone.
- plugin-kit 71 and SDK 72 tests pass, both typecheck clean.
`api/openapi.json` was regenerated from the built host; the generated web client and paraglide output are gitignored and rebuilt at build time.
## Before this helps anyone
1. **Publish `@punktfunk/host@0.1.3`** (tag `sdk-v0.1.3`) and reinstall the plugins — nothing else fixes the nav.
2. **Build a host canary** — the art fix is host-side, so lutris/steam keep failing to claim until one ships.
3. `@punktfunk/plugin-kit@0.3.3` (tag) carries the readable `HostRequestError` and the category warning.
Right now `.21` has **no Steam or Lutris games at all**: those two built-in scanners are toggled off and their plugins cannot claim. Re-enabling the two built-in toggles restores the games today, with no release.
Three symptoms on .21, two defects. Lutris and Heroic appeared in the console sidebar
they explicitly opt out of; Lutris's settings were unreachable from the Library
screen; and Lutris and Steam logged `sync (startup) failed: HostRequestError`.
**The sidebar is a publish gap.** The console is correct — it keeps
`category: "library"` plugins out of the nav (`uiPlugins`, app-shell.tsx) — but the
host reports no category for them at all. `defineLibraryPlugin` sets it and
`sdk/src/ui.ts` forwards it; what SHIPS does not. `@punktfunk/host` was bumped to
0.1.2 on 2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's
0.1.2 is the pre-category build and every installed scanner registers without one.
Bumps the SDK to 0.1.3 — **inert until it is published**.
Because the field rides the untyped `pf.request` seam so an older host ignores it
rather than rejecting the registration, dropping it is silent by design. `serveUi` now
reads its own directory entry back and warns once when a requested category did not
land, the same way `defineLibraryPlugin` already warns when a store claim did not take.
That is what turns the next occurrence into a log line instead of a bug report.
**The missing settings and the failed sync are ONE defect: a write/read disagreement
about `file://`.** `local_art_bytes` decodes a `file://` value before testing
containment; `validate_art_paths` handed the raw value to `Path::new`, where
`file:///home/u/c.jpg` is a RELATIVE path whose first component is `file:`. It
canonicalized against the cwd, failed, and read as "outside every art root". So the
host refused every cover the kit's own `fileUrl` helper emits — the documented way for
a plugin to publish local art — while the read path would have served those same files.
That the two symptoms share a cause is not obvious and is why this is one commit: the
Library screen's settings control renders only for `origin: "plugin"`, and a source
becomes `plugin` only once it holds a store CLAIM, which is taken during a successful
reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed its
store, both stayed `origin: "builtin"`, and neither got a settings button. Heroic
reconciled (its art is http(s)) and has had its settings all along; rom-manager was
never affected because zero entries meant it never applied.
`art_path_is_servable` now decodes first, so both halves of the confinement judge the
same string. Confinement itself is unchanged: an out-of-root path is still refused in
`file://` clothing, which the test asserts alongside the accept case.
Diagnosing this took the HOST's journal, because both surfaces that should have
explained it lied. `HostRequestError` stringified to its bare tag, so the sync engine's
`${e.cause}` logged `HostRequestError` and discarded the method, the path and the
host's own message; it now renders all three, including an object-shaped cause that
used to print `[object Object]`. And the host logged "payload carries a field this lane
may not set" for BOTH refusals in `check_entry_fields`, so a 400 about an art path read
as an auth problem — it now logs the real reason and the entry title.
Verified on .21 (Linux): 463 host tests pass, clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The new art test fails without the fix and passes with
it. plugin-kit 71 and SDK 72 tests pass, both typecheck clean, biome clean.
The library had one visibility control and it was all-or-nothing: turn a SOURCE off
and every one of its games goes. There was no way to drop a single title — a Proton
tool the filter missed, a demo, a game someone doesn't want on the TV — short of
hiding the whole launcher it came from.
**Where the setting lives.** Not on the entry. Only manual custom entries are stored;
a scanner's and a plugin's titles are rebuilt from scratch on every scan and every
reconcile, so a flag written onto one would be erased by the next sync — silently, and
minutes later, which is the worst possible shape for a setting. So `library-hidden.json`
holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is
stable by construction (D2: a claimed store's entries keep `<store>:<external_id>`
across reconciles), so a hide survives a re-scan, a plugin restart, and a store's
built-in→plugin migration.
**Where it takes effect.** In `all_games`, which is the one place every play surface
already funnels through — the grid on a client, native clients, the GameStream app
list, and launch resolution. Putting it there rather than at each call site is
deliberate: a per-surface filter is a rule someone has to remember, and forgetting one
is precisely the class of bug the `file://` art asymmetry in the previous commit was.
Hiding is curation, not access control — nothing is deleted, and un-hiding is instant.
**The console is the one surface that still sees them**, or a hidden title could never
be brought back. That exception is a TYPE, not a flag: `GET /library` answers
`Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on
theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting
a filter — there is no field there to leak. `hidden` is skipped when false, so the
response is byte-identical to today's for a library with nothing hidden.
`PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert,
unlike the scanner toggle. A plugin has no business deciding what its operator sees, and
a client must not be able to hide a game on the host it is streaming from. The id is not
validated against the current library on purpose: a title can be legitimately absent at
that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the
operator's choice in that window is worse than storing an id that matches nothing today.
On the card, the poster dims and a Hidden badge says why — a faded tile with no label
reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary
card's, are not hover-revealed: the un-hide button is the only way out of the state, and
hiding it behind a hover would strand anyone on a touch screen.
Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The routing test is the one that earns its keep — every
library id contains a colon and Heroic's contain two, so a router that split on it would
404 the console against ids the host itself produced. Console: tsc clean, production
build clean, i18n 633 messages across en+de, biome clean on the touched files.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Two commits: the bug fixes behind a field report on
.21, then the per-title hide the library never had.What was reported
Three symptoms after installing the newest host plus the lutris and heroic plugins: both plugins showed up in the console nav although they ship no UI, lutris's settings were unreachable from the Library screen, and lutris + steam logged
sync (startup) failed: HostRequestError.Three symptoms, two defects — and the second pairing is not the obvious one.
The nav — a publish gap
The console was never wrong:
uiPlugins(web/src/api/plugins.ts:118) already keepscategory: "library"plugins out of the sidebar. But the live host reports no category at all for them.defineLibraryPluginsets it andsdk/src/ui.ts:198forwards it — what ships does not.@punktfunk/hostwas bumped to 0.1.2 on 2026-07-20;categorylanded 2026-08-05 (3d4a6599) with no bump, so the registry's 0.1.2 is the pre-categorybuild and every installed scanner registers without one.Bumps the SDK to 0.1.3. ⚠️ Inert until it is published — the fix reaches nobody until a
sdk-v0.1.3tag goes out and the plugins are reinstalled.Because the field rides the untyped
pf.requestseam so an older host ignores it rather than rejecting the registration, dropping it is silent by design.serveUinow reads its own directory entry back and warns once when a requested category did not land — the same shape as the store-claim warningdefineLibraryPluginalready has. That is what turns the next occurrence into a log line instead of a bug report.The missing settings and the failed sync — one defect
local_art_bytesdecodes afile://value before testing containment.validate_art_pathshanded the raw value toPath::new, wherefile:///home/u/c.jpgis a relative path whose first component isfile:. It canonicalized against the cwd, failed, and read as "outside every art root". So the host refused every cover the kit's ownfileUrlhelper emits — the documented way for a plugin to publish local art — while the read path would have served those same files.That the two symptoms share a cause is why they are one commit: the Library screen's settings control renders only for
origin: "plugin", and a source becomespluginonly once it holds a store claim, taken during a successful reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed, both stayedorigin: "builtin", and neither got a settings button. Heroic reconciled (its art is http(s)) and has had its settings all along; rom-manager was never affected because zero entries meant it never applied.art_path_is_servablenow decodes first, so both halves of the confinement judge the same string. Confinement is unchanged — an out-of-root path is still refused infile://clothing, which the test asserts alongside the accept case.Diagnosing this took the host's journal, because both surfaces that should have explained it lied.
HostRequestErrorstringified to its bare tag, discarding the method, the path and the host's message; it now renders all three. And the host logged "payload carries a field this lane may not set" for both refusals incheck_entry_fields, so a 400 about an art path read as an auth problem.Hiding one title
The library had one visibility control and it was all-or-nothing: turn a source off and every one of its games goes. No way to drop a single title.
library-hidden.jsonholds the ids instead, mirroringlibrary-scanners.json. The id is stable by construction (D2), so a hide survives a re-scan, a plugin restart, and a store's built-in→plugin migration.all_games, the one place every play surface already funnels through — client grid, native clients, the GameStream app list, launch resolution. A per-surface filter is a rule someone has to remember, and forgetting one is exactly the bug class thefile://asymmetry above is.GET /libraryanswersVec<GameEntry>on every lane but the operator's andVec<OperatorGameEntry>on theirs, so a hidden entry cannot reach a paired client by someone forgetting a filter.hiddenis skipped when false, so the response is byte-identical to today's for a library with nothing hidden.PUT /library/hidden/{id}is operator-only — neither the plugin lane nor a paired cert, unlike the scanner toggle.Curation, not access control: nothing is deleted and un-hiding is instant.
Verification
All on
.21(Linux) — the host's test target does not build on macOS.clippy -D warningsclean;cargo fmt --all --checkclean.tscclean, production build clean, i18n 633 messages across en + de, biome clean on the touched files. Two pre-existing biome findings (SourceSettings.tsxaccumulating spread, an unusedentriesinindex.tsx) were left alone.api/openapi.jsonwas regenerated from the built host; the generated web client and paraglide output are gitignored and rebuilt at build time.Before this helps anyone
@punktfunk/host@0.1.3(tagsdk-v0.1.3) and reinstall the plugins — nothing else fixes the nav.@punktfunk/plugin-kit@0.3.3(tag) carries the readableHostRequestErrorand the category warning.Right now
.21has no Steam or Lutris games at all: those two built-in scanners are toggled off and their plugins cannot claim. Re-enabling the two built-in toggles restores the games today, with no release.Three symptoms on .21, two defects. Lutris and Heroic appeared in the console sidebar they explicitly opt out of; Lutris's settings were unreachable from the Library screen; and Lutris and Steam logged `sync (startup) failed: HostRequestError`. **The sidebar is a publish gap.** The console is correct — it keeps `category: "library"` plugins out of the nav (`uiPlugins`, app-shell.tsx) — but the host reports no category for them at all. `defineLibraryPlugin` sets it and `sdk/src/ui.ts` forwards it; what SHIPS does not. `@punktfunk/host` was bumped to 0.1.2 on 2026-07-20 and `category` landed 2026-08-05 without a bump, so the registry's 0.1.2 is the pre-category build and every installed scanner registers without one. Bumps the SDK to 0.1.3 — **inert until it is published**. Because the field rides the untyped `pf.request` seam so an older host ignores it rather than rejecting the registration, dropping it is silent by design. `serveUi` now reads its own directory entry back and warns once when a requested category did not land, the same way `defineLibraryPlugin` already warns when a store claim did not take. That is what turns the next occurrence into a log line instead of a bug report. **The missing settings and the failed sync are ONE defect: a write/read disagreement about `file://`.** `local_art_bytes` decodes a `file://` value before testing containment; `validate_art_paths` handed the raw value to `Path::new`, where `file:///home/u/c.jpg` is a RELATIVE path whose first component is `file:`. It canonicalized against the cwd, failed, and read as "outside every art root". So the host refused every cover the kit's own `fileUrl` helper emits — the documented way for a plugin to publish local art — while the read path would have served those same files. That the two symptoms share a cause is not obvious and is why this is one commit: the Library screen's settings control renders only for `origin: "plugin"`, and a source becomes `plugin` only once it holds a store CLAIM, which is taken during a successful reconcile. Lutris failed at entry 0 and Steam at entry 3, so neither ever claimed its store, both stayed `origin: "builtin"`, and neither got a settings button. Heroic reconciled (its art is http(s)) and has had its settings all along; rom-manager was never affected because zero entries meant it never applied. `art_path_is_servable` now decodes first, so both halves of the confinement judge the same string. Confinement itself is unchanged: an out-of-root path is still refused in `file://` clothing, which the test asserts alongside the accept case. Diagnosing this took the HOST's journal, because both surfaces that should have explained it lied. `HostRequestError` stringified to its bare tag, so the sync engine's `${e.cause}` logged `HostRequestError` and discarded the method, the path and the host's own message; it now renders all three, including an object-shaped cause that used to print `[object Object]`. And the host logged "payload carries a field this lane may not set" for BOTH refusals in `check_entry_fields`, so a 400 about an art path read as an auth problem — it now logs the real reason and the entry title. Verified on .21 (Linux): 463 host tests pass, clippy clean under `-D warnings`, `cargo fmt --all --check` clean. The new art test fails without the fix and passes with it. plugin-kit 71 and SDK 72 tests pass, both typecheck clean, biome clean.The library had one visibility control and it was all-or-nothing: turn a SOURCE off and every one of its games goes. There was no way to drop a single title — a Proton tool the filter missed, a demo, a game someone doesn't want on the TV — short of hiding the whole launcher it came from. **Where the setting lives.** Not on the entry. Only manual custom entries are stored; a scanner's and a plugin's titles are rebuilt from scratch on every scan and every reconcile, so a flag written onto one would be erased by the next sync — silently, and minutes later, which is the worst possible shape for a setting. So `library-hidden.json` holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is stable by construction (D2: a claimed store's entries keep `<store>:<external_id>` across reconciles), so a hide survives a re-scan, a plugin restart, and a store's built-in→plugin migration. **Where it takes effect.** In `all_games`, which is the one place every play surface already funnels through — the grid on a client, native clients, the GameStream app list, and launch resolution. Putting it there rather than at each call site is deliberate: a per-surface filter is a rule someone has to remember, and forgetting one is precisely the class of bug the `file://` art asymmetry in the previous commit was. Hiding is curation, not access control — nothing is deleted, and un-hiding is instant. **The console is the one surface that still sees them**, or a hidden title could never be brought back. That exception is a TYPE, not a flag: `GET /library` answers `Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting a filter — there is no field there to leak. `hidden` is skipped when false, so the response is byte-identical to today's for a library with nothing hidden. `PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert, unlike the scanner toggle. A plugin has no business deciding what its operator sees, and a client must not be able to hide a game on the host it is streaming from. The id is not validated against the current library on purpose: a title can be legitimately absent at that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the operator's choice in that window is worse than storing an id that matches nothing today. On the card, the poster dims and a Hidden badge says why — a faded tile with no label reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary card's, are not hover-revealed: the un-hide button is the only way out of the state, and hiding it behind a hover would strand anyone on a touch screen. Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`, `cargo fmt --all --check` clean. The routing test is the one that earns its keep — every library id contains a colon and Heroic's contain two, so a router that split on it would 404 the console against ids the host itself produced. Console: tsc clean, production build clean, i18n 633 messages across en+de, biome clean on the touched files.