Skip to content

03 — Module Development Lifecycle

Dev Guide — Savoy Signature Hotels
PRD refs: 07_Modules_and_Templates.md, 04_Frontend_Architecture.md, 14_Accessibility_and_Compliance.md


This guide provides a repeatable checklist for creating any module (M01-Mxx) from scratch. It is designed to be followed by Claude Code or a human developer and produces a complete, production-ready module every time.


Every module — static or interactive — follows three phases:

PHASE A — Backend / CMS
A1. Define Element Type schema (properties, aliases, editors, tabs)
A2. Create Element Type in Umbraco backoffice
A3. Add as allowed block on target page Document Types
A4. Create test content and verify API output (Content Delivery API v2)
PHASE B — Frontend
B1. Figma Analysis (breakpoints, image variants, interactivity)
B2. Scaffold files (folder + all files per naming pattern)
B3. Define types (.types.ts) — aligned with Element Type schema from A1
B4. Write Storybook story (skeleton BEFORE implementation)
B5. Implement component (.tsx + .scss, BEM + tokens)
→ Static (default): Server Component
→ Interactive: Server wrapper + .client.tsx
B6. Write mapper (.mapper.ts) — Umbraco JSON → component props
B7. Register in registry (packages/modules/src/registry.ts)
B8. Write tests (.test.tsx) — mapper + rendering
B9. Validate across themes + viewports
B10. **Pixel Perfect Visual Testing** — Fetch Figma baselines, run visual tests, iterate until passing
B11. **Promote regression baselines** — Capture approved screenshots as regression baselines
B12. Export from index.ts
PHASE C — Integration
C1. Validate mapper against real API response (not just mock data)
C2. Test in at least 2 sites (default + one themed)
C3. Verify cache purge triggers on content publish

Before starting a module, confirm:

  • Module ID assigned (e.g., M08)
  • Module name defined (e.g., Card Grid)
  • Figma design available (Desktop + Mobile + Tablet if applicable)
  • Server or Client Component decision made
  • Image variants identified (does it need Desktop + Mobile images?)
  • Accessibility requirements noted (keyboard nav, ARIA patterns)
  • Umbraco Element Type alias confirmed with BE team (e.g., cardGrid)

Context: Read docs/dev-backend-guides/01_UMBRACO_CONTENT_MODELING.md before starting this phase.

Every module needs an Element Type in Umbraco. This must exist BEFORE frontend work begins so that:

  • The TypeScript types (.types.ts) match real property aliases
  • The mapper can be tested against real API output
  • Storybook mock data reflects the actual CMS structure

Create a property table for the module:

PropertyAliasEditorRequiredTab
TitletitleBlock List (htmlHeading, single-block mode)NoContent
LabellabelBlock List (htmlHeading, single-block mode)NoContent
Image DesktopimageDesktopMedia PickerYesImages
Image MobileimageMobileMedia PickerYesImages

Rules:

  • Aliases are camelCase and immutable once content exists
  • Always use imageDesktop + imageMobile for images (never single image)
  • Always use htmlHeading Element Type via Block List for title, subtitle, and label/eyebrow fields (never plain Textstring)
  • Group properties into Tabs (Content, Settings, Images)
  • Text properties must vary by culture for multi-language

Settings → Document Types → Element Types → Create. Set alias matching the FE registry key (e.g., cardGrid).

Add the Element Type as an allowed block on target page Document Types. See docs/dev-backend-guides/01_UMBRACO_CONTENT_MODELING.md section 7 for configuration steps.

  1. Create test content using the new Element Type in Umbraco backoffice
  2. Fetch the page via Content Delivery API: GET /umbraco/delivery/api/v2/content/item/{path}
  3. Confirm the JSON structure matches your schema — property aliases, nested blocks, media URLs
  4. Save a sample JSON response to use as test fixture in Step B8

Create the folder and all files:

packages/modules/src/{mXX-module-name}/
index.ts
ModuleName.tsx
ModuleName.scss
ModuleName.types.ts
ModuleName.mapper.ts
ModuleName.stories.tsx
ModuleName.test.tsx

If the module requires client-side interactivity, also create:

ModuleName.client.tsx # Client component wrapper

Start with the props interface. This drives everything else.

CardGrid.types.ts
export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
export interface HtmlHeading {
text: string; // HTML string (may contain <strong>)
html: HeadingTag; // semantic tag chosen by editor
}
export interface CardGridProps {
title?: HtmlHeading;
cards: CardGridItem[];
columns?: 2 | 3 | 4;
siteKey: string;
locale: string;
moduleId?: string;
}
export interface CardGridItem {
imageDesktop: { url: string; width: number; height: number };
imageMobile: { url: string; width: number; height: number };
altText: string;
title: string;
description?: string;
link?: { label: string; href: string };
}

Rules:

  • siteKey and locale are always present (injected by ModuleRenderer)
  • Image props always include imageDesktop + imageMobile with dimensions
  • Title, subtitle, and label/eyebrow fields always use HtmlHeading type (never plain string)
  • Optional fields use ?
  • Use specific union types for constrained values (columns?: 2 | 3 | 4)

Write stories BEFORE implementing the component. This validates the design intent:

CardGrid.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { CardGrid } from './CardGrid';
const meta: Meta<typeof CardGrid> = {
title: 'Modules/M08 — Card Grid',
component: CardGrid,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
design: {
type: 'figma',
url: 'https://www.figma.com/file/...?node-id=XX',
},
},
};
export default meta;
type Story = StoryObj<typeof CardGrid>;
// Always include: Default, Minimal, Edge case variants
export const Default: Story = { args: { /* realistic data */ } };
export const MinimalContent: Story = { args: { /* minimum viable */ } };
export const EmptyState: Story = { args: { cards: [], siteKey: 'savoy-palace', locale: 'pt' } };

Component (Server Component by default):

CardGrid.tsx
import './CardGrid.scss';
import { CardGridProps } from './CardGrid.types';
import { ResponsiveImage } from '@savoy/ui';
// Heading helper — renders HtmlHeading with dynamic semantic tag
// Content is trusted CMS-authored HTML (Umbraco RTE), not user input
function Heading({ heading, className }: { heading: HtmlHeading; className: string }) {
const Tag = heading.html;
return <Tag className={className} dangerouslySetInnerHTML={{ __html: heading.text }} />;
}
export function CardGrid({ title, cards, columns = 3, siteKey, locale, moduleId }: CardGridProps) {
return (
<section className="card-grid" data-module="cardGrid" data-module-id={moduleId}>
<div className="card-grid__container">
{title && <Heading heading={title} className="card-grid__title" />}
<div className={`card-grid__grid card-grid__grid--cols-${columns}`}>
{cards.map((card, index) => (
<article key={index} className="card-grid__card">
<div className="card-grid__image">
<ResponsiveImage
desktop={card.imageDesktop}
mobile={card.imageMobile}
alt={card.altText}
/>
</div>
<div className="card-grid__content">
<h3 className="card-grid__card-title">{card.title}</h3>
{card.description && (
<p className="card-grid__card-description">{card.description}</p>
)}
{card.link && (
<a href={card.link.href} className="card-grid__card-link">
{card.link.label}
</a>
)}
</div>
</article>
))}
</div>
</div>
</section>
);
}

SCSS (BEM + tokens):

CardGrid.scss
.card-grid {
padding: var(--space-12) 0;
&__container {
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding: 0 var(--container-padding);
}
&__title {
font-family: var(--font-heading);
font-size: var(--text-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text);
margin-bottom: var(--space-8);
@media (min-width: 1024px) {
font-size: var(--text-4xl);
}
}
&__grid {
display: grid;
gap: var(--space-6);
// Mobile-first: single column
&--cols-2 {
@media (min-width: 768px) { grid-template-columns: repeat(2, 1fr); }
}
&--cols-3 {
@media (min-width: 768px) { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 1024px) { grid-template-columns: repeat(3, 1fr); }
}
&--cols-4 {
@media (min-width: 640px) { grid-template-columns: repeat(2, 1fr); }
@media (min-width: 1024px) { grid-template-columns: repeat(4, 1fr); }
}
}
&__card {
background-color: var(--color-surface);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition-normal);
&:hover {
box-shadow: var(--shadow-md);
}
}
&__card-title {
font-family: var(--font-heading);
font-size: var(--text-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
&__card-description {
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text-muted);
line-height: var(--leading-normal);
}
&__card-link {
font-family: var(--font-body);
font-size: var(--text-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
CardGrid.mapper.ts
import { UmbracoElement } from '@savoy/cms-client';
import { CardGridProps, HtmlHeading } from './CardGrid.types';
// Reusable helper — extracts HtmlHeading from Block List single-block format
function mapHtmlHeading(value: unknown): HtmlHeading | undefined {
const items = (value as any)?.items;
const block = items?.[0];
if (!block) return undefined;
const props = block.content?.properties ?? block;
const validTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'p'];
return { text: props.text, html: validTags.includes(props.html) ? props.html : 'span' };
}
export function mapCardGrid(element: UmbracoElement): Omit<CardGridProps, 'siteKey' | 'locale'> {
return {
title: mapHtmlHeading(element.properties.title),
columns: element.properties.columns ?? 3,
cards: (element.properties.cards || []).map((card: any) => ({
imageDesktop: {
url: card.imageDesktop?.url,
width: card.imageDesktop?.width ?? 800,
height: card.imageDesktop?.height ?? 600,
},
imageMobile: {
url: card.imageMobile?.url,
width: card.imageMobile?.width ?? 400,
height: card.imageMobile?.height ?? 300,
},
altText: card.altText || card.title || '',
title: card.title,
description: card.description,
link: card.link ? { label: card.link.name, href: card.link.url } : undefined,
})),
};
}
// In packages/modules/src/registry.ts — add entry:
cardGrid: { component: CardGrid, mapper: mapCardGrid, moduleId: "M08" },
CardGrid.test.tsx
import { describe, it, expect } from 'vitest';
import { mapCardGrid } from './CardGrid.mapper';
describe('mapCardGrid', () => {
it('maps API response correctly', () => {
const element = {
contentType: 'cardGrid',
properties: {
title: 'Our Rooms',
columns: 3,
cards: [{
imageDesktop: { url: '/img-d.jpg', width: 800, height: 600 },
imageMobile: { url: '/img-m.jpg', width: 400, height: 300 },
altText: 'Room',
title: 'Deluxe',
description: 'A room',
link: { name: 'View', url: '/rooms/deluxe' },
}],
},
};
const result = mapCardGrid(element as any);
expect(result.title).toBe('Our Rooms');
expect(result.cards).toHaveLength(1);
expect(result.cards[0].link?.href).toBe('/rooms/deluxe');
});
it('defaults columns to 3', () => {
const element = { properties: { cards: [] } };
const result = mapCardGrid(element as any);
expect(result.columns).toBe(3);
});
});

Step 8: Pixel Perfect Visual Testing (MANDATORY)

Section titled “Step 8: Pixel Perfect Visual Testing (MANDATORY)”

Every module MUST pass Pixel Perfect visual tests before being considered complete.

A. Fetch Figma Baselines (new modules only):

  1. Use Figma MCP server (get_screenshot) to capture the Figma design at:
    • Desktop: 1440x900 → apps/storybook/__figma_baselines__/{storyId}/desktop-1440x900.png
    • Mobile: 375x812 → apps/storybook/__figma_baselines__/{storyId}/mobile-375x812.png
  2. Register the mapping in apps/storybook/.storybook/visual-testing/figma-mapping.ts:
    'modules-m08-card-grid--default': {
    fileKey: 'YOUR_FIGMA_FILE_KEY',
    nodeId: '1076:1595',
    },

B. Run Visual Tests:

Terminal window
# Run all visual tests (Storybook must be running)
pnpm --filter storybook visual:test
# Run for a specific story
pnpm --filter storybook visual:test -- --story modules-m08-card-grid--default

C. Review Results:

Open the Pixel Perfect tab in Storybook. The panel shows:

  • KPI bar: pass rate, passed/failed counts, Figma/regression split, average diff %
  • Per-story results table with diff %, comparison type, and viewport
  • Click “View” to see the screenshot, “Golden” for the Figma baseline, “Diff” for the overlay
  • Red pixels in the diff = differences between implementation and design

D. Iterate Until Passing:

  • Adjust CSS until the diff % is within threshold (default: 0.5%)
  • Focus on spacing, colors, font sizes, and alignment — these are the most common causes of diffs

E. Promote to Regression Baseline:

Once the Figma validation passes, promote the current screenshots as regression baselines:

  • Click “Baseline” in the Pixel Perfect panel for each viewport
  • Or run: pnpm --filter storybook visual:update
  • Commit the baselines to git (__figma_baselines__/ and __visual_snapshots__/ are tracked)
index.ts
export { CardGrid } from './CardGrid';
export type { CardGridProps, CardGridItem } from './CardGrid.types';

6. Static vs Interactive Component Decision

Section titled “6. Static vs Interactive Component Decision”
Interactive ('use client' in .client.tsx)Static (Server Component, default)
M01 Header (scroll, mobile menu)M02 Footer
M03 Booking Bar (Navarino widget)M04 Page Hero
M05 Hero Slider (carousel)M06 Rich Text Block
M11 Image Gallery (lightbox)M07 Image + Text
M13 Accordion (expand/collapse)M08 Card Grid
M17 Form Module (validation)M09 Featured Cards

Rule: Default to Static (Server Component). Only add 'use client' when the component needs browser APIs, event handlers, or state. This is a frontend rendering decision — both types require the full backend phase (Phase A).


  • Semantic HTML (&lt;section&gt;, &lt;article&gt;, &lt;nav&gt;, &lt;h2&gt;-&lt;h4&gt;)
  • Images have descriptive alt text (or alt="" if decorative)
  • Interactive elements reachable via keyboard (Tab, Enter, Space)
  • Visible focus indicator (:focus-visible from global styles)
  • Color contrast meets 4.5:1 ratio
  • ARIA attributes where native HTML is insufficient
  • No content conveyed only through color

For Client Components, additionally:

  • Focus trapped in modals/overlays
  • aria-expanded on toggles (accordion, dropdown)
  • aria-live for dynamic content updates
  • Autoplay has pause control

FileNaming PatternExample
Folderm{XX}-{kebab-name}m08-card-grid
Component{PascalName}.tsxCardGrid.tsx
Styles{PascalName}.scssCardGrid.scss
Types{PascalName}.types.tsCardGrid.types.ts
Mapper{PascalName}.mapper.tsCardGrid.mapper.ts
Stories{PascalName}.stories.tsxCardGrid.stories.tsx
Tests{PascalName}.test.tsxCardGrid.test.tsx
Client{PascalName}.client.tsxCardGrid.client.tsx
Exportindex.tsindex.ts
BEM block.{kebab-name}.card-grid
Registry key{camelCase}cardGrid
Storybook titleModules/M{XX} — {Name}Modules/M08 — Card Grid

Run the mapper against the actual JSON from step A4 (not just the mock data from stories). If any property is missing or misnamed, fix the mapper OR coordinate with BE to update the Element Type.

Render the module on at least 2 different hotel sites:

  • The default site (savoy-signature) — baseline
  • One themed site (e.g., savoy-palace) — verify token-based theming works
  1. Publish content containing the module in Umbraco
  2. Confirm the webhook fires and Cloudflare purges the page URL
  3. Confirm the page re-renders with updated content