feat(store): Apple App Store-style mobile layout#959
Conversation
The Store rendered its desktop two-pane layout (left section sidebar + content grid) on phones, so text wrapped, cards clipped and Get/Preview buttons were cut off. Add a mobile-only presentation switched on useIsMobile(), leaving the desktop render path byte-identical. Mobile layout: - Bottom tab bar (Discover / Apps / Agents / Search / Updates) mapped to the existing nav ids, with safe-area-inset-bottom respected. - Full-width vertical feed: a featured Editor's Choice hero, then horizontal snap-scroll carousels with peek of the next card, plus App Store-style rows (rounded icon, title, subtitle, trailing Get pill). - Sticky header with the section title, a search affordance and a device filter chip; tapping Search opens a full-screen search view. - Device selection folds into a header chip + bottom sheet, replacing the desktop device pill bar. All catalog data, install/uninstall logic, device/backend filters and the model resolver are reused from index.tsx. Theme tokens only (shell-* / accent / dock), dark graphite default. tsc clean, 42 Store tests pass.
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughNew ChangesMobile Store and Icon Infrastructure
Sequence Diagram(s)sequenceDiagram
participant User
participant StoreApp
participant MobileStore
participant TabBar
participant FilterCatalog
participant DeviceSheet
participant GetButton
participant InstallAPI as /api/store/install-v2
User->>StoreApp: load store
StoreApp->>StoreApp: check useIsMobile()
alt isMobile = true
StoreApp->>MobileStore: render with catalog state
else isMobile = false
StoreApp->>StoreApp: render desktop sidebar layout
end
User->>TabBar: tap tab (Discover/Apps/etc.)
TabBar->>MobileStore: setTab(tab), scroll reset
MobileStore->>FilterCatalog: filter apps by tab + devices/backends
FilterCatalog-->>MobileStore: filtered app pool
MobileStore->>User: render Discover sections or SectionView
User->>MobileStore: tap device filter chip
MobileStore->>DeviceSheet: open sheet
DeviceSheet->>User: render device toggle list
User->>DeviceSheet: toggle device, tap Done
DeviceSheet->>MobileStore: updated selectedDevices Set
MobileStore->>FilterCatalog: re-filter with new devices
User->>GetButton: tap "Get"
GetButton->>GetButton: set busy=true
GetButton->>InstallAPI: POST {slug, targets}
InstallAPI-->>GetButton: 200 OK
GetButton->>MobileStore: onInstall(app.id)
GetButton->>GetButton: show "Open" state
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@desktop/src/apps/StoreApp/index.tsx`:
- Around line 1092-1100: The MobileStore component is being passed the
selectedBackends prop which contains persisted backend filters, but the mobile
UI provides no way to change or clear these filters, causing apps to be silently
hidden. Either remove the selectedBackends prop from the MobileStore component
invocation and pass an empty array instead, or add a handler callback (similar
to onDevicesChange for devices) that allows the mobile UI to modify and clear
backend filters. The safest short-term approach is to reset selectedBackends to
an empty array when rendering MobileStore so the filter does not persist on
mobile.
In `@desktop/src/apps/StoreApp/MobileStore.tsx`:
- Around line 101-123: The GetButton component currently always uses the first
install target from the installTargets array instead of respecting the user's
selection. Add a new prop to the GetButton function (such as selectedTarget or
activeTarget) that receives the currently selected install target from the
parent component, and use this prop instead of installTargets[0] when
constructing the fetch request body at the line setting target_remote. Then
update all call sites where GetButton is instantiated to pass the currently
selected target as this new prop, ensuring the user's device selection is
properly propagated through all usage paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: b46cb4cd-9a14-44da-91ea-068b08639f71
📒 Files selected for processing (2)
desktop/src/apps/StoreApp/MobileStore.tsxdesktop/src/apps/StoreApp/index.tsx
| <MobileStore | ||
| apps={apps} | ||
| loading={loading} | ||
| installTargets={installTargets} | ||
| selectedDevices={selectedDevices} | ||
| onDevicesChange={setSelectedDevices} | ||
| selectedBackends={selectedBackends} | ||
| compatMap={compatMap} | ||
| onInstall={handleInstall} |
There was a problem hiding this comment.
Mobile path applies backend filters without any mobile control to change them.
This branch passes persisted selectedBackends into MobileStore, but the mobile UI has no backend picker/clear action. A previously saved backend filter can silently hide apps with no recovery path.
Safe short-term mitigation
<MobileStore
apps={apps}
loading={loading}
installTargets={installTargets}
selectedDevices={selectedDevices}
onDevicesChange={setSelectedDevices}
- selectedBackends={selectedBackends}
+ selectedBackends={[]}
compatMap={compatMap}
onInstall={handleInstall}
/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <MobileStore | |
| apps={apps} | |
| loading={loading} | |
| installTargets={installTargets} | |
| selectedDevices={selectedDevices} | |
| onDevicesChange={setSelectedDevices} | |
| selectedBackends={selectedBackends} | |
| compatMap={compatMap} | |
| onInstall={handleInstall} | |
| <MobileStore | |
| apps={apps} | |
| loading={loading} | |
| installTargets={installTargets} | |
| selectedDevices={selectedDevices} | |
| onDevicesChange={setSelectedDevices} | |
| selectedBackends={[]} | |
| compatMap={compatMap} | |
| onInstall={handleInstall} |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@desktop/src/apps/StoreApp/index.tsx` around lines 1092 - 1100, The
MobileStore component is being passed the selectedBackends prop which contains
persisted backend filters, but the mobile UI provides no way to change or clear
these filters, causing apps to be silently hidden. Either remove the
selectedBackends prop from the MobileStore component invocation and pass an
empty array instead, or add a handler callback (similar to onDevicesChange for
devices) that allows the mobile UI to modify and clear backend filters. The
safest short-term approach is to reset selectedBackends to an empty array when
rendering MobileStore so the filter does not persist on mobile.
| function GetButton({ | ||
| app, onInstall, installTargets, | ||
| }: { | ||
| app: CatalogApp; | ||
| onInstall: (id: string) => void; | ||
| installTargets: InstallTarget[]; | ||
| }) { | ||
| const [busy, setBusy] = useState(false); | ||
|
|
||
| const handleGet = useCallback(async () => { | ||
| if (app.installed || busy) return; | ||
| setBusy(true); | ||
| try { | ||
| const target = installTargets[0]?.name ?? "local"; | ||
| const res = await fetch("/api/store/install-v2", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json", Accept: "application/json" }, | ||
| body: JSON.stringify({ app_id: app.id, target_remote: target }), | ||
| }); | ||
| if (res.ok) onInstall(app.id); | ||
| } catch { /* network blip - leave as Get */ } | ||
| setBusy(false); | ||
| }, [app.id, app.installed, busy, installTargets, onInstall]); |
There was a problem hiding this comment.
Install target selection ignores the device the user picked.
At Line 114, the request always uses installTargets[0], so mobile installs can go to the wrong host even after selecting a device in the sheet.
Proposed fix
function GetButton({
- app, onInstall, installTargets,
+ app, onInstall, installTargets, selectedDevices,
}: {
app: CatalogApp;
onInstall: (id: string) => void;
installTargets: InstallTarget[];
+ selectedDevices: string[];
}) {
@@
- const target = installTargets[0]?.name ?? "local";
+ const target =
+ selectedDevices.length === 1
+ ? selectedDevices[0]
+ : (installTargets[0]?.name ?? "local");
const res = await fetch("/api/store/install-v2", {
@@
- }, [app.id, app.installed, busy, installTargets, onInstall]);
+ }, [app.id, app.installed, busy, installTargets, onInstall, selectedDevices]);- <GetButton app={app} onInstall={onInstall} installTargets={installTargets} />
+ <GetButton app={app} onInstall={onInstall} installTargets={installTargets} selectedDevices={selectedDevices} />(Apply the same prop pass-through for each GetButton usage path.)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@desktop/src/apps/StoreApp/MobileStore.tsx` around lines 101 - 123, The
GetButton component currently always uses the first install target from the
installTargets array instead of respecting the user's selection. Add a new prop
to the GetButton function (such as selectedTarget or activeTarget) that receives
the currently selected install target from the parent component, and use this
prop instead of installTargets[0] when constructing the fetch request body at
the line setting target_remote. Then update all call sites where GetButton is
instantiated to pass the currently selected target as this new prop, ensuring
the user's device selection is properly propagated through all usage paths.
| const compatibleFor = useCallback((pool: CatalogApp[]): CatalogApp[] => { | ||
| const selDevObjs = installTargets.filter((t) => selectedDevices.includes(t.name)); | ||
| const { compatible } = filterCatalog(pool, selDevObjs, selectedBackends); | ||
| return compatible.filter((a) => |
There was a problem hiding this comment.
WARNING: Incompatible apps are hidden without an affordance
compatibleFor discards filterCatalog(...).incompatible and MobileStore never renders an IncompatibleToggle, unlike the desktop grid. Users can see empty or shortened lists with no indication that apps were filtered out by device/backend/model-resolver constraints. Add a mobile incompatible toggle/banner or show filtered-out counts so empty states are explainable.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| onChange(selSet.has(name) ? selected.filter((n) => n !== name) : [...selected, name]); | ||
| }; | ||
| return ( | ||
| <div className="absolute inset-0 z-50 flex flex-col justify-end" role="dialog" aria-modal="true" aria-label="Filter by device"> |
There was a problem hiding this comment.
SUGGESTION: Device sheet lacks dialog focus management
The sheet declares role="dialog" but does not move focus into it, trap Tab focus, return focus to the device chip, or close on Escape. Keyboard and screen-reader users may lose context or tab into background content. Add focus trapping/focus restoration and Escape handling, or use an existing dialog primitive if available.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| // snap-scroll carousels and a full-screen search. Same data and install | ||
| // handlers as desktop; only the presentation changes. The desktop render | ||
| // path below is left untouched. | ||
| if (isMobile) { |
There was a problem hiding this comment.
SUGGESTION: Mobile branch still pays desktop filtering cost
StoreApp computes searchFiltered, navFiltered, filterCatalog, availableBackends, and related effects before the isMobile early return. On mobile renders this does unnecessary work for desktop-only grid state. Move the mobile branch earlier where possible or memoize/split the expensive desktop-only calculations to avoid wasted work on the mobile path.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 5 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICALNo CRITICAL issues found. WARNING
SUGGESTION
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (2 files)
Fix these issues in Kilo Cloud Previous Review Summary (commit 65804ad)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 65804ad)Status: 5 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICALNo CRITICAL issues found. WARNING
SUGGESTION
Other Observations (not in diff)Issues found in unchanged code that cannot receive inline comments:
Files Reviewed (2 files)
Reviewed by nex-n2-pro:free · 883,136 tokens |
Every Store app now shows an intentional icon instead of a blank tile. A new shared AppIcon component (desktop + mobile) resolves the logo through dashboard-icons, tries a name-derived slug, then falls back to a branded monogram: the app initials on a deterministic per-app gradient tuned for the graphite shell. The taOS agent frameworks (OpenClaw, Hermes, IronClaw, MicroClaw, Moltis, Agent Zero, Langroid), which have no upstream logo, now read as clean monograms. Real Discover logos are wired via verified dashboard-icons slugs (comfyui, n8n, ollama, qwen, gemma, github, mcp, hugging-face, openai and the homelab set). The dead /static/store-icons SVG and .jpg references that silently 404'd are gone. A coverFor helper gives featured and carousel cards a distinct cover: an explicit app.cover when set, otherwise a layered gradient from the same hue family as the icon, so the hero reads like an App Store featured card and no card ships flat. AppIcon and coverFor replace the duplicated icon resolvers and inline Package fallbacks across MobileStore and the desktop StoreApp.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
desktop/src/apps/StoreApp/MobileStore.tsx (1)
333-333:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winPotential crash if
descriptionis undefined.If any
CatalogApphas an undefined or nulldescription, calling.toLowerCase()will throw aTypeError, crashing the search functionality.🛡️ Proposed fix with defensive access
return compatibleFor( - apps.filter((a) => a.name.toLowerCase().includes(q) || a.description.toLowerCase().includes(q)), + apps.filter((a) => a.name.toLowerCase().includes(q) || (a.description ?? "").toLowerCase().includes(q)), );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@desktop/src/apps/StoreApp/MobileStore.tsx` at line 333, The filter function in the apps list is calling .toLowerCase() on a.description without checking if it exists first, which will crash if any CatalogApp has an undefined or null description. Add a defensive check using optional chaining or a null coalescing operator to safely handle cases where description is undefined or null. Either use optional chaining like a.description?.toLowerCase().includes(q) or provide a fallback empty string like (a.description ?? "").toLowerCase().includes(q) so the search continues working even when description is missing.
♻️ Duplicate comments (1)
desktop/src/apps/StoreApp/index.tsx (1)
1010-1023:⚠️ Potential issue | 🟠 Major | ⚡ Quick winMobile path receives backend filters without a mobile UI to change them.
Line 1018 passes
selectedBackendstoMobileStore, but the mobile UI (per MobileStore.tsx) provides no backend picker. Users on mobile inherit persisted backend filters with no recovery path to clear or change them, potentially hiding compatible apps.Short-term mitigation
<MobileStore apps={apps} loading={loading} installTargets={installTargets} selectedDevices={selectedDevices} onDevicesChange={setSelectedDevices} - selectedBackends={selectedBackends} + selectedBackends={[]} compatMap={compatMap} onInstall={handleInstall} />🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@desktop/src/apps/StoreApp/index.tsx` around lines 1010 - 1023, The MobileStore component is receiving the selectedBackends prop but lacks any UI controls for users to modify backend filters on mobile, trapping them with persisted filters they cannot change. Remove the selectedBackends prop from the MobileStore component call to prevent mobile users from being locked into backend filters they have no way to adjust, ensuring they can see all compatible apps regardless of previously selected backend filters.
🧹 Nitpick comments (1)
desktop/src/apps/StoreApp/AppIcon.tsx (1)
74-78: 💤 Low valueConsider simplifying the redundant null-coalescing.
Line 76 checks
if (APP_ICONS[app.id])then returnsAPP_ICONS[app.id] ?? null. Since all APP_ICONS values are strings, the if-check and ?? null are redundant.♻️ Simpler alternative
function primaryIconUrl(app: CatalogApp): string | null { if (app.iconSlug) return di(app.iconSlug); - if (APP_ICONS[app.id]) return APP_ICONS[app.id] ?? null; - return familyIcon(app.id); + return APP_ICONS[app.id] ?? familyIcon(app.id); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@desktop/src/apps/StoreApp/AppIcon.tsx` around lines 74 - 78, The primaryIconUrl function contains a redundant null-coalescing pattern on line 76. The if-check already ensures APP_ICONS[app.id] is truthy, making the subsequent ?? null operator redundant. Simplify the return statement in the APP_ICONS condition to just return APP_ICONS[app.id] directly without the null-coalescing operator, since the truthiness check already guarantees a non-null value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@desktop/src/apps/StoreApp/MobileStore.tsx`:
- Line 333: The filter function in the apps list is calling .toLowerCase() on
a.description without checking if it exists first, which will crash if any
CatalogApp has an undefined or null description. Add a defensive check using
optional chaining or a null coalescing operator to safely handle cases where
description is undefined or null. Either use optional chaining like
a.description?.toLowerCase().includes(q) or provide a fallback empty string like
(a.description ?? "").toLowerCase().includes(q) so the search continues working
even when description is missing.
---
Duplicate comments:
In `@desktop/src/apps/StoreApp/index.tsx`:
- Around line 1010-1023: The MobileStore component is receiving the
selectedBackends prop but lacks any UI controls for users to modify backend
filters on mobile, trapping them with persisted filters they cannot change.
Remove the selectedBackends prop from the MobileStore component call to prevent
mobile users from being locked into backend filters they have no way to adjust,
ensuring they can see all compatible apps regardless of previously selected
backend filters.
---
Nitpick comments:
In `@desktop/src/apps/StoreApp/AppIcon.tsx`:
- Around line 74-78: The primaryIconUrl function contains a redundant
null-coalescing pattern on line 76. The if-check already ensures
APP_ICONS[app.id] is truthy, making the subsequent ?? null operator redundant.
Simplify the return statement in the APP_ICONS condition to just return
APP_ICONS[app.id] directly without the null-coalescing operator, since the
truthiness check already guarantees a non-null value.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 047eab20-cca6-40bb-b316-5889155a0466
📒 Files selected for processing (3)
desktop/src/apps/StoreApp/AppIcon.tsxdesktop/src/apps/StoreApp/MobileStore.tsxdesktop/src/apps/StoreApp/index.tsx
Official screenshots/hero art for the real apps (comfyui, n8n, home-assistant, immich, jellyfin, sonarr, radarr, uptime-kuma, vaultwarden, code-server, nextcloud) saved as optimized webp under public/store-covers and shown behind the featured + carousel cards via a new coverImage field (falls back to the gradient when absent). taOS-specific frameworks keep the branded monogram cover.
| method: "POST", | ||
| headers: { "Content-Type": "application/json", Accept: "application/json" }, | ||
| body: JSON.stringify({ app_id: app.id, target_remote: target }), | ||
| }); |
There was a problem hiding this comment.
WARNING: Mobile installs ignore the selected device target
GetButton always posts target_remote using installTargets[0], even though the mobile device sheet lets users select a target device and StoreApp passes selectedDevices into MobileStore. With multiple install targets, an install can be sent to the wrong host after the user changes the device selection.
Derive the target from selectedDevices (for example selectedDevices[0] ?? installTargets[0]?.name ?? "local") and thread that value through each GetButton call site.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Official banners (no upstream dashboard-icons logo for these taOS frameworks), converted to webp and keyed by id in COVER_BY_ID so they resolve for the curated and backend-sourced rows alike.
Add Ollama banner and a shared Stable Diffusion banner across both WebUI cards. The AUTOMATIC1111 build (sd-webui) uses a grayscale cut of the same banner so the two Stable Diffusion entries read as distinct. Covers resolve via COVER_BY_ID by app id for backend-sourced catalog rows.
… SD banners wired), Pi on b4b2af7
| // Both Stable Diffusion WebUI cards share one banner; the AUTOMATIC1111 | ||
| // build (sd-webui) gets a grayscale cut so the two read as distinct. | ||
| "stable-diffusion-webui": { coverImage: "/desktop/store-covers/stable-diffusion.webp" }, | ||
| "sd-webui": { coverImage: "/desktop/store-covers/stable-diffusion-bw.webp" }, |
There was a problem hiding this comment.
WARNING: Stable Diffusion fallback cover keys do not match catalog IDs
app-catalog/catalog.yaml:626 has stable-diffusion-webui and stable-diffusion-cpp, but this map uses stable-diffusion-webui and sd-webui. As a result, stable-diffusion-cpp gets no fallback cover and the sd-webui entry appears unused. Confirm the intended variant mapping and key the fallbacks against the actual catalog IDs.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
- Installed apps no longer render a dead Open span: show an honest role=status Installed indicator (store services are managed, not launched from the storefront), removing the misleading affordance. - Surface install failures: a non-ok or failed request now flips the pill to a Retry state instead of silently swallowing the error. - AppIcon resets its resolution stage when the candidate URL set changes, so a reused instance does not carry a stale error stage to a new app. - StoreCover clears its failed flag when coverImage changes, so a reused instance retries the new app's cover. - Add MobileStore tests covering Get/Installed/Retry states and the AppIcon/StoreCover instance-reuse resets (47 Store tests pass).
Code Review ✅ Approved 5 resolved / 5 findingsImplements an Apple App Store-style mobile layout for the store, resolving issues with action buttons, error handling, component re-usability, and testing. No open findings remain. ✅ 5 resolved✅ Bug: Installed apps have no working action on mobile (Open is a dead span)
✅ Quality: Install failures are silently swallowed with no user feedback
✅ Quality: New 704-line MobileStore component ships without tests
✅ Edge Case: AppIcon stage not reset when app prop changes on reused instance
✅ Edge Case: StoreCover failed state not reset when app prop changes
OptionsAuto-apply is off → Gitar will not commit updates to this branch. Comment with these commands to change:
Was this helpful? React with 👍 / 👎 | Gitar |
…rs, gitar #959 folded), master=fa4bd377
What
The Store app rendered its desktop two-pane layout (left section sidebar + content grid) on phones, so text wrapped, cards clipped, and the Get/Preview buttons were cut off. This adds a mobile-only presentation switched on
useIsMobile()that reads like the Apple App Store. The desktop render path is left byte-identical (the diff toindex.tsxis purely additive: two imports, the hook call, and an earlyisMobilebranch).Mobile layout
safe-area-inset-bottomrespected.All catalog data, install/uninstall logic, device/backend filters, and the model resolver are reused from
index.tsx. Theme tokens only (shell-*/accent/dock), dark graphite default. Mobile components' language (44px bars, frosted dock chrome,active:touch states, safe areas) matchescomponents/mobile/.Verification
npx tsc --noEmit -p tsconfig.jsonclean.vitest run src/apps/StoreApp).npm run buildpasses..design/research/mobile-store/(gitignored).No em dashes. Desktop layout unchanged.
Summary by CodeRabbit
coverImagesupport with graceful fallbacks.