Component: CardHero
Work Item: WI-2122
Type: Domain control — Document Hero: composes the generic CardHeroViewModel scaffold via DocumentHeroFactory (maps DocumentItem → scaffold; does not subclass it — see Decisions D11)
Last Updated: 2026-08-26 — image fallback changed (AB#2580): no-image/failed-image now hides the media slot entirely instead of substituting placeholder art (see Image, below); 2026-07-15 implemented: ViewModel and factory shipped (WI-2262, D58/D59); as-built sections below reconciled from a pre-implementation draft (2026-06-05)
Shows how CardHeroViewModel properties map to the rendered control.
┌──────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [ Image Area ] │ │ ◄── Image (ImageSource)
│ │ background illustration │ │ 465×500px child frame
│ │ 465 × 500 px │ │
│ │ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Primary Heading Text │ ◄── Title (string)
│ │ Large, bold — main message
│ Brief description text goes here │ ◄── Description (string)
│ spanning two lines for content │ 2-line supplemental text
│ │
│ [ Entire card is tappable ] ────────────────────────┼──► Link (string)
│ │ Navigates to detail/article view
└──────────────────────────────────────────────────────────┘
718 × 504 px | fill: none (transparent) | stroke: 1px
IsLoading ───────────► Loading state — default strategy (no skeleton/overlay)
✅ Implemented — CardHeroViewModel + CardHero.razor + DocumentHeroFactory shipped (D58/D59)
✅ Former Scope/Category gap resolved in practice — shipped consumers don't query by fixed scope/category
Implemented (2026-07-15).
CardHeroViewModelships inALPAMobile.Presentation/ViewModels/CardHeroViewModel.cs(the single home since the 2026-07-15 WI-2254/D61 cutover — the head'sComponentViewModels.csbundle is deleted), rendered byALPAMobile.Presentation/Components/Library/CardHero.razor, andDocumentHeroFactoryships inALPAMobile.Presentation/Components/DocumentHeroFactory.cs(WI-2262, D58; migrated with WI-2254, D61). This document originally pre-dated the implementation; the mapping and factory sections below now reflect the as-built shape, with deltas from the original draft called out inline.
| Property | Status | Source | Notes |
|---|---|---|---|
| Image | ✅ Resolved | DocumentItem.Image via DataManager |
Hero illustration — same field used by document list pages |
| Title | ✅ Resolved | DocumentItem.Title via DataManager |
Direct string match |
| Description | ✅ Resolved | DocumentItem.Description via DataManager |
Field exists on DocumentItem — confirmed |
| Link | ✅ Resolved | DocumentHeroFactory.OpenRoute(fileId) → /document-open |
Route resolves through the legacy HandleDocumentActionAsync path — never a raw Path href (P1) |
| PublishDate | ✅ Bonus | DocumentItem.FormattedPublishDate |
Available if hero card needs a date stamp |
| IsLoading | ✅ Available | ViewModel state | Default strategy — no skeleton overlay |
| Scope/Category | ✅ Resolved | DocumentItem.Scope + DocumentItem.Category |
Fields on model — query via DataManager.GetDocumentCategoriesForScopeAsync → GetDocumentsForScopeCategoryAsync; scope+category string values are a content config decision, not a code gap |
DocumentItem aligns directly with the CardHero content properties, but the base itself stays domain-free — it holds generic presentation properties only. The DocumentItem → scaffold mapping lives in the domain control (DocumentHeroFactory), not on the base. The shipped base (bindable, nullable) differs from the original draft in three ways: it adds Eyebrow and AccentColor, Link/IsLinkVisible live on the CardViewModel parent (where IsLinkVisible checks Link or LinkText), and there is no PublishDate property — the date is available on DocumentItem.FormattedPublishDate but no shipped consumer renders it on a hero card yet.
// As shipped — ALPAMobile/Components/ViewModels/ComponentViewModels.cs
/// Abstract base for content cards with title/description/image/link (card-lg). Domain-free.
public abstract class DocumentCardViewModel : CardViewModel
{
public string? Eyebrow { get; set; } // bindable via SetProperty in real code
public string? Title { get; set; }
public string? Description { get; set; }
public string? Image { get; set; }
public string? AccentColor { get; set; } // left accent color token
// Link + IsLinkVisible inherited from CardViewModel
}
/// Hero / featured content card — rendered by the Document Hero domain control.
public class CardHeroViewModel : DocumentCardViewModel { }
Generic scaffold cards on this base: CardHeroViewModel (hero/featured content). The domain binding that fills it is the Document Hero control below; the same base is reusable by any future content-card domain control.
✅ Former gap resolved in practice: the original draft's open question — which
Scope/Categorystrings feed hero content — no longer blocks anything: the shipped consumers don't query by a fixed scope/category. Favorites resolves a specific document by fileId (GetDocumentByFileIdAsync); the dynamic feed delivers hero items pre-selected by the server. A future fixed-slot hero (theBuildAsyncpattern below) would still need its scope/category chosen by the content team at configuration time.
(This section originally documented that no ViewModel existed — superseded 2026-07-15.) The shipped pieces and their consumers:
CardHeroViewModel — ALPAMobile/Components/ViewModels/ComponentViewModels.cs (live) + ALPAMobile.Presentation/ViewModels/CardHeroViewModel.cs (staged, WI-2254). Empty terminal subclass of DocumentCardViewModel per the scaffold tree (D6).CardHero.razor — ALPAMobile.Presentation/Components/Library/. Renders image (media slot hidden, not placeholder-filled, when there's no usable image — AB#2580), eyebrow, title, description, bottom accent, and the optional favorite heart (D31).DocumentHeroFactory — ALPAMobile/Components/DocumentHeroFactory.cs (WI-2262, D58). Consumers: FavoritesViewModel (document favorites), DocumentsListPage.razor (OpenRoute only). The dynamic feed hydrates the same ViewModel through ItemComponentFactory (ItemDto path) instead.The Figma export identifies two unnamed structural child frames:
- background illustration (FRAME, 465×500px) — contains the hero image/illustration
- Hero (FRAME, 477×539px) — contains the text content and tap target
The Document Hero domain control keeps the D11 split: the generic CardHeroViewModel scaffold (domain-free) and a pure factory that maps a DocumentItem onto it — the async lookup stays with the caller. The scaffold never references DocumentItem.
// As shipped — ALPAMobile/Components/DocumentHeroFactory.cs (DI singleton)
public sealed class DocumentHeroFactory
{
// The internal route that opens a document through the document-open flow.
// Documents must ALWAYS open through this route — never a raw DocumentItem.Path
// href (P1: relative paths navigated the WebView onto a dead local URL and
// killed the Blazor session; public URLs can't display gated member content).
public static string OpenRoute(string fileId) =>
$"/document-open?fileId={Uri.EscapeDataString(fileId)}";
// Link is caller-supplied because fileId keying differs per context:
// Favorites keys by Favorite.ItemId; direct consumers by DocumentItem.FileID.
public CardHeroViewModel Create(DocumentItem document, string? link) => new()
{
Title = document.Title,
Description = document.Description,
Image = document.Image,
Link = link,
};
}
Deltas from the original draft (kept below for the record):
RawRepresentationFactory<T> base class — none exists in code; the "pure mapper" rule is a convention (same as ItemComponentFactory), not an inherited type.Link is a route, not a raw FileID — built via OpenRoute, which the /document-open page resolves through the legacy HandleDocumentActionAsync path (the draft's "opened via SharedActionsService" intent, with the routing hop the Blazor host requires).PublishDate not mapped — no property on the shipped base; available on DocumentItem.FormattedPublishDate when a consumer needs it.DocumentHeroService.BuildAsync domain-service sketch is not shipped — the shipped consumers own their own lookups (FavoritesViewModel resolves by fileId; the dynamic feed is server-selected). The sketch remains the intended pattern for a future fixed-slot hero (e.g. the Pilot Card contract link), unchanged from the draft:// Future pattern (not shipped) — a fixed-slot hero that queries by scope/category
public sealed class DocumentHeroService(IDocumentsQueries documents, DocumentHeroFactory factory)
{
public async Task<CardHeroViewModel?> BuildAsync(string scope, string category)
{
var docs = await documents.GetDocumentsForScopeCategoryAsync(scope, category);
var doc = docs?.FirstOrDefault();
return doc is null ? null : factory.Create(doc, DocumentHeroFactory.OpenRoute(doc.FileID));
}
}
string? (URL)DocumentItem.Image (via DocumentHeroFactory) or ItemDto.SliderItems[].ImageUrl (dynamic feed). CardHero.razor hides the .alpa-cardlg-media slot entirely (.alpa-cardlg--noimg) when the URL is missing or fails to load — changed 2026-08-26 (AB#2580) from a deterministic placeholder-photo fallback, matching CardText/CardSmall/PromoBanner's existing no-image treatment. Exception: a consumer can set CardHero's UseDesignPlaceholder parameter to restore the pre-AB#2580 placeholder-art behavior when "no image" is an intentional design choice rather than missing feed data — TopNav.razor's "My MEC" drawer hero does this, since its own placeholder art is the design mock's photography, not a fallbackbackground illustration child frame (FRAME, 465×500px)string?DocumentItem.Title (via DocumentHeroFactory) or ItemDto title fields (dynamic feed)Hero child frame (477×539px)string?DocumentItem.Description (via DocumentHeroFactory) or ItemDto.Blurb (dynamic feed)Hero child framestring?DocumentHeroFactory.Create — built with DocumentHeroFactory.OpenRoute(fileId) (the /document-open internal route, P1 rule: never a raw DocumentItem.Path)Hero child frame/document-open page delegates to the legacy HandleDocumentActionAsync action path (authenticated download for PDFs, session-carrying in-app WebView for webpage types)bool!string.IsNullOrEmpty(Link)boolComponentViewModel| Service | Interface | Notes |
|---|---|---|
| DataManager | DataManager |
GetDocumentCategoriesForScopeAsync + GetDocumentsForScopeCategoryAsync — same pattern as Pilot Card |
| DocumentItem | ALPADocs.Data.Models.DocumentItem |
All content fields: Title, Description, Image, FileID, FormattedPublishDate |
| SharedActionsService | SharedActionsService |
Link/file resolution and navigation |
| Layer Name | Type | Size (pt) | Fill | Notes |
|---|---|---|---|---|
mobile hero (root) |
FRAME | 718×504 | none (transparent) | Outer card container; stroke 1px |
background illustration |
FRAME | 465×500 | — | Hero image/illustration slot |
Hero |
FRAME | 477×539 | — | Content slot: title, description, tap target |
Note: Figma children are unnamed Group frames — structural detail is sparse in the
.figexport. Layer names above are from the raw Figma export metadata provided in the component spec.
docs/component-specifications/card-hero/card-hero-component.htmlcard/pilot-card-property-mapping.htmlALPAMobile.Presentation/Components/DocumentHeroFactory.cs · Renderer: ALPAMobile.Presentation/Components/Library/CardHero.razorALPAMobile/Services/IAuthentication.csALPAMobile/Services/SharedActionsService.cs (assumed — confirm path)Document Owner: ALPA Mobile Team
Work Item: WI-2122
Purpose: Data source analysis for CardHero presentation-layer implementation phase