M28 — Icon List: Development Prompt
Read the following context files first:
- docs/PRD/07_Modules_and_Templates.md
- docs/dev-frontend-guides/03_MODULE_DEVELOPMENT_LIFECYCLE.md
- docs/dev-frontend-guides/05_BEM_SASS_THEMING.md
- docs/dev-frontend-guides/01_FIGMA_TO_CODE_WORKFLOW.md
- docs/dev-frontend-guides/04_STORYBOOK_FIRST_DEVELOPMENT.md
- docs/dev-frontend-guides/06_RESPONSIVE_IMAGES_PATTERN.md
- packages/modules/src/m11-banner/ (reference for static module with multiple optional elements)
- packages/modules/src/m27-text-image/ (reference for static module with header + content sections)
- packages/modules/src/registry.ts
- packages/ui/src/ (scan for available UI components, especially CtaLink and ResponsiveImage)
1. Module Overview
Section titled “1. Module Overview”| Field | Value |
|---|---|
| Module ID | M28 |
| Module Name | Icon List |
| Folder | m28-icon-list |
| PascalCase | IconList |
| camelCase (Registry Key / Umbraco Alias) | iconList |
| BEM Block | .icon-list |
| Component Type | Static (Server Component — no interactivity, no client-side state) |
Description: Versatile grid module that displays a collection of icon/image cards with configurable optional elements. The module has 3 sections: a centered header (label, headline, body text), a responsive card grid (4 columns desktop, 2 columns mobile), and footer CTAs (primary filled + secondary link with icon). Each card can show up to 7 optional elements: label, big image, small image, big number, title, body text, and a CTA link. The choice to show/hide each card element is controlled at the module level and affects all cards uniformly. Supports 1 to N cards.
Figma Links:
2. Props
Section titled “2. Props”import type { HtmlHeading, CtaLinkWithElement } from '@savoy/cms-client';import type { SiteKey } from '@savoy/cms-client';import type { ModuleLayoutProps } from '../_layout-props';
// --- Card item data ---export interface IconListItem { label?: string; // Uppercase label text (accent color) imageBig?: { url: string; width: number; height: number; alt: string }; // Wide landscape image imageSmall?: { url: string; width: number; height: number; alt: string }; // Small square icon/image bigNumber?: string; // Large display number (e.g., "01", "02") title?: HtmlHeading; // Card heading (subtitle font) body?: string; // Card description text cta?: CtaLinkWithElement; // Card-level CTA link (underline style)}
// --- Module-level visibility toggles ---// These control whether each optional card element is shown across ALL cardsexport interface IconListVisibility { showLabel?: boolean; // Show card labels (default: true) showImageBig?: boolean; // Show wide landscape images (default: true) showImageSmall?: boolean; // Show small square icons (default: true) showBigNumber?: boolean; // Show large display numbers (default: true) showTitle?: boolean; // Show card titles (default: true) showBody?: boolean; // Show card body text (default: true) showCta?: boolean; // Show card CTAs (default: true)}
// --- Module props ---export interface IconListProps extends ModuleLayoutProps { active?: boolean;
// Header section (all optional) label?: HtmlHeading; // Uppercase eyebrow label (accent color) headline?: HtmlHeading; // Main heading (H4 desktop 56px, mobile 40px) body?: string; // Body text below headline
// Card items items: IconListItem[]; // 1 to N cards
// Visibility toggles (module-level — affect all cards) visibility?: IconListVisibility;
// Footer CTAs (optional) cta?: CtaLinkWithElement; // Primary CTA (filled button) ctaSecondary?: CtaLinkWithElement; // Secondary CTA (link with icon)
// Standard module props siteKey: SiteKey; locale: string; moduleId?: string;}Prop notes:
visibilitytoggles control which optional elements render across ALL cards. When a toggle isfalse, that element is hidden for every card — even if the card data contains a value.itemsis a Block List in Umbraco — each item is aniconListItemElement Type.bigNumberis a per-card text field. If not manually set, the component auto-generates it from the card’s 1-based index (zero-padded: “01”, “02”, etc.).ctaSecondaryshould not render ifctais absent (secondary CTA cannot stand alone).- All header fields are optional — the heading section is hidden entirely if no label, headline, or body is provided.
3. Visual Specifications
Section titled “3. Visual Specifications”Desktop (1440px)
Section titled “Desktop (1440px)”| Property | Value |
|---|---|
| Background | White (--color-bg) |
| Vertical padding | 80px (--space-16 × 5, or Figma paddings/vertical/default) |
| Horizontal padding | 120px (--container-padding-lg) |
| Header max-width | 792px, centered |
| Header gap (label → headline+body) | 24px (--space-6) |
| Headline+body internal gap | 16px (--space-4) |
| Section gap (header → cards → footer) | 64px |
| Cards container | 1200px, left-aligned |
| Cards per row | 4 |
| Card width | 282px (fluid within grid) |
| Card gap (horizontal) | 24px |
| Row gap (vertical) | 40px |
| Card internal gap | 24px between elements |
| Big number font | Display M — 80px, heading color |
| Card title font | Subtitle — 28px, heading color |
| Card body font | Body S — 16px, body color |
| Card label font | Label M — 16px, accent color, uppercase |
| Card CTA font | Button S — 14px, accent color, uppercase, underline |
| Big image aspect | 2:1 (282×141) |
| Small image size | 64×64 |
| Footer CTAs | Horizontal, 24px gap, centered |
| Primary CTA | Filled pill button (accent bg, white text) |
| Secondary CTA | Link + arrow icon, underline |
Mobile (360px)
Section titled “Mobile (360px)”| Property | Value |
|---|---|
| Vertical padding | 64px |
| Horizontal padding | 24px |
| Header width | 312px, centered |
| Section gap | 52px |
| Cards per row | 2 |
| Card width | 148px |
| Card gap (horizontal) | 16px |
| Row gap (vertical) | 32px |
| Card internal gap | 16px |
| Big number font | Display M — 52px |
| Card title font | Subtitle — 24px |
| Headline font | H4 — 40px (vs 56px desktop) |
| Big image aspect | 2:1 (148×74) |
| Small image size | 48×48 |
| Footer CTAs | Stacked vertically, 16px gap |
| Primary CTA | Full width (min-width: 312px) |
Text alignment
Section titled “Text alignment”- Header: center-aligned
- Cards: center-aligned (all text and elements)
4. Layout
Section titled “4. Layout”Desktop structure (BEM):
<section class="icon-list" data-module="iconList" data-module-id="M28"> <div class="icon-list__header"> ← centered, max-width 792px <span class="icon-list__label"> ← label (htmlHeading, uppercase, accent) <div class="icon-list__header-content"> <h4 class="icon-list__headline"> ← headline (htmlHeading) <p class="icon-list__body"> ← body text <div class="icon-list__grid"> ← 4-col grid, max-width 1200px <div class="icon-list__card"> ← per card item (centered content) <span class="icon-list__card-label"> ← card label (uppercase, accent) <div class="icon-list__card-image-big"> ← wide landscape image (2:1) <div class="icon-list__card-image-small"> ← small square icon (64px) <div class="icon-list__card-content"> <span class="icon-list__card-number"> ← big display number <div class="icon-list__card-text"> <h5 class="icon-list__card-title"> ← card title (htmlHeading) <p class="icon-list__card-body"> ← card description <a class="icon-list__card-cta"> ← link button (CtaLink secondary style) <div class="icon-list__footer"> ← footer CTAs, centered <a class="icon-list__cta"> ← primary filled CTA <a class="icon-list__cta-secondary"> ← secondary link CTA with iconMobile:
- Grid becomes 2 columns
- All card elements scale proportionally
- Footer CTAs stack vertically, primary full-width
- Header uses full available width (312px)
5. CSS Tokens to Use
Section titled “5. CSS Tokens to Use”// Background--color-bg // Page background (white)
// Text colors--color-text // Body text (black)--color-primary // Heading color (#977e54)--color-accent // Label + card label color (#59437d)
// Typography--font-heading // Heading/display font (Noe Display)--font-body // Body font (Firme)--text-4xl // H4 headline desktop (56px)--text-3xl // H4 headline mobile (40px)--text-base // Body M (18px)--text-sm // Body S (16px)
// Spacing--space-1 through --space-16--container-max--container-padding--container-padding-lg
// Radius--radius-full // Pill button border-radius (100px)6. UI Components to Use
Section titled “6. UI Components to Use”| Component | Source | Usage |
|---|---|---|
CtaLink | @savoy/ui | Primary (filled) and secondary (link) footer CTAs, card CTAs |
| HtmlHeading rendering pattern | inline (see frontend-module.md) | label, headline, card titles |
Note: Card images are simple <img> elements (not ResponsiveImage) since they are icons/logos that don’t need different crops per viewport — just CSS-scaled. The same image URL is used at both desktop and mobile sizes.
7. Mapper
Section titled “7. Mapper”import { mapHtmlHeading } from '@savoy/cms-client';import type { IconListProps, IconListItem } from './IconList.types';
export function mapIconList( element: UmbracoElement): Omit<IconListProps, 'siteKey' | 'locale'> { const p = element.properties;
const ctaLink = p.cta?.[0]; const ctaSecondaryLink = p.ctaSecondary?.[0];
// Map nested Block List items (iconListItem Element Type) const items: IconListItem[] = (p.items?.items ?? []).map((item: any) => { const ip = item.content?.properties ?? {}; const itemCta = ip.cta?.[0]; return { label: ip.label ?? undefined, imageBig: ip.imageBig ? { url: ip.imageBig.url, width: 282, height: 141, alt: ip.imageBig.name ?? '' } : undefined, imageSmall: ip.imageSmall ? { url: ip.imageSmall.url, width: 64, height: 64, alt: ip.imageSmall.name ?? '' } : undefined, bigNumber: ip.bigNumber ?? undefined, title: ip.title ? mapHtmlHeading(ip.title) : undefined, body: ip.body ?? undefined, cta: itemCta ? { label: itemCta.name, href: itemCta.url, as: 'a' as const } : undefined, }; });
return { active: p.active !== false,
label: p.label ? mapHtmlHeading(p.label) : undefined, headline: p.headline ? mapHtmlHeading(p.headline) : undefined, body: p.body ?? undefined,
items,
visibility: { showLabel: p.showLabel !== false, showImageBig: p.showImageBig !== false, showImageSmall: p.showImageSmall !== false, showBigNumber: p.showBigNumber !== false, showTitle: p.showTitle !== false, showBody: p.showBody !== false, showCta: p.showCta !== false, },
cta: ctaLink ? { label: ctaLink.name, href: ctaLink.url, as: 'a' as const } : undefined, ctaSecondary: ctaSecondaryLink ? { label: ctaSecondaryLink.name, href: ctaSecondaryLink.url, as: 'a' as const } : undefined,
// Layout spacing marginTop: p.marginTop != null && p.marginTop !== '' ? parseInt(p.marginTop as string, 10) : undefined, marginBottom: p.marginBottom != null && p.marginBottom !== '' ? parseInt(p.marginBottom as string, 10) : undefined, paddingTop: p.paddingTop != null && p.paddingTop !== '' ? parseInt(p.paddingTop as string, 10) : undefined, paddingBottom: p.paddingBottom != null && p.paddingBottom !== '' ? parseInt(p.paddingBottom as string, 10) : undefined, };}8. Storybook Stories
Section titled “8. Storybook Stories”Required variants:
| Story | Purpose |
|---|---|
Default | 8 cards, all card elements visible, header + both CTAs, realistic hotel content |
FourCards | 4 cards (single row on desktop), all elements visible |
TwoCards | 2 cards only, all elements visible |
NumbersOnly | showImageBig=false, showImageSmall=false, showLabel=false — only numbers + title + body |
ImagesOnly | showBigNumber=false, showCta=false — only images + title |
MinimalContent | No header, no footer CTAs, 4 cards with title only |
EmptyState | Empty items array — should render nothing or minimal shell |
LongContent | 12+ cards, long titles and body text — tests overflow and wrapping |
AllOptions | All fields filled, all toggles on, both CTAs, 8 cards |
FigmaFidelity | Exact Figma content verbatim (Lorem ipsum as-is) — visual test target |
FigmaFidelity story parameters:
parameters: { pixelPerfect: { threshold: { desktop: 0.05, mobile: 0.10 }, viewports: ['desktop', 'mobile'], themes: ['savoy-palace'], },},Mock data guidelines:
- Use hotel amenity/service descriptions as realistic content (spa services, dining options, room features, activities)
- siteKey:
'savoy-palace', locale:'pt' - Image URLs: use images from
apps/storybook/public/storybook/— reference as/storybook/{filename}
9. Umbraco Element Type Schema
Section titled “9. Umbraco Element Type Schema”iconListItem (Nested Element Type)
Section titled “iconListItem (Nested Element Type)”Alias: iconListItem
Icon: icon-list
IsElement: true
| # | Property Name | Alias | Editor | Variation | Notes |
|---|---|---|---|---|---|
| 0 | Label | label | Textstring | Culture | Card eyebrow label text |
| 1 | Image Big | imageBig | Media Picker | Nothing | Wide landscape image (2:1) |
| 2 | Image Small | imageSmall | Media Picker | Nothing | Small square icon (64px) |
| 3 | Big Number | bigNumber | Textstring | Nothing | Display number (“01”, “02”) — invariant |
| 4 | Title | title | Single Html Heading Block List | Nothing | Card heading (BL variation on nested props) |
| 5 | Body | body | Textarea | Culture | Card description text |
| 6 | CTA | cta | Single URL Picker | Culture | Card link button |
iconList (Module Element Type)
Section titled “iconList (Module Element Type)”Alias: iconList
Icon: icon-bulleted-list
Tab: Content (sortOrder 0)
| # | Property Name | Alias | Editor | Variation | Notes |
|---|---|---|---|---|---|
| 0 | Active | active | Toggle | Nothing | Default ON |
| 1 | Label | label | Single Html Heading Block List | Nothing | Header eyebrow label |
| 2 | Headline | headline | Single Html Heading Block List | Nothing | Header main heading |
| 3 | Body | body | Textarea | Culture | Header body text |
Tab: Settings (sortOrder 1)
| # | Property Name | Alias | Editor | Variation | Notes |
|---|---|---|---|---|---|
| 0 | Show Label | showLabel | Toggle | Nothing | Default ON |
| 1 | Show Image Big | showImageBig | Toggle | Nothing | Default ON |
| 2 | Show Image Small | showImageSmall | Toggle | Nothing | Default ON |
| 3 | Show Big Number | showBigNumber | Toggle | Nothing | Default ON |
| 4 | Show Title | showTitle | Toggle | Nothing | Default ON |
| 5 | Show Body | showBody | Toggle | Nothing | Default ON |
| 6 | Show CTA | showCta | Toggle | Nothing | Default ON |
Tab: Items (sortOrder 2)
| # | Property Name | Alias | Editor | Variation | Notes |
|---|---|---|---|---|---|
| 0 | Items | items | Icon List Items Block List | Nothing | Block List of iconListItem |
Tab: CTAs (sortOrder 3)
| # | Property Name | Alias | Editor | Variation | Notes |
|---|---|---|---|---|---|
| 0 | CTA | cta | Single URL Picker | Culture | Primary filled button |
| 1 | CTA Secondary | ctaSecondary | Single URL Picker | Culture | Secondary link with icon |
Block List label expression: Icon List - {blText: headline}
(Falls back to module name if headline is empty)
SVG Thumbnail: apps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m28-icon-list.svg
10. Files to Create
Section titled “10. Files to Create”packages/modules/src/m28-icon-list/ index.ts IconList.tsx IconList.scss IconList.types.ts IconList.mapper.ts IconList.stories.tsx IconList.test.tsxRegister in packages/modules/src/registry.ts:
iconList: { component: IconList, mapper: mapIconList, moduleId: 'M28' },Phase C also requires:
apps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m28-icon-list.svgapps/cms/Savoy.Cms/Migrations/AddIconListMigration.csapps/cms/Savoy.Cms/Migrations/SavoyMigrationPlan.cs(add migration registration)
11. Common Pitfalls for This Module
Section titled “11. Common Pitfalls for This Module”- Visibility toggles must be module-level, not per-card — a single
showBigNumbertoggle hides big numbers for ALL cards, not individual ones - Big number auto-generation — if
bigNumberis not set on a card item, auto-generate from 1-based index with zero-padding:String(index + 1).padStart(2, '0') - Card grid must be fluid — use CSS Grid with
repeat(4, 1fr)on desktop,repeat(2, 1fr)on mobile, not fixed pixel widths - Secondary CTA guard — if
ctaprop is absent,ctaSecondarymust also be hidden - Header section guard — hide the entire header block if no label, headline, or body is provided
- Card image sizing — big image uses
object-fit: coverwith 2:1 aspect ratio; small image usesobject-fit: containat square aspect - Text alignment — everything in this module is center-aligned (header AND cards)
- Do not use ResponsiveImage — card images are single images that CSS-scale between breakpoints, not responsive pairs with different crops
- Footer CTA layout changes on mobile — horizontal on desktop (24px gap), vertical stack on mobile (16px gap, primary full-width)
- Card internal spacing — 24px gap on desktop, 16px on mobile between card elements
- Display number typography — uses Display M font (80px desktop, 52px mobile), NOT heading font
- Nested Block List in Umbraco — items are
iconListItemElement Type in a Block List, created bottom-up in migration (child first, then parent)