Documents Carousel — Feed & Adapter Requirements

Updated: 2026-08-26 09:55 ET · Feature: AB#2580 "Document Module" (Epic AB#1821 UI Refresh / dynamic-feed AB#2133) · Prepared: 2026-08-25 · Prepared by: Claude Code, with Jose Castro

Draft — for review, not yet actioned. This is a first pass assembled from AB#2580's attached sample payload, the live getdocuments endpoint (tested against a real UAL account), the existing dynamic-feed contract, and the backend API mapping report's data-sanitation findings. Open questions are called out explicitly in Open Questions — this is the starting point for the conversation, not a spec ready to build from.

Build-readiness review (2026-08-25): §5's plan was checked line-by-line against the current code. Two of five steps had a gap that would have shipped a visible or silent defect if built as first drafted — a missing favorite Id field (step 2) and a duplicate section header (step 4) — both are now corrected in place with exact file:line citations and a stated fix. §6's six open questions are still genuinely open; everything else in §5 is confirmed against the current codebase, not assumed.

TLDR

1. Context — AB#2580

AB#2580 "Document Module" (Task, State: New, assigned Alejandro Acevedo) reads:

"The attached payload will be loaded in the background to cache the data. Then, using this data structure from the cache, the Document Module will implement data helping UI to be rendered. What is the endpoint this JSON payload will be retrieved from? Is the endpoint live or must it be mocked for now?"

All three parts of the description are already answered by the existing codebase — the two explicit questions, and the background-caching behavior the paragraph opens with:

QuestionAnswer
Endpoint{BaseAPIUrlV2}/api/doc/getdocumentsRestService.GetDocumentListAsync (ALPAMobile/Services/RestService.cs:1581-1586); prod host gateway.alpa.org/v2, QA gatewayapiqa.alpa.org/v2.
Live or mock?Both, auto-selected. DocumentsQueriesRouter (ALPAMobile/Services/ScaffoldDataSourceRouters.cs:208-300) routes to the live client when auth.IsLoggedIn, else MockDocumentsApiService. No new wiring needed — the Document Module inherits this automatically by depending on IScaffoldDocumentsQueries.
"Loaded in the background to cache the data... rendered from the cache"Already built and already running. DataManager.GetDocumentListAsync (ALPAMobile/Services/DataManager.cs:1136-1260) is local-store-first: it reads the prior response back from _cacheDatabase.GetCacheJSON(nameof(GetDocumentListAsync)), returns it immediately, and fires an unawaited RefreshDocumentListInBackgroundAsync() behind it — the exact "render from cache, refresh in the background" shape the description asks for. It's wired into app launch today via CacheContentService.FetchContentAuthorized (CacheContentService.cs:203). See §2.4 for the one adjacent system this is not.

AB#2580's attached sample (samplegetdocs.txt) is a raw capture of that live response: a JSON array of 20 scope nodes, 759 documents. Structure below.

2. What already exists (reuse, don't rebuild)

2.1 The MEC/LEC hub stack — where this work actually lands

PieceFileRole
DocumentsListPage.razorALPAMobile.Presentation/Components/Pages/DocumentsListPage.razor(.cs)The routable page@page "/documents", reading Scope/Category/Grouping/Sort/List from the query string. Renders <DocumentsHub Scope="@Scope" /> when DocumentScopeBanner.UsesHub(Scope) is true (MEC/LEC). Stays exactly as-is — this work adds a section inside the hub, not a new route or page.
DocumentsHub.razorALPAMobile.Presentation/Components/Library/DocumentsHub.razorThe orchestrator. Composes three <DocumentsBrowser> sections today for a scope (MEC/LEC): the top ungrouped category grid, one section per grouping, and "Latest Documents". A carousel section is a fourth composition call here — no new parent component needed.
DocumentsBrowser.razor / .razor.csALPAMobile.Presentation/Components/Library/DocumentsBrowser.razor(.cs)The adapter/loader. Already fetches via IScaffoldDocumentsQueries, filters by Scope/Category/Grouping (DocumentsBrowserLogic.FilterDocuments), sorts (DocumentSort.Apply), and caps to MaxItems — the exact pipeline a carousel section needs. Currently maps the result to CardIconViewModel/CardSmallViewModel/DocumentRowViewModel depending on Presentation; needs one more mapping arm for hero cards (§5).
DocumentsBrowserMode / DocumentsBrowserPresentationALPAMobile.Presentation/Components/DocumentsBrowserTypes.cs:9-31The mode/presentation enums DocumentsBrowser.ResolvePresentation validates against. DocumentList mode currently allows DatedRows (default) and Tiles — a new Carousel value slots in alongside them.
DocumentHeroFactoryALPAMobile.Presentation/Components/DocumentHeroFactory.csThe per-item adapter this work actually needs: a pure mapper, DocumentItem → CardHeroViewModel, including the mandatory /document-open?fileId= link (never a raw Path href — a raw relative path previously killed the WebView session). Currently only called from FavoritesViewModelDocumentsBrowser would become its second caller, joining the already-injected CardIconFactory/CategoryCardFactory/DocumentRowFactory (DocumentsBrowser.razor.cs:68-72).
DocumentsBrowserLogic.ResolveIconALPAMobile.Presentation/Components/DocumentsBrowserLogic.cs:69-70Existing image/icon fallback chain (API icon URL → title-based mask icon → generic) used by the MEC hub's CardIcon/CardSmall rows today. Named alternative to CardHero's own fallback for the new carousel cards — see §4/§6.
CardHero.EffectiveImageALPAMobile.Presentation/Components/Library/CardHero.razor.cs:36-48Already ships a missing-image fallback for HeroCard specifically: when Vm.Image is null/empty (or the real URL 404s/auth-blocks — the code comment names the live document feed explicitly), the card substitutes one of two Figma-mock placeholder photos (cockpit.jpg / pilots.jpg), chosen deterministically by title length so a feed of fallbacks doesn't repeat one image. The media region (.alpa-cardlg-media) always renders — there is currently no "hide the image region" variant. See §4/§6.
CarouselViewModel / Carousel.razorALPAMobile.Presentation/ViewModels/CarouselViewModel.cs, .../Components/Library/Carousel.razorThe rendering target: horizontal scroll track, a documents-aware empty state already built in ("Off the Radar", single card, FilterDescription shown as "scope › category"), and SourceUnavailable distinguishing "no documents" from "could not load documents". The new DocumentsBrowser carousel branch wraps its mapped items in a CarouselViewModel purely to reuse this component's rendering — see §5 for the wrapping question.

Routing stays as-is (2026-08-25, Jose): DocumentsListPage remains a standalone, directly-routable page — reached via /documents?scope=MEC (etc.) from a menu-item route, the same pattern the legacy native app used (a Shell route bound to a menu item, not a page nested inside another page). This carousel work adds a section inside the hub the page already renders; it does not restructure routing or collapse the page/hub/browser layering into one component.

The production mechanism for that menu-item route already exists: TopNav's member-facing drawer rows are driven by backend MenuItem records whose SpecialCode is exactly "blazor-hybrid", with MenuItem.Link pointing at a Blazor route. A production MEC-documents menu item would set that link to /documents?scope=MEC and appear as a normal (non-Dev-tagged) row. The two topnav.drawerDocHubMec9 / topnav.drawerDocHubAlpa10 buttons currently in the drawer (TopNav.razor:159-172, tagged Dev, comment: "TEMPORARY DEMO AID... delete this button to revert") are a stand-in for that real menu-item route, not the intended long-term entry point — worth retiring once a real MenuItem record covers it.

A MenuItem whose real content is document-backed must route into the hub, not around it — this has already happened once, live. A prior capture (docs/component-specifications/mec/mec-customization-mock-guide.html:203-207) guessed CLE172's "Roster" button would be a plain MenuItem route (path: "LECs/CLE172/Roster"). A live-navigated capture against a real UAL account contradicted that guess: the app's actual "MEC Documents → Roster" entry navigates to DocumentsListPage?Scope=MEC&Category=Roster&Grouping=MEC%20Documents — i.e. it is DocumentItem content rendered through DocumentsHub (§2.1's stack), not an independent MenuItem destination outside it. Any MenuItem whose target is really document content needs a Path shaped like /documents?scope=...&category=...&grouping=... to render correctly through this same DocumentsListPageDocumentsHubDocumentsBrowser pipeline — a MenuItem pointing anywhere else would bypass the Carousel presentation (and every other presentation) this document specifies. See Open Questions #7.

2.2 The ALPA/"ALP MAG" carousel — a different surface, not touched by this work

PieceFileRole
MockHomePreviewFeedServiceALPAMobile/Services/MockHomePreviewFeedService.csBuilds a Carousel from live GetDocuments data hardcoded to scope="ALPA" + category="ALP MAG", for the separate /home page — reached through HomePreviewPage.razor (@page "/home"; this is the app's actual boot route, BlazorHostPage.BootRoute, confirmed live 2026-08-25 — "home-preview" is descriptive language the codebase itself uses, e.g. TabBarSeed.cs's comments, not a literal second route), never through DocumentsListPage/DocumentsHub. Confirmed out of scope for AB#2580 (§7): the target is MEC/LEC, not this surface.

Note (2026-08-25, Jose): the /home ALP MAG surface is a client-side mock standing in for the real thing — the intended end state is that it, too, gets orchestrated through the dynamic feed (a real PageDto from the AB#2133 backend endpoint, contract §9's still-open "Endpoint URL + auth" item) rather than staying a client adapter over GetDocuments forever. That replacement is a separate, later effort — it doesn't change anything about the MEC/LEC hub carousel work in this document, since that work never routes through PageDto/ContainerDto/ItemDto in the first place (§5).
Dynamic-feed contractdocs/component-specifications/dynamic-feed/dynamic-feed-api-contract.htmlStatus: "Working Design — endpoint not yet built (AB#2133)". Defines the PageDto → ContainerDto → ItemDto shape MockHomePreviewFeedService builds for the ALP MAG carousel. Not the shape this work needs — the MEC/LEC hub carousel is built directly as a CarouselViewModel inside DocumentsBrowser, with no PageDto/ItemDto layer involved.

What this means for AB#2580: the Carousel UI is done, the per-item adapter (DocumentHeroFactory) is done. The work is wiring a new DocumentsBrowserPresentation.Carousel arm into the existing MEC/LEC hub stack — not generalizing MockHomePreviewFeedService, and not touching the dynamic-feed PageDto/ContainerDto/ItemDto contract at all.

2.3 A third, unrelated system solves an adjacent problem — mobile-content-api's template field (not used here)

Context, not a build input. Flagged so this doesn't get rediscovered mid-implementation and mistaken for a second way to do this work — it isn't. This system is unconnected to the MEC/LEC hub stack in §2.1 and to the dynamic-feed PageDto/ItemDto system in §2.2.

A third content-API doc set exists in this repo — docs/mobile-content-api/ (Sites → Pages → Containers → Items) — separate from both the MEC/LEC hub stack (§2.1) and the dynamic-feed PageDto/ContainerDto/ItemDto system (§2.2). It documents a free-form template field that solves a conceptually adjacent problem — binding a UI slot to filtered DocumentItem content — with a completely different mechanism:

FieldTypeSourceBehavior
ItemDto.templatestring?docs/mobile-content-api/dynamic-feed.html:144"Item-type-specific layout or data payload. Raw JSON string; parsed and applied by the mobile client based on itemType."
AdminItemRequest.templatestring?, optionaldocs/mobile-content-api/admin-portal.html:234"Free-form JSON string carrying item-type-specific layout or data payload. The API stores and returns it as-is — no server-side schema validation is applied. The mobile client is responsible for parsing this field based on itemType."

A real captured instance (Carousel container → List item, UAL Communications feed) shows the shape a caller puts inside that unschema'd string — this specific inner shape is not documented anywhere in this repo; it's one observed instance, not a contract:

{
  "renderType": "CardText",
  "variant": null,
  "contentType": "DocumentItem",
  "parentFilterType": "MEC",
  "parentFilterValue": "ual",
  "filterType": "Category",
  "filterValue": "Communications",
  "scope": null,
  "category": null,
  "grouping": null,
  "searchFilter": "by:date-direction:desc-top:3",
  "action": null
}

A repo-wide grep for renderType, filterType, filterValue, and searchFilter as literal JSON keys returns hits only against this one captured payload — no DAL or FDX equivalent exists anywhere in docs or source, and neither does a full page-level response combining multiple such items. None of this generalizes to AB#2580: the MEC/LEC hub carousel this document specifies is built directly against IScaffoldDocumentsQueries/DocumentItem (§2.1), never through an ItemDto.template string.

2.4 The background caching AB#2580 asks about is already built — and it is not the PrefetchCount download cache

Two different things are both called "document cache" in this repo. Flagged so the wrong one doesn't get reused or re-explained mid-implementation.
MechanismFileCaches
DataManager.GetDocumentListAsyncALPAMobile/Services/DataManager.cs:1136-1260This is the one AB#2580 describes. The metadata payload — the whole getdocuments JSON response (§3's shape) — via _cacheDatabase.SetCache(nameof(GetDocumentListAsync), ...). A non-refresh call reads the cached copy first (ReadCachedDocumentList) and returns it immediately, then fires RefreshDocumentListInBackgroundAsync() unawaited to refetch and re-cache behind it; failures there are swallowed on purpose (the member is already looking at the cached list). A first-ever authenticated call, with no cache yet, is the one case that waits on the network (StartOrJoinDocumentListFetchAsync, single-flight). Called at app launch via CacheContentService.FetchContentAuthorized (CacheContentService.cs:203) and again whenever a page calls GetDocumentListAsync(true) (e.g. DocumentsListPageViewModel.cs:62). The MEC/LEC hub carousel in §5 needs no new caching work — it rides this path automatically through IScaffoldDocumentsQueries, the same as every other DocumentsBrowser presentation today.
PrefetchCount / IDocumentCacheQueriesdocs/component-specifications/design-gaps/document-cache-design-gap.html (AB#2578, originating feature AB#2576)A different, adjacent concern: auto-downloading the top N individual document files in a category on-device (via CategoryItem.PrefetchCount) so a member can open them offline, silently, with no UI indicator today — that design gap is about surfacing that silent behavior, not about caching the list payload. Unrelated to the metadata cache above and unrelated to this document's carousel work; not touched by §5.

2.5 Decision (2026-08-26, Jose) — CardHero refactors to auto-hide on no image; three of the RCL's four image-bearing components already do this

Resolves Open Questions #1 and #2 (§6). Researched, not assumed: every RCL (ALPAMobile.Presentation/Components/Library) component that ever renders an image/ImageUrl property was read directly. CardHero is the only one of the four that reserves the image slot and fills it with stand-in art — everyone else collapses the slot instead.
ComponentFileBehavior on no image
CardTextCardText.razor:242-247@@if (Vm.Image != null) — the image <div> doesn't render at all when there's no image; no placeholder, no reserved space.
PromoBannerPromoBanner.razor:309-311@@if (!string.IsNullOrEmpty(Vm.ImageUrl)) — same no-placeholder pattern.
CardSmallCardSmall.razor:115-134, CardSmall.razor.cs (HasVisual)The most complete precedent. HasVisual gates the whole .alpa-cardsm-img slot — true only when there's a usable Vm.Image (and it hasn't 404'd) or an IconMaskUrl; false collapses the slot entirely and adds an .alpa-cardsm-content--noimg modifier class (alpa-components.css:7916) that restores the left inset the image slot used to provide. The code comment states the exact reason a hide-not-placeholder treatment was chosen: an unconditionally-rendered slot left a colored rectangle behind a 404'd MenuItem.ImageSource, and testers read that as "still loading" rather than "no image" — 13 real instances across KCM home, Member Resources, and Internal Comms drove the fix.
CardHeroCardHero.razor:9-11, CardHero.razor.cs:78-88 (EffectiveImage)The outlier. EffectiveImage unconditionally substitutes one of two stock placeholder photos (cockpit.jpg/pilots.jpg) whenever Vm.Image is empty or the real URL fails to load (@@onerror sets _imageFailed) — the .alpa-cardlg-media slot always renders, never collapses.

Decision: CardHero moves to the CardText/CardSmall/PromoBanner pattern — no image in the payload, or a real URL that 404s/auth-blocks, hides .alpa-cardlg-media entirely rather than substituting placeholder art. This directly answers Open Questions #1 and #2 (§6): no backend fix is required for the image gap, and the client's standing answer to "no image" is auto-hide, not CardHero's current placeholder or DocumentsBrowserLogic.ResolveIcon's icon treatment (§6 #2's other two options).

What the refactor actually touches:

3. Sample payload structure (AB#2580 attachment, samplegetdocs.txt)

Three-level array, matching the live getdocuments response shape:

[
  {
    "scope": "ALPA",
    "categories": [
      {
        "category": "Canada Limits",
        "description": "Canada Limits",
        "documents": [
          {
            "title": "Canadian Limits: 2026 & 2025",
            "docType": "pdf",
            "path": "~/media/968EF181A0EB47F99A27F49ACCB5A1CE.ashx?modified=...",
            "image": null,
            "featuredImage": null,
            "featuredImageLink": null,
            "featured": false,
            "description": null,
            "publishDate": "2026-03-18T13:15:00-04:00",
            "source": "sitecore",
            "fileID": "{968EF181-A0EB-47F9-9A27-F49ACCB5A1CE}",
            "portalID": -1
          }
        ]
      }
    ]
  }
]

Sample composition: 20 scope nodes / 759 documents total. docType ∈ {pdf, html, htm, webpage}; source ∈ {DNN, sitecore}. Maps onto the existing domain model 1:1 — ScopeItem → CategoryItem → DocumentItem (ALPAMobile.Application/Abstractions/Documents, ALPAMobile.Domain/Data/Models/CategoryItem.cs) — via the standard IDocumentsQueries deserialization path already in place; no new raw-DTO mapping is needed at this level.

4. Known data-quality gaps in the live feed

These were first captured 2026-07-10 in backend-api-mapping-report.html Part 3 ("GetDocuments metadata alignment", sampled 383 documents / 20 scope / 20 category nodes). I re-checked every one against the AB#2580 sample (759 documents, captured 2026-08-24) and against a live logged-in account (UAL test member, 2026-08-25) — all are still present, six weeks later. A recommended target shape for the gap that actually blocks this work is in §8 — as of the 2026-08-26 decision in §2.5/§6, only the duplicate-scope-nodes gap still does; the image gap is resolved client-side.

Gap2026-07-10 finding2026-08-25 re-checkImpact on the carousel
No document imagery image: null on 42/42 sampled (later 383/383 in the full report) image, featuredImage, featuredImageLink all null on 759/759 documents in the AB#2580 sample; featured: true on 0/759 HeroCard is image-forward (contract §5: Title/Description/Eyebrow/Image/Link). Decided (2026-08-26, §2.5): CardHero moves from today's stock-placeholder fallback (CardHero.razor.cs's EffectiveImage) to auto-hiding .alpa-cardlg-media when Vm.Image is empty, matching CardText/CardSmall/PromoBanner. No backend fix needed for this to ship correctly — see Open Questions #1/#2.
Duplicate scope nodes 20 nodes for ~3 logical scopes (ALPA ×6, MEC ×11, LEC ×2); identified as the root cause of BUG-1995 AB#2580 sample: ALPA ×6, MEC ×9, LEC ×4, KCM ×1 (20 nodes, 4 logical scopes). Live-account test (UAL member #1216266): the client's dedup-by-name continue logic processed zero ALPA-scope categories from the live response (MEC: 120 categories, LEC: 9, KCM: 3, across the full 1195-document bulk load) — the account's ALPA feed rendered empty ("No Documents") instead of whatever real content sits behind the duplicate nodes. Live confirmation that BUG-1995 is not just a theoretical defect — it emptied a real scope for a real logged-in account during this session.

Re-validated live 2026-08-26 — does not reproduce. Two independent checks against the current imp/blazor-hybrid tip: (1) a static replay of the current composite-key dedup algorithm (DataManager.cs:1298-1354, CategoryItem.CompositeId = Grouping-Scope-Category, DocumentItem.CompositeId adds -FileID) against the exact AB#2580 sample found zero categories or documents dropped despite the identical ×6/×9/×4 duplicate-node shape; (2) a live login on a real production UAL account (member 2239644) found ALPA rendering 6 real categories, and MEC/LEC fully populated — not empty. One transient backend 503 on getdocuments was hit mid-test (surfaced as Blazor's generic error banner, self-recovered ~15s later on retry) — the likely actual cause of the original finding, not a dedup regression. The composite-key fix (34a7a7af, AB#2208, 2026-07-06) holds; see §6 #3.
Sparse descriptions not separately quantified 271/759 (36%) have a non-null description HeroCard's Description falls back through Description ?? Blurb at the item-factory level (contract aliasing rule), but there is no further fallback below that — 64% of cards would render title + image only.
Inconsistent friendly-name field "1/20 (ALP MAG): title echoes the key and the friendly name appears only in description, as a sentence ('ALPA Magazine.')" Not independently re-sampled at the category level in this pass Carousel/category titles resolve from CategoryItem.DisplayTitle (already handles "API title-if-distinct, else description" per MockHomePreviewFeedService.cs:60-62) — existing logic likely already absorbs this specific case, worth confirming rather than re-deriving.
docType vocabulary drift htm/html/pdf/webpage co-exist Confirmed identical set in the AB#2580 sample: {pdf, html, htm, webpage} Affects document-open routing (DocumentOpenPage switches on docType), not the carousel card itself directly — flagged here for awareness since the same feed drives both.
path has 4 conventions / fileName never populated per backend-api-mapping-report.html Not independently re-checked this pass Not carousel-card-relevant (the card never uses path directly — it opens via fileID through /document-open), but relevant to the Document Module's caching/download layer generally. Carrying forward from the existing report rather than re-deriving.

5. Adapter requirements — the MEC/LEC hub carousel

Decided direction (2026-08-25): extend the existing DocumentsHub/DocumentsBrowser stack rather than add a new orchestrating component. Concretely, five changes, all localized to the files already in §2.1. The plan below was reviewed against the current code on 2026-08-25 and three of the five steps needed correction — flagged inline as Verified / Gap found — corrected below so the corrections aren't mistaken for the original framing.

  1. New presentation value. Verified DocumentsBrowserTypes.cs:21-31 — add Carousel to DocumentsBrowserPresentation. DocumentsBrowserLogic.cs:17-21's Allowed dictionary currently reads [DocumentsBrowserMode.DocumentList] = [DatedRows, Tiles] — needs Carousel added to that array (the Defaults dictionary at :24-28 stays DatedRows as the default; Carousel is opt-in via the Presentation parameter, matching how Tiles already works). Without this, ResolvePresentation throws by design (:36-39) — confirmed this is the actual current guard, not a hypothetical one.
  2. Map through DocumentHeroFactory, not a new mapper — but fix a missing field first. Gap found — corrected below DocumentsBrowser.razor.cs's LoadDocumentsAsync() (:144-164) already produces the exact List<DocumentItem> a carousel needs — fetched, filtered, sorted, capped to MaxItems, the same pipeline DatedRows uses. The new branch maps that same ordered list through DocumentHeroFactory.Create(doc, doc.FileID) into a sibling field (e.g. _heroCards), with DocumentHeroFactory injected alongside the existing CardIconFactory/CategoryCardFactory/DocumentRowFactory (:68-72).

    The gap: DocumentHeroFactory.Create (DocumentHeroFactory.cs:35-42) does not set ComponentViewModel.Id — confirmed by reading it directly: it sets FavoriteItemTypeId, Title, Description, Image, Link, and nothing else. Its only current caller, FavoritesViewModel.HydrateAsync (FavoritesViewModel.cs:113), never needs .Id either, because Favorites rows use their own remove affordance, not the generic heart toggle. But DocumentsBrowser's other two per-document factories both set it explicitly — DocumentRowFactory.Create sets Id = document.FileID (DocumentRowFactory.cs) and CardIconFactory.Create sets Id = CategoryCardFactory.RawId(...) — because ComponentView.CanFavorite (ComponentView.razor.cs:40-45) requires !string.IsNullOrEmpty(vm.Id) before a heart renders at all. Left as-is, every hero card in the new carousel would silently show no favorite heart — not a crash, not a visible error, just a missing affordance a tester would have to notice by its absence.

    Fix: set Id = fileId inside DocumentHeroFactory.Create itself (it already receives fileId as a parameter) rather than patching it in at each call site — a one-line, backward-compatible addition that also closes the gap for FavoritesViewModel's existing call, and matches the sibling factories' convention.
  3. IsEmpty gets a new arm. Verified DocumentsBrowser.razor.cs:86-91's switch needs DocumentsBrowserPresentation.Carousel => _heroCards.Count == 0 — confirmed the existing switch's default arm (_ => _documentRows.Count == 0) would otherwise silently catch an unhandled Carousel value and check the wrong (always-empty) collection.
  4. Render by wrapping into CarouselViewModel — but DocumentsBrowser's own header/empty-state need a guard, or the section double-renders. Gap found — corrected below The recommended approach (§6 #4) is still to build new CarouselViewModel { Title = ..., Items = [.. _heroCards] } and render <Carousel Vm="..." /> inside the new switch case, reusing Carousel.razor unchanged.

    The gap: read against the current file, DocumentsBrowser.razor renders its own section header from SectionTitle/ViewAllLink (:22-32, alpa-docbrowser-head) and its own empty-state gate (:34-37, generic <EmptyState>) before the presentation switch even runs (:40) — neither is presentation-aware today. Carousel.razor unconditionally renders its own header (.alpa-feed-header, no guard around the outer <div> — only the "View All" anchor inside it is conditional). Adding Carousel as a plain new case in the switch, as originally drafted, would render two headers stacked on top of each other showing the same title and View All link — DocumentsBrowser's alpa-docbrowser-head and Carousel.razor's alpa-feed-header — and would also mean Carousel.razor's own "Off the Radar" empty treatment is unreachable, since DocumentsBrowser's generic <EmptyState> gate (:34-37) always wins first.

    Fix: two small, localized changes — (a) guard DocumentsBrowser.razor:22's header condition with && _presentation != DocumentsBrowserPresentation.Carousel, so Carousel.razor is the sole header owner for that presentation; (b) pass SuppressEmptyState="true" on the new hub composition call (matching the hub's existing convention for its other three sections, §5 step 5) so an empty carousel section collapses entirely via the existing :12-17 gate, rather than needing to resolve which of the two components' empty-state treatments should win. This sidesteps needing Carousel.razor's "Off the Radar" card at all for this specific use — it stays reachable in principle but isn't exercised via DocumentsHub.
  5. One new composition call in the hub. Verified DocumentsHub.razor gets a fourth <DocumentsBrowser Mode="DocumentsBrowserMode.DocumentList" Presentation="DocumentsBrowserPresentation.Carousel" SuppressEmptyState="true" .../>, same shape as its existing "Latest Documents" call (:33-39), with SuppressEmptyState="true" added per the fix above. Placement in the hub's section order (top, alongside "Latest Documents", or replacing it) and whether it needs its own Category/Grouping filter are still open — see §6 #5/#6.

Confirmed working with no changes needed: favorite-heart wiring (as opposed to the .Id gap above) — ComponentView's cascading parameter is typed FavoriteToggleContext? (ComponentView.razor.cs:26), the identical type DocumentsHub.razor already supplies via <CascadingValue Value="_favoriteContext"> wrapping all its <DocumentsBrowser> children. Neither Carousel.razor nor ComponentView redeclare that cascading type, so the same context reaches hero cards inside a nested <Carousel> automatically — once the .Id gap above is fixed, no additional favorite plumbing is needed. Also unchanged: the fetch/filter/sort pipeline, SourceUnavailable/FilterDescription semantics, and the mandatory /document-open link policy (already inside DocumentHeroFactory).

Test impact: UnitTest/DocumentsBrowserLogicTests.cs exercises Allowed/Defaults/ResolvePresentation today and will need a new case asserting Carousel is valid for DocumentList (and invalid for CategoryGrid, matching the existing throw-on-invalid-pair test shape). Not independently checked for a DocumentsBrowser.razor.cs-level render test that might assert exhaustively on _presentation — worth a quick grep before implementation.

6. Open questions

#QuestionStatus
1Backend fix for zero document images, or accept the client-side fallback as the standing behavior? (Raised 2026-07-09, still unresolved 2026-08-25 — six weeks with no image field populated on any sampled document.)Decided 2026-08-26 — no backend fix required. CardHero auto-hides the image area instead (§2.5); real cover art from a future backend fix would still be an improvement, but nothing here blocks on it. §8.1/§8.2's shape proposal for this gap is now nice-to-have, not required (§8.1).
2The client-side missing-image fallback already exists (CardHero.EffectiveImage, stock placeholder photo). Keep that as-is for the MEC/LEC hub carousel, switch it to DocumentsBrowserLogic.ResolveIcon's icon treatment (already used elsewhere in the same hub), or auto-hide the media region entirely for cards with no real image (text-only variant)?Decided 2026-08-26 — auto-hide (the third option), matching CardText/CardSmall/PromoBanner's existing precedent. See §2.5.
3Fix BUG-1995's dedup-by-name continue logic, or is a backend fix for the duplicate scope nodes themselves the intended remediation instead?Resolved 2026-08-26 — no fix needed. Live-validated on a real production account (§4): the composite-key dedup (AB#2208, already shipped 2026-07-06) correctly preserves every category/document under duplicate-named scope nodes; the 2026-08-25 empty-ALPA finding does not reproduce and was most likely a transient backend 503 (independently observed during this re-test). A backend fix to stop sending duplicate scope nodes in the first place is still worthwhile hygiene — see §8.2 — but it is no longer required for correctness.
4Wrap the carousel's items in a CarouselViewModel inside DocumentsBrowser to reuse Carousel.razor as-is, or give Carousel.razor/CarouselViewModel a lighter construction path that doesn't imply the full dynamic-feed shape? Wrapping still recommended (§5), now with a known, small cost confirmed by review: DocumentsBrowser's own header (:22-32) needs a one-condition guard so it doesn't double-render alongside Carousel.razor's own header — not free, but still smaller than a new Carousel.razor entry point.Recommended: wrap
5Where does the new carousel section sit in DocumentsHub's existing order (ungrouped grid → per-grouping grids → Latest Documents) — before "Latest Documents", after it, or replacing it for scopes where a carousel of the same underlying documents is intended to supersede the dated-rows list?Needs decision
6Does the new carousel section filter to a specific category/grouping (mirroring the ALP MAG carousel's single-category focus) or surface the scope's newest documents generally (mirroring the existing "Latest Documents" DatedRows section it would sit beside)?Needs decision
7Which existing/planned MenuItem records actually point at document-backed content, and do all of their Path values already route through /documents?scope=...&category=...&grouping=... (§2.1)? The one confirmed real precedent (CLE172 "Roster", §2.1) shows a menu item can be assumed to be a plain route and turn out to be document content that needs the hub — worth an inventory pass over live MenuItem records before this ships, not just this feature's own new entry point.Needs decision

7. Out of scope (this document)

8. Recommended feed shape — a guide for the backend contract

Nice-to-have, not required (revised 2026-08-26). §6 #1 and #3 — the two gaps this section was originally written for — are both now resolved client-side (§2.5, §4): CardHero auto-hides on no image, and the composite-key dedup fix already holds live against duplicate scope nodes. Neither blocks this work, and §8.1's table below no longer has a single "Required" row. This section is kept as a guide for the backend team if they choose to clean up the underlying data anyway (real cover art, one scope node per logical scope) — not because this carousel needs it to ship. Every change below is additive to the existing getdocuments response — no field is proposed for removal or rename, following the same forward-compatibility posture the dynamic-feed contract already commits to (unknown/missing fields degrade gracefully, never break the client).

8.1 Which gaps actually block the carousel

As of 2026-08-26, none of §4's gaps need a backend fix for this specific work — the two that once did (image, dedup) are resolved client-side (§2.5, §4/§6 #3). What's left is pre-existing hygiene, split by whether it's still worth a backend ask:

Gap (§4)Blocks the carousel?Recommended shape change
No document imageryNot required (revised 2026-08-26, §2.5). Originally scored "Required" on the assumption the client would keep showing placeholder art; now that CardHero auto-hides the image area instead (§6 #1/#2), a card with no image renders as a correct text-only card, not a degraded one. A backend fix would still be a nice-to-have improvement (real cover art beats no image), just not a blocker or a "Required" ask for this work.Nice-to-have only, demoted from Required. If pursued anyway: populate image (or featuredImage/featuredImageLink, whichever the backend already treats as canonical) with a real URL for document types where cover art exists (PDFs like ALP MAG issues, policy manuals). See 8.2 for the field-level proposal.
Duplicate scope nodesNot required (revised 2026-08-26, §4/§6 #3). Originally scored "Required" on the 2026-08-25 finding that it emptied the ALPA scope for a real account; a 2026-08-26 live re-test on a real production account found ALPA/MEC/LEC all rendering correctly on the current build, and a static replay of the current dedup algorithm against the AB#2580 sample confirmed zero categories/documents dropped. The composite-key fix (AB#2208) already handles this correctly client-side.Nice-to-have only, demoted from Required. If pursued anyway: one scope node per logical scope value — merge every category array under ALPA (currently 6 nodes) into a single ALPA node's categories array, and the same for MEC/LEC/KCM. See 8.2.
Sparse descriptions (36% coverage)Not blocking — HeroCard.Description already renders conditionally (CardHero.razor:32-35, only shown when non-empty); a card with no description just shows title + image, not a broken layout.Nice-to-have only. No proposed shape change — not worth a backend ask for this work specifically.
Inconsistent friendly-name field (title echoes key, name buried in description)Not blocking for the carousel itself (it renders DocumentItem.Title, not the category name) — but does affect the section header if a future carousel is category-scoped (§6 #6).A dedicated, always-populated category display-name field, distinct from description — see 8.2. Lower priority than the "Required" row above.
docType vocabulary driftNot blocking for the carousel card itself — affects document-open routing, a different pipeline.Carried forward from backend-api-mapping-report.html, no new proposal here — out of this document's scope (§7).
path conventions / fileName never populatedNot blocking — the carousel never reads path; it opens documents via fileID through /document-open exclusively (§5, mandatory link policy).Carried forward from backend-api-mapping-report.html, no new proposal here.

8.2 Recommended shape — annotated example

Current shape (from the AB#2580 sample, six duplicate ALPA nodes shown collapsed to two for brevity) versus the recommended shape below — both changes are now nice-to-have hygiene proposals rather than required fixes, per the 2026-08-26 findings in §2.5 and §4/§6 #3.

// CURRENT — as captured 2026-08-24, six separate "ALPA" nodes (this doc's own §4 finding)
[
  { "scope": "ALPA", "categories": [ { "category": "Canada Limits", "documents": [ ... ] } ] },
  { "scope": "ALPA", "categories": [ { "category": "ALP MAG", "description": "ALPA Magazine.",
      "documents": [ { "title": "ALP MAG", "image": null, ... } ] } ] },
  // ...4 more "ALPA" nodes, each with a different category array
]

// RECOMMENDED — one node per logical scope, categories merged under it
[
  {
    "scope": "ALPA",
    "categories": [
      { "category": "Canada Limits", "categoryTitle": "Canada Limits", "documents": [ ... ] },
      {
        "category": "ALP MAG",
        // NEW — always populated, distinct from description; description keeps its
        // existing free-text role instead of doubling as the only source of a friendly name.
        "categoryTitle": "ALP Magazine",
        "description": "ALPA Magazine.",
        "documents": [
          {
            "title": "Air Line Pilot — July/August 2026",
            // NEW — real cover art where it exists; absent (null) is still a valid,
            // client-handled state (§2.1) for document types with no cover art (e.g. Canada
            // Limits, a policy PDF) — this is additive, not a required-on-every-document field.
            "image": "https://.../covers/alp-mag-2026-07.jpg",
            "docType": "pdf",
            "publishDate": "2026-07-01T00:00:00-04:00",
            "source": "sitecore",
            "fileID": "{...}",
            "portalID": -1
          }
        ]
      }
      // ...every other real ALPA category, all under this one node
    ]
  }
]

Nothing else in the existing shape (§3) changes — fileID, docType, source, publishDate, portalID, and the rest are unaffected and already sufficient for this work.

8.3 Rollout note

Both proposed fixes are additive/structural, not breaking, whichever priority they end up shipping at: a client that doesn't yet know about categoryTitle ignores it (it already falls back to CategoryItem.DisplayTitle's existing "API title-if-distinct, else description" logic — §4 row 4); a client reading the deduplicated scope array sees strictly more correct data than today's silently-dropped duplicates, not a different shape to parse. Neither fix requires a client-side version gate.

References