Reported as "Forza Horizon 6 shows a banner instead of a cover, and it breaks the library on iOS". Two independent defects, one in the scanner and one in the Apple client.
1. The art scan knew one filename per kind and one layout
Measured against a real 779-app appcache/librarycache (the .41 box):
the scan looked for
what is actually there
library_600x900.jpg
also library_capsule.jpg — 46 appids have ONLY this
library_header.jpg (122 appids)
also header.jpg — 594 appids, i.e. the majority
<appid>/<hash>/<name>
also <appid>/<name> — 623 of 779 appids, no hash dir
No appid carries both spellings of one kind, and none carries one kind in both layouts, so these are pure renames rather than variants. Confirmed by measuring the files: every library_capsule.jpg in that cache is 300×450 and so is every library_600x900.jpg (that name lies); both header spellings are 460×215.
Why it read as "some games". A title that resolves no local file still gets the flat CDN URL, and for anything Valve has not re-hashed that URL works. It is the newer titles that lose the art outright — Forza Horizon 6 (appid 2483190) 404s on both library_600x900.jpg and header.jpg — and the client then walks portrait → header → hero and draws a banner in a 2:3 poster slot.
Running the real resolver over that cache, per-appid art found locally:
with zero regressions — every appid the old resolver found still resolves.
2. A fallback banner sized the tile it was supposed to be cropped into
PosterImage already meant to prevent this; its own comment said a banner "would otherwise report a much wider intrinsic size than the card and overflow into neighboring cards". The guard it used does not do that. scaledToFill answers a proposal with a size that COVERS it, and .frame(maxWidth: .infinity, maxHeight: .infinity) clamps that answer to infinity — no clamp at all — so the image's size propagates up through aspectRatio(2/3, .fit) and decides the tile.
Measured offscreen with ImageRenderer, one LazyVGrid column at 170pt:
460x215 header, before poster 1750.9 x 818.4 tile 545.6 x 255.0
460x215 header, after poster 170.0 x 255.0 tile 170.0 x 255.0
300x450 cover, either poster 170.0 x 255.0 tile 170.0 x 255.0
A 545pt tile in a 170pt column is the reported break, and the cover case coming out right either way is why it only ever showed on titles whose cover was missing. Color.clear now takes the proposal and the art rides as its overlay, where it can be drawn but never measured. LibraryCoverflowView reuses PosterImage directly, so it is fixed by the same change.
The web console (aspect-[2/3] + object-cover) and Android (ContentScale.Crop in a constrained box) were checked against the same case and are immune.
Verification
bun test 82 pass, bun run typecheck, bun run check — all clean.
swift build in clients/apple — Build complete, LibraryWidgets.swift recompiles without warnings.
The real kit code run over a faithful replica of the .41 cache tree: the table above, zero regressions.
End to end on .41, with the fix hot-applied to the installed kit: library art values went local 31 → 77, CDN 52 → 6, and GET /api/v1/library/art/steam:2483190/portrait served 200 image/jpeg300×450. The box was then reverted and re-verified byte-identical to its pre-patch payload — it is not left patched.
This does not reach a host on merge
Each plugin carries its own nestednode_modules/@punktfunk/plugin-kit, so a kit publish alone changes nothing — patching the hoisted copy on .41 was a no-op while the runner logged a clean reconcile. Same four-repo chain as the icon field at 0.4.1:
this PR → tag plugin-kit-v0.4.2
punktfunk-plugin-steam → dep ^0.4.2 + version bump → tag v0.1.2
punktfunk-plugin-index → version + integrity + verification.reviewedAt
hosts
minHost is unaffected: nothing on the wire changes shape, only which file on disk the scan finds.
Reported as "Forza Horizon 6 shows a banner instead of a cover, and it breaks the library on iOS". Two independent defects, one in the scanner and one in the Apple client.
## 1. The art scan knew one filename per kind and one layout
Measured against a real 779-app `appcache/librarycache` (the .41 box):
| the scan looked for | what is actually there |
|---|---|
| `library_600x900.jpg` | also **`library_capsule.jpg`** — 46 appids have ONLY this |
| `library_header.jpg` (122 appids) | also **`header.jpg`** — **594** appids, i.e. the majority |
| `<appid>/<hash>/<name>` | also **`<appid>/<name>`** — **623 of 779** appids, no hash dir |
No appid carries both spellings of one kind, and none carries one kind in both layouts, so these are pure renames rather than variants. Confirmed by measuring the files: every `library_capsule.jpg` in that cache is 300×450 and so is every `library_600x900.jpg` (that name lies); both header spellings are 460×215.
**Why it read as "some games".** A title that resolves no local file still gets the flat CDN URL, and for anything Valve has not re-hashed that URL works. It is the newer titles that lose the art outright — Forza Horizon 6 (appid 2483190) 404s on both `library_600x900.jpg` and `header.jpg` — and the client then walks portrait → header → hero and draws a banner in a 2:3 poster slot.
Running the real resolver over that cache, per-appid art found locally:
```
portrait 25 -> 328 hero 75 -> 323
header 86 -> 716 logo 66 -> 295
```
with **zero regressions** — every appid the old resolver found still resolves.
## 2. A fallback banner sized the tile it was supposed to be cropped into
`PosterImage` already meant to prevent this; its own comment said a banner "would otherwise report a much wider intrinsic size than the card and overflow into neighboring cards". The guard it used does not do that. `scaledToFill` answers a proposal with a size that COVERS it, and `.frame(maxWidth: .infinity, maxHeight: .infinity)` clamps that answer to infinity — no clamp at all — so the image's size propagates up through `aspectRatio(2/3, .fit)` and decides the tile.
Measured offscreen with `ImageRenderer`, one LazyVGrid column at 170pt:
```
460x215 header, before poster 1750.9 x 818.4 tile 545.6 x 255.0
460x215 header, after poster 170.0 x 255.0 tile 170.0 x 255.0
300x450 cover, either poster 170.0 x 255.0 tile 170.0 x 255.0
```
A 545pt tile in a 170pt column is the reported break, and the cover case coming out right either way is why it only ever showed on titles whose cover was missing. `Color.clear` now takes the proposal and the art rides as its overlay, where it can be drawn but never measured. `LibraryCoverflowView` reuses `PosterImage` directly, so it is fixed by the same change.
The web console (`aspect-[2/3]` + `object-cover`) and Android (`ContentScale.Crop` in a constrained box) were checked against the same case and are immune.
## Verification
* `bun test` 82 pass, `bun run typecheck`, `bun run check` — all clean.
* `swift build` in `clients/apple` — Build complete, `LibraryWidgets.swift` recompiles without warnings.
* The real kit code run over a faithful replica of the .41 cache tree: the table above, zero regressions.
* **End to end on .41**, with the fix hot-applied to the installed kit: library art values went **local 31 → 77, CDN 52 → 6**, and `GET /api/v1/library/art/steam:2483190/portrait` served 200 `image/jpeg` **300×450**. The box was then reverted and re-verified byte-identical to its pre-patch payload — it is not left patched.
## This does not reach a host on merge
Each plugin carries its **own nested** `node_modules/@punktfunk/plugin-kit`, so a kit publish alone changes nothing — patching the hoisted copy on .41 was a no-op while the runner logged a clean reconcile. Same four-repo chain as the `icon` field at 0.4.1:
1. this PR → tag `plugin-kit-v0.4.2`
2. `punktfunk-plugin-steam` → dep `^0.4.2` + version bump → tag `v0.1.2`
3. `punktfunk-plugin-index` → version + integrity + `verification.reviewedAt`
4. hosts
`minHost` is unaffected: nothing on the wire changes shape, only which file on disk the scan finds.
Forza Horizon 6 shows a banner where its cover should be. The cover is on
disk the whole time: `librarycache/2483190/711e39.../library_capsule.jpg`,
300x450, the exact asset Steam itself draws. The scan never looked for that
name, fell back to the flat CDN URL for `library_600x900.jpg`, which 404s for
this appid, and the client then walked its candidate list down to the header —
a 460x215 banner in a 2:3 poster slot.
Measured against a real 779-app `appcache/librarycache` (the .41 box), the scan
was missing far more than one title. Three findings, each independent:
* `library_capsule.jpg` is the newer name for the 2:3 cover. 46 appids carry
only that name; none carry both it and `library_600x900.jpg`.
* `header.jpg` is the newer name for the header. 594 appids carry it, 122
carry `library_header.jpg`, and again no appid carries both — so the one
name we knew covered 16% of them.
* The `<appid>/<name>` layout, with no hash dir in between, is the MAJORITY:
623 of 779 appids. `findLocalArtFile` walked only `<appid>/<hash>/<name>`
and the oldest `<appid>_<name>` form, so it saw none of them.
The renamed files are the same assets — every `library_capsule.jpg` in that
cache measures 300x450, the same as every `library_600x900.jpg`, and both
header spellings measure 460x215 — so this is purely about knowing to look.
Simulating the resolver over that cache, per-appid art found locally:
portrait 25 -> 328 hero 75 -> 323
header 86 -> 716 logo 66 -> 295
None of this was visible before because a title that resolves no local file
still gets a CDN URL, and for anything Valve has not re-hashed that URL works.
It is the newer titles — the ones whose flat CDN URL 404s — that lose their art
outright, which is why this reads as "some games" rather than "the library".
Reported as the library "breaking" on iOS whenever a title showed a banner
instead of a cover: the tile expanded out of its bounds.
`PosterImage` already meant to prevent exactly this — its own comment said a
banner "would otherwise report a much wider intrinsic size than the card and
overflow into neighboring cards" — and the guard it used, a flexible frame plus
`.clipped()`, does not do it. `scaledToFill` answers a proposal with a size
that COVERS it, i.e. larger; `.frame(maxWidth: .infinity, maxHeight: .infinity)`
then clamps that answer to infinity, which is no clamp at all, so the image's
size propagates straight up through `aspectRatio(2/3, .fit)` and decides the
tile.
Measured offscreen with ImageRenderer, one LazyVGrid column at 170pt:
460x215 header, before poster 1750.9 x 818.4 tile 545.6 x 255.0
460x215 header, after poster 170.0 x 255.0 tile 170.0 x 255.0
300x450 cover, either poster 170.0 x 255.0 tile 170.0 x 255.0
A 545pt tile in a 170pt column is what the report is describing, and the cover
case coming out right either way is why this only ever showed on the titles
whose cover was missing.
The fix gives the sizing role to something that has no opinion about it:
`Color.clear` takes the proposal, and the art rides as its overlay, where it
can be drawn but never measured. `LibraryCoverflowView` reuses `PosterImage`
directly, so it is fixed by the same change.
The Steam scanner is `@punktfunk/plugin-steam`, in its own repo, and it builds
with `--external '@punktfunk/*'` — so `findLocalArtFile` is resolved from the
registry at install time, not bundled. Until the kit cuts a version, every host
keeps resolving 0.4.1 and keeps missing the covers.
Same shape as the `icon` field at 0.4.1: kit change, out-of-tree producers, and
nothing in this repo says so. Nothing but the version moves here.
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.
Reported as "Forza Horizon 6 shows a banner instead of a cover, and it breaks the library on iOS". Two independent defects, one in the scanner and one in the Apple client.
1. The art scan knew one filename per kind and one layout
Measured against a real 779-app
appcache/librarycache(the .41 box):library_600x900.jpglibrary_capsule.jpg— 46 appids have ONLY thislibrary_header.jpg(122 appids)header.jpg— 594 appids, i.e. the majority<appid>/<hash>/<name><appid>/<name>— 623 of 779 appids, no hash dirNo appid carries both spellings of one kind, and none carries one kind in both layouts, so these are pure renames rather than variants. Confirmed by measuring the files: every
library_capsule.jpgin that cache is 300×450 and so is everylibrary_600x900.jpg(that name lies); both header spellings are 460×215.Why it read as "some games". A title that resolves no local file still gets the flat CDN URL, and for anything Valve has not re-hashed that URL works. It is the newer titles that lose the art outright — Forza Horizon 6 (appid 2483190) 404s on both
library_600x900.jpgandheader.jpg— and the client then walks portrait → header → hero and draws a banner in a 2:3 poster slot.Running the real resolver over that cache, per-appid art found locally:
with zero regressions — every appid the old resolver found still resolves.
2. A fallback banner sized the tile it was supposed to be cropped into
PosterImagealready meant to prevent this; its own comment said a banner "would otherwise report a much wider intrinsic size than the card and overflow into neighboring cards". The guard it used does not do that.scaledToFillanswers a proposal with a size that COVERS it, and.frame(maxWidth: .infinity, maxHeight: .infinity)clamps that answer to infinity — no clamp at all — so the image's size propagates up throughaspectRatio(2/3, .fit)and decides the tile.Measured offscreen with
ImageRenderer, one LazyVGrid column at 170pt:A 545pt tile in a 170pt column is the reported break, and the cover case coming out right either way is why it only ever showed on titles whose cover was missing.
Color.clearnow takes the proposal and the art rides as its overlay, where it can be drawn but never measured.LibraryCoverflowViewreusesPosterImagedirectly, so it is fixed by the same change.The web console (
aspect-[2/3]+object-cover) and Android (ContentScale.Cropin a constrained box) were checked against the same case and are immune.Verification
bun test82 pass,bun run typecheck,bun run check— all clean.swift buildinclients/apple— Build complete,LibraryWidgets.swiftrecompiles without warnings.GET /api/v1/library/art/steam:2483190/portraitserved 200image/jpeg300×450. The box was then reverted and re-verified byte-identical to its pre-patch payload — it is not left patched.This does not reach a host on merge
Each plugin carries its own nested
node_modules/@punktfunk/plugin-kit, so a kit publish alone changes nothing — patching the hoisted copy on .41 was a no-op while the runner logged a clean reconcile. Same four-repo chain as theiconfield at 0.4.1:plugin-kit-v0.4.2punktfunk-plugin-steam→ dep^0.4.2+ version bump → tagv0.1.2punktfunk-plugin-index→ version + integrity +verification.reviewedAtminHostis unaffected: nothing on the wire changes shape, only which file on disk the scan finds.Forza Horizon 6 shows a banner where its cover should be. The cover is on disk the whole time: `librarycache/2483190/711e39.../library_capsule.jpg`, 300x450, the exact asset Steam itself draws. The scan never looked for that name, fell back to the flat CDN URL for `library_600x900.jpg`, which 404s for this appid, and the client then walked its candidate list down to the header — a 460x215 banner in a 2:3 poster slot. Measured against a real 779-app `appcache/librarycache` (the .41 box), the scan was missing far more than one title. Three findings, each independent: * `library_capsule.jpg` is the newer name for the 2:3 cover. 46 appids carry only that name; none carry both it and `library_600x900.jpg`. * `header.jpg` is the newer name for the header. 594 appids carry it, 122 carry `library_header.jpg`, and again no appid carries both — so the one name we knew covered 16% of them. * The `<appid>/<name>` layout, with no hash dir in between, is the MAJORITY: 623 of 779 appids. `findLocalArtFile` walked only `<appid>/<hash>/<name>` and the oldest `<appid>_<name>` form, so it saw none of them. The renamed files are the same assets — every `library_capsule.jpg` in that cache measures 300x450, the same as every `library_600x900.jpg`, and both header spellings measure 460x215 — so this is purely about knowing to look. Simulating the resolver over that cache, per-appid art found locally: portrait 25 -> 328 hero 75 -> 323 header 86 -> 716 logo 66 -> 295 None of this was visible before because a title that resolves no local file still gets a CDN URL, and for anything Valve has not re-hashed that URL works. It is the newer titles — the ones whose flat CDN URL 404s — that lose their art outright, which is why this reads as "some games" rather than "the library".