Skip to content

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)

FieldValue
Module IDM28
Module NameIcon List
Folderm28-icon-list
PascalCaseIconList
camelCase (Registry Key / Umbraco Alias)iconList
BEM Block.icon-list
Component TypeStatic (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:

VariantURL
Desktop (1440px)https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=974-15524&m=dev
Mobile (360px)https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=974-15793&m=dev
Card (isolated)https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=974-11785&m=dev

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 cards
export 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:

  • visibility toggles control which optional elements render across ALL cards. When a toggle is false, that element is hidden for every card — even if the card data contains a value.
  • items is a Block List in Umbraco — each item is an iconListItem Element Type.
  • bigNumber is 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.).
  • ctaSecondary should not render if cta is 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.

PropertyValue
BackgroundWhite (--color-bg)
Vertical padding80px (--space-16 × 5, or Figma paddings/vertical/default)
Horizontal padding120px (--container-padding-lg)
Header max-width792px, centered
Header gap (label → headline+body)24px (--space-6)
Headline+body internal gap16px (--space-4)
Section gap (header → cards → footer)64px
Cards container1200px, left-aligned
Cards per row4
Card width282px (fluid within grid)
Card gap (horizontal)24px
Row gap (vertical)40px
Card internal gap24px between elements
Big number fontDisplay M — 80px, heading color
Card title fontSubtitle — 28px, heading color
Card body fontBody S — 16px, body color
Card label fontLabel M — 16px, accent color, uppercase
Card CTA fontButton S — 14px, accent color, uppercase, underline
Big image aspect2:1 (282×141)
Small image size64×64
Footer CTAsHorizontal, 24px gap, centered
Primary CTAFilled pill button (accent bg, white text)
Secondary CTALink + arrow icon, underline
PropertyValue
Vertical padding64px
Horizontal padding24px
Header width312px, centered
Section gap52px
Cards per row2
Card width148px
Card gap (horizontal)16px
Row gap (vertical)32px
Card internal gap16px
Big number fontDisplay M — 52px
Card title fontSubtitle — 24px
Headline fontH4 — 40px (vs 56px desktop)
Big image aspect2:1 (148×74)
Small image size48×48
Footer CTAsStacked vertically, 16px gap
Primary CTAFull width (min-width: 312px)
  • Header: center-aligned
  • Cards: center-aligned (all text and elements)

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 icon

Mobile:

  • Grid becomes 2 columns
  • All card elements scale proportionally
  • Footer CTAs stack vertically, primary full-width
  • Header uses full available width (312px)

// 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)

ComponentSourceUsage
CtaLink@savoy/uiPrimary (filled) and secondary (link) footer CTAs, card CTAs
HtmlHeading rendering patterninline (see frontend-module.md)label, headline, card titles

Note: Card images are simple &lt;img&gt; 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.


IconList.mapper.ts
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,
};
}

Required variants:

StoryPurpose
Default8 cards, all card elements visible, header + both CTAs, realistic hotel content
FourCards4 cards (single row on desktop), all elements visible
TwoCards2 cards only, all elements visible
NumbersOnlyshowImageBig=false, showImageSmall=false, showLabel=false — only numbers + title + body
ImagesOnlyshowBigNumber=false, showCta=false — only images + title
MinimalContentNo header, no footer CTAs, 4 cards with title only
EmptyStateEmpty items array — should render nothing or minimal shell
LongContent12+ cards, long titles and body text — tests overflow and wrapping
AllOptionsAll fields filled, all toggles on, both CTAs, 8 cards
FigmaFidelityExact 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}

Alias: iconListItem Icon: icon-list IsElement: true

#Property NameAliasEditorVariationNotes
0LabellabelTextstringCultureCard eyebrow label text
1Image BigimageBigMedia PickerNothingWide landscape image (2:1)
2Image SmallimageSmallMedia PickerNothingSmall square icon (64px)
3Big NumberbigNumberTextstringNothingDisplay number (“01”, “02”) — invariant
4TitletitleSingle Html Heading Block ListNothingCard heading (BL variation on nested props)
5BodybodyTextareaCultureCard description text
6CTActaSingle URL PickerCultureCard link button

Alias: iconList Icon: icon-bulleted-list

Tab: Content (sortOrder 0)

#Property NameAliasEditorVariationNotes
0ActiveactiveToggleNothingDefault ON
1LabellabelSingle Html Heading Block ListNothingHeader eyebrow label
2HeadlineheadlineSingle Html Heading Block ListNothingHeader main heading
3BodybodyTextareaCultureHeader body text

Tab: Settings (sortOrder 1)

#Property NameAliasEditorVariationNotes
0Show LabelshowLabelToggleNothingDefault ON
1Show Image BigshowImageBigToggleNothingDefault ON
2Show Image SmallshowImageSmallToggleNothingDefault ON
3Show Big NumbershowBigNumberToggleNothingDefault ON
4Show TitleshowTitleToggleNothingDefault ON
5Show BodyshowBodyToggleNothingDefault ON
6Show CTAshowCtaToggleNothingDefault ON

Tab: Items (sortOrder 2)

#Property NameAliasEditorVariationNotes
0ItemsitemsIcon List Items Block ListNothingBlock List of iconListItem

Tab: CTAs (sortOrder 3)

#Property NameAliasEditorVariationNotes
0CTActaSingle URL PickerCulturePrimary filled button
1CTA SecondaryctaSecondarySingle URL PickerCultureSecondary 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


packages/modules/src/m28-icon-list/
index.ts
IconList.tsx
IconList.scss
IconList.types.ts
IconList.mapper.ts
IconList.stories.tsx
IconList.test.tsx

Register 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.svg
  • apps/cms/Savoy.Cms/Migrations/AddIconListMigration.cs
  • apps/cms/Savoy.Cms/Migrations/SavoyMigrationPlan.cs (add migration registration)

  1. Visibility toggles must be module-level, not per-card — a single showBigNumber toggle hides big numbers for ALL cards, not individual ones
  2. Big number auto-generation — if bigNumber is not set on a card item, auto-generate from 1-based index with zero-padding: String(index + 1).padStart(2, '0')
  3. Card grid must be fluid — use CSS Grid with repeat(4, 1fr) on desktop, repeat(2, 1fr) on mobile, not fixed pixel widths
  4. Secondary CTA guard — if cta prop is absent, ctaSecondary must also be hidden
  5. Header section guard — hide the entire header block if no label, headline, or body is provided
  6. Card image sizing — big image uses object-fit: cover with 2:1 aspect ratio; small image uses object-fit: contain at square aspect
  7. Text alignment — everything in this module is center-aligned (header AND cards)
  8. Do not use ResponsiveImage — card images are single images that CSS-scale between breakpoints, not responsive pairs with different crops
  9. Footer CTA layout changes on mobile — horizontal on desktop (24px gap), vertical stack on mobile (16px gap, primary full-width)
  10. Card internal spacing — 24px gap on desktop, 16px on mobile between card elements
  11. Display number typography — uses Display M font (80px desktop, 52px mobile), NOT heading font
  12. Nested Block List in Umbraco — items are iconListItem Element Type in a Block List, created bottom-up in migration (child first, then parent)