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
1. Purpose
Section titled “1. Purpose”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.
2. Module Lifecycle Overview
Section titled “2. Module Lifecycle Overview”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 publish3. Pre-Flight Checklist
Section titled “3. Pre-Flight Checklist”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)
4. Phase A — Backend / CMS
Section titled “4. Phase A — Backend / CMS”Context: Read
docs/dev-backend-guides/01_UMBRACO_CONTENT_MODELING.mdbefore 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
A1. Define Element Type Schema
Section titled “A1. Define Element Type Schema”Create a property table for the module:
| Property | Alias | Editor | Required | Tab |
|---|---|---|---|---|
| Title | title | Block List (htmlHeading, single-block mode) | No | Content |
| Label | label | Block List (htmlHeading, single-block mode) | No | Content |
| Image Desktop | imageDesktop | Media Picker | Yes | Images |
| Image Mobile | imageMobile | Media Picker | Yes | Images |
Rules:
- Aliases are camelCase and immutable once content exists
- Always use
imageDesktop+imageMobilefor images (never singleimage) - Always use
htmlHeadingElement 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
A2. Create in Umbraco
Section titled “A2. Create in Umbraco”Settings → Document Types → Element Types → Create.
Set alias matching the FE registry key (e.g., cardGrid).
A3. Configure Block List
Section titled “A3. Configure Block List”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.
A4. Verify API Output
Section titled “A4. Verify API Output”- Create test content using the new Element Type in Umbraco backoffice
- Fetch the page via Content Delivery API:
GET /umbraco/delivery/api/v2/content/item/{path} - Confirm the JSON structure matches your schema — property aliases, nested blocks, media URLs
- Save a sample JSON response to use as test fixture in Step B8
5. Phase B — Frontend (Step-by-Step)
Section titled “5. Phase B — Frontend (Step-by-Step)”Step 1: Scaffold Files
Section titled “Step 1: Scaffold Files”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.tsxIf the module requires client-side interactivity, also create:
ModuleName.client.tsx # Client component wrapperStep 2: Define Types
Section titled “Step 2: Define Types”Start with the props interface. This drives everything else.
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:
siteKeyandlocaleare always present (injected by ModuleRenderer)- Image props always include
imageDesktop+imageMobilewith dimensions - Title, subtitle, and label/eyebrow fields always use
HtmlHeadingtype (never plainstring) - Optional fields use
? - Use specific union types for constrained values (
columns?: 2 | 3 | 4)
Step 3: Write Storybook Story
Section titled “Step 3: Write Storybook Story”Write stories BEFORE implementing the component. This validates the design intent:
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 variantsexport const Default: Story = { args: { /* realistic data */ } };export const MinimalContent: Story = { args: { /* minimum viable */ } };export const EmptyState: Story = { args: { cards: [], siteKey: 'savoy-palace', locale: 'pt' } };Step 4: Implement Component + SCSS
Section titled “Step 4: Implement Component + SCSS”Component (Server Component by default):
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 inputfunction 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):
.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; } }}Step 5: Write Mapper
Section titled “Step 5: Write Mapper”import { UmbracoElement } from '@savoy/cms-client';import { CardGridProps, HtmlHeading } from './CardGrid.types';
// Reusable helper — extracts HtmlHeading from Block List single-block formatfunction 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, })), };}Step 6: Register
Section titled “Step 6: Register”// In packages/modules/src/registry.ts — add entry:cardGrid: { component: CardGrid, mapper: mapCardGrid, moduleId: "M08" },Step 7: Write Tests
Section titled “Step 7: Write Tests”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):
- 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
- Desktop: 1440x900 →
- 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:
# Run all visual tests (Storybook must be running)pnpm --filter storybook visual:test
# Run for a specific storypnpm --filter storybook visual:test -- --story modules-m08-card-grid--defaultC. 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)
Step 9: Export
Section titled “Step 9: Export”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).
7. Accessibility Checklist per Module
Section titled “7. Accessibility Checklist per Module”- Semantic HTML (
<section>,<article>,<nav>,<h2>-<h4>) - Images have descriptive
alttext (oralt=""if decorative) - Interactive elements reachable via keyboard (
Tab,Enter,Space) - Visible focus indicator (
:focus-visiblefrom 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-expandedon toggles (accordion, dropdown) -
aria-livefor dynamic content updates - Autoplay has pause control
8. Module File Naming Quick Reference
Section titled “8. Module File Naming Quick Reference”| File | Naming Pattern | Example |
|---|---|---|
| Folder | m{XX}-{kebab-name} | m08-card-grid |
| Component | {PascalName}.tsx | CardGrid.tsx |
| Styles | {PascalName}.scss | CardGrid.scss |
| Types | {PascalName}.types.ts | CardGrid.types.ts |
| Mapper | {PascalName}.mapper.ts | CardGrid.mapper.ts |
| Stories | {PascalName}.stories.tsx | CardGrid.stories.tsx |
| Tests | {PascalName}.test.tsx | CardGrid.test.tsx |
| Client | {PascalName}.client.tsx | CardGrid.client.tsx |
| Export | index.ts | index.ts |
| BEM block | .{kebab-name} | .card-grid |
| Registry key | {camelCase} | cardGrid |
| Storybook title | Modules/M{XX} — {Name} | Modules/M08 — Card Grid |
9. Phase C — Integration
Section titled “9. Phase C — Integration”C1. Validate Mapper Against Real API
Section titled “C1. Validate Mapper Against Real API”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.
C2. Test in Multiple Sites
Section titled “C2. Test in Multiple Sites”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
C3. Verify Cache Purge
Section titled “C3. Verify Cache Purge”- Publish content containing the module in Umbraco
- Confirm the webhook fires and Cloudflare purges the page URL
- Confirm the page re-renders with updated content