M27 — Text Image: 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/m10-highlight/ (reference implementation — similar two-column module)
- packages/modules/src/registry.ts
- packages/themes/src/_base.css
- packages/themes/src/savoy-palace.css (reference for module token pattern)
- packages/ui/src/ (scan for available UI components, especially ResponsiveImage)
1. Module Overview
Section titled “1. Module Overview”| Field | Value |
|---|---|
| Module ID | M27 |
| Module Name | Text Image |
| Folder | m27-text-image |
| PascalCase | TextImage |
| camelCase (Registry Key / Umbraco Alias) | textImage |
| BEM Block | .text-image |
| Component Type | Static (Server Component — no interactivity, no client-side state) |
Description: A split-layout content module pairing a text column (optional label, heading, body, CTA) with a large square image. Supports image placement on the left or right side, and two background colour variants (light/white and medium/tinted). The image container border-radius is theme-driven via CSS custom properties, producing square images for some hotels, rounded corners for Calheta Beach, and an asymmetric large corner for Hotel Next — all without any prop or class change.
Figma Links:
User Story / Task: TBD (Zoho Project)
2. Pre-Flight Checklist
Section titled “2. Pre-Flight Checklist”- Module ID assigned: M27
- Module name defined: Text Image
- Figma desktop link available (primary + 6 theme variations)
- Figma mobile link available
- Static Component decision made (no interactivity)
- Image variants identified: 1 image slot requiring imageDesktop + imageMobile
- Accessibility requirements noted: semantic heading tag, descriptive alt text, CTA as
<a>link - User Story / task link available (Zoho Project)
- SVG thumbnail created for Block List picker (
wwwroot/assets/thumbnails/m27-text-image.svg)
3. Design Analysis — Variant Inventory
Section titled “3. Design Analysis — Variant Inventory”3.1 Layout Variants (from Figma Component Props)
Section titled “3.1 Layout Variants (from Figma Component Props)”The Figma component M27-textimage-desktop exposes these props:
| Prop | Values | Description |
|---|---|---|
background | "Light" / "Medium" | Light = white page background; Medium = tinted alt background |
imagePosition | "Right" / "Left" | Which side the image appears on |
label | true / false | Whether the uppercase label is shown |
title | true / false | Whether the heading is shown |
body | true / false | Whether the body text is shown |
cta | true / false | Whether the CTA button is shown |
This produces 4 primary layout combinations (2 backgrounds x 2 image positions), each with any subset of label/title/body/cta visible or hidden.
3.2 Image Container Shape Variants (Theme-Driven)
Section titled “3.2 Image Container Shape Variants (Theme-Driven)”The Figma containers-textimage component uses Hotel and Type props to control the image container shape. This is the KEY visual differentiator across themes:
| Theme(s) | Container Shape | Token Value |
|---|---|---|
| Savoy Signature, Savoy Palace, Royal Savoy, The Reserve, Gardens | Square, no radius | --radius-container-image: 0 |
| Saccharum — Default | Square, no radius | --radius-container-image: 0 |
| Saccharum — Cut-Top | Large concave cut at top corners | See open questions below |
| Saccharum — Cut-Bottom | Large concave cut at bottom corners | See open questions below |
| Calheta Beach | Rounded corners (~24px all corners) | --radius-container-image: 24px |
| Hotel Next | Large round corner top-left only (~100px) | --radius-container-image: 100px 0 0 0 |
Implementation: The image container border-radius is controlled by a single CSS custom property --radius-container-image defined in each theme file. The component renders identically across all themes — only the token value changes. No props, no modifier classes, no conditional logic. This is pure CSS theming.
3.3 Background Colour Variants
Section titled “3.3 Background Colour Variants”| Variant | Figma Token Path | Savoy Palace Value | CSS Token |
|---|---|---|---|
| Light | colour/surface/background-page/white | #ffffff (white) | var(--color-text-image-bg-light) |
| Medium | colour/surface/background-page/light | #ede9e2 (warm beige) | var(--color-text-image-bg-medium) |
The “Medium” tint colour varies by theme — Calheta Beach uses a light blue-green, Hotel Next uses a light blue. All controlled via the same token.
3.4 Button Variant per Background
Section titled “3.4 Button Variant per Background”| Background | Figma Button Component | Purpose |
|---|---|---|
| Light | Button_Light | Button styled for white backgrounds |
| Medium | Button_Medium | Button styled for tinted backgrounds |
In the Figma designs, both buttons resolve to the same visual (gold fill, white text) in most themes, but they use different semantic token paths to allow themes to differentiate. The implementation uses a single pair of tokens (--color-text-image-btn-bg, --color-text-image-btn-text) applied via the --bg-medium modifier class context if differentiation is ever needed.
4. Content Structure & Props
Section titled “4. Content Structure & Props”export type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'p';
export interface TextImageImageSource { /** Image URL */ url: string; /** Image intrinsic width in pixels */ width: number; /** Image intrinsic height in pixels */ height: number; /** Optional focal point from Umbraco Image Cropper */ focalPoint?: { top: number; left: number };}
export interface TextImageCta { /** Button text label */ label: string; /** Link destination URL */ href: string;}
export interface TextImageProps { /** Optional uppercase label above the heading (e.g., "Accommodation", "Fine Dining") */ label?: string;
/** Main heading text */ title?: string;
/** HTML element for the title (default: 'h2') */ titleTag?: HeadingTag;
/** Body paragraph text */ body?: string;
/** Desktop image source — REQUIRED */ imageDesktop: TextImageImageSource;
/** Mobile image source — REQUIRED */ imageMobile: TextImageImageSource;
/** Descriptive alt text for the image — REQUIRED */ imageAlt: string;
/** Which side the image appears on: 'right' (default) or 'left' */ imagePosition?: 'right' | 'left';
/** Background colour variant: 'light' (white, default) or 'medium' (tinted alt) */ background?: 'light' | 'medium';
/** Optional CTA button */ cta?: TextImageCta;
/** Site key — injected by ModuleRenderer */ siteKey: string;
/** Locale — injected by ModuleRenderer */ locale: string;}| Field | Required | Default | Notes |
|---|---|---|---|
imageDesktop | Yes | — | Desktop image source with url, width, height |
imageMobile | Yes | — | Mobile image source with url, width, height |
imageAlt | Yes | — | Must be descriptive (content image, not decorative) |
siteKey | Yes | — | Injected by ModuleRenderer |
locale | Yes | — | Injected by ModuleRenderer |
label | No | undefined | Uppercase label above heading |
title | No | undefined | Main heading |
titleTag | No | 'h2' | Semantic heading level |
body | No | undefined | Body paragraph |
imagePosition | No | 'right' | Image placement |
background | No | 'light' | Background variant |
cta | No | undefined | CTA with label + href |
5. Layout Specification
Section titled “5. Layout Specification”5.1 Desktop Layout (1440px Viewport)
Section titled “5.1 Desktop Layout (1440px Viewport)”+------------------------------------------------------------------+| 120px padding 120px || +----------------------------------------------------------+ || | max-width: 1200px, centered | || | | || | +--- Text Column (flex: 1) ---+ 76px +--- Image ---+ | || | | LABEL (uppercase) | gap | 588 x 588 | | || | | TITLE (48px heading) | | square | | || | | | | themed | | || | | 64px indent: | | border- | | || | | Body text (18px) | | radius | | || | | | | | | || | | [CTA BUTTON] | | | | || | +-----------------------------+ +-------------+ | || | | || +----------------------------------------------------------+ || |+------------------------------------------------------------------+| Property | Value | CSS Token / Implementation |
|---|---|---|
| Section vertical padding | 80px top and bottom | padding-block: var(--space-20) |
| Section horizontal padding | 120px left and right | padding-inline: 120px (lateral padding from Figma; the inner container provides the effective max-width) |
| Inner container max-width | 1200px | max-width: var(--container-max, 1200px) |
| Inner container centering | Centered | margin-inline: auto |
| Flex direction | Row | display: flex; flex-direction: row |
| Gap between text and image | 76px | Custom gap: gap: 76px (Figma value; falls between --space-16 64px and --space-20 80px) |
| Vertical alignment | Center | align-items: center |
| Image container size | 588px wide, square | width: 588px; flex-shrink: 0; aspect-ratio: 1 |
| Text column | Fills remaining space | flex: 1 1 0; min-width: 0 |
| Column order (image right) | Text first, image second | Default DOM order |
| Column order (image left) | Image first, text second | CSS order or reversed DOM with flex-direction: row-reverse |
Text Column Internal Spacing (Desktop):
| Element | Spacing Rule | Token |
|---|---|---|
| Label to title gap | 24px | gap: var(--space-6) |
| Header group (label+title) to body group | 24px | gap: var(--space-6) |
| Body text left indent | 64px | padding-left: var(--space-16) |
| Body to CTA gap | 40px | gap: var(--space-10) |
5.2 Mobile Layout (360-375px Viewport)
Section titled “5.2 Mobile Layout (360-375px Viewport)”+------------------------------------+| 24px padding 24px || +------------------------------+ || | LABEL (uppercase, 16px) | || | TITLE (32px heading) | || | | || | Body text (18px) | || | (no indent) | || | | || | [CTA BUTTON — full width] | || | | || | +------------------------+ | || | | Image (full width) | | || | | square 1:1 ratio | | || | | themed radius | | || | +------------------------+ | || +------------------------------+ |+------------------------------------+| Property | Value | CSS Token / Implementation |
|---|---|---|
| Section vertical padding | 64px | padding-block: var(--space-16) |
| Section horizontal padding | 24px | padding-inline: var(--space-6) |
| Layout direction | Column (stacked) | flex-direction: column (base/mobile styles) |
| Text-to-image gap | 48px | gap: var(--space-12) |
| Image width | Full container width | width: 100% |
| Image aspect ratio | 1:1 square | aspect-ratio: 1 |
| Body text indent | 0 (no indent) | padding-left: 0 |
| Label-to-title gap | 16px | gap: var(--space-4) |
| Body-to-CTA gap | 32px | gap: var(--space-8) |
| CTA button width | Full width | width: 100% |
CRITICAL: On mobile, the text ALWAYS comes first and the image ALWAYS comes second (below), regardless of the imagePosition prop. The left/right image position only takes effect at md (768px) and above.
5.3 Responsive Breakpoint Behaviour
Section titled “5.3 Responsive Breakpoint Behaviour”| Breakpoint | Layout Change |
|---|---|
| Base (< 768px) | Single column. Text above, image below. Full-width CTA. No body indent. imagePosition prop has no visual effect. |
| md (768px) | Transition to two-column row layout. imagePosition prop activates. Body gets 64px left indent. CTA shrinks to intrinsic width. Image is a fixed-width square at ~49% of container. |
| lg (1024px) | Full desktop proportions. Gap between columns widens to 76px. All spacing at full desktop values. |
| xl (1280px) | No further changes; container max-width (1200px) prevents further growth. |
| 2xl (1440px) | Lateral whitespace grows as viewport exceeds container max-width. Design matches Figma precisely at this width. |
6. Visual Design Specification
Section titled “6. Visual Design Specification”6.1 Typography
Section titled “6.1 Typography”| Element | Figma Style Name | Font Family | Size (Desktop) | Size (Mobile) | Weight | Line Height | Colour Token | Extra |
|---|---|---|---|---|---|---|---|---|
| Label | Labels/Label M | var(--font-body) | var(--text-base) (16px) | var(--text-base) (16px) | var(--font-weight-bold) (700) | 20px | var(--color-text-image-label) | text-transform: uppercase; letter-spacing: 0 |
| Title | Titles/H5 | var(--font-heading) | var(--text-5xl) (48px) | var(--text-4xl) (36px) | var(--font-weight-medium) (500) | 52px desktop / 40px mobile | var(--color-text-image-heading) | — |
| Body | Body Texts/Body M - Book | var(--font-body) | var(--text-lg) (18px) | var(--text-lg) (18px) | var(--font-weight-normal) (400) | 24px / var(--leading-normal) | var(--color-text-image-body) | — |
| CTA Text | Buttons/Button M | var(--font-body) | var(--text-base) (16px) | var(--text-base) (16px) | var(--font-weight-bold) (700) | 24px | var(--color-text-image-btn-text) | text-transform: uppercase; white-space: nowrap (desktop only) |
6.2 Colours — Module-Specific Tokens
Section titled “6.2 Colours — Module-Specific Tokens”Every theme file must define these tokens. Values below are from the Savoy Palace Figma (reference theme):
| Token | Savoy Palace Value | Purpose |
|---|---|---|
--color-text-image-bg-light | #ffffff | Light background variant |
--color-text-image-bg-medium | #ede9e2 | Medium/tinted background variant |
--color-text-image-label | #59437d | Label text colour |
--color-text-image-heading | #977e54 | Heading text colour |
--color-text-image-body | #000000 | Body text colour |
--color-text-image-btn-bg | #7d6946 | CTA button fill |
--color-text-image-btn-text | #ffffff | CTA button text |
--radius-container-image | 0 | Image container border-radius (theme-driven) |
6.3 CTA Button Specification
Section titled “6.3 CTA Button Specification”| Property | Value | Implementation |
|---|---|---|
| Background | Gold/brown | background-color: var(--color-text-image-btn-bg) |
| Text colour | White | color: var(--color-text-image-btn-text) |
| Height | 48px | height: 48px |
| Horizontal padding | 24px | padding-inline: var(--space-6) |
| Vertical padding | 14px | padding-block: 14px |
| Border radius | Fully rounded pill | border-radius: var(--radius-full) (9999px) |
| Min width (desktop) | 40px | min-width: 40px |
| Width (mobile) | Full container width | width: 100% at base, width: auto at md+ |
| Hover state | Subtle darkening | &:hover { filter: brightness(0.9) } (pending design team confirmation) |
| Text | Uppercase, bold | See typography table above |
| Display | Inline flex, centered | display: inline-flex; align-items: center; justify-content: center |
| Element type | <a> link | Not a <button> — this navigates to a URL |
| Overflow | Hidden | overflow: hidden |
6.4 Image Specification
Section titled “6.4 Image Specification”| Property | Desktop | Mobile |
|---|---|---|
| Aspect ratio | 1:1 (square) | 1:1 (square) |
| Container size | 588px x 588px | Full width x auto (aspect-ratio enforced) |
| Object fit | cover | cover |
| Border radius | var(--radius-container-image) | var(--radius-container-image) |
| Overflow | hidden (clips to border-radius) | hidden |
| Recommended upload size | 960x960 (retina) | 750x750 |
| Priority loading | false (not hero/LCP) | false |
7. BEM Class Map
Section titled “7. BEM Class Map”.text-image Block: root <section> element.text-image--bg-light Modifier: white/light background (default).text-image--bg-medium Modifier: tinted/alt background.text-image--image-right Modifier: image on right side (default).text-image--image-left Modifier: image on left side.text-image__container Element: inner max-width wrapper (1200px centered).text-image__row Element: flex row container (row on desktop, column on mobile).text-image__content Element: text column wrapper.text-image__header Element: group containing label + title.text-image__label Element: uppercase label <p>.text-image__title Element: heading (h2 by default, configurable tag).text-image__body-group Element: wrapper for body + CTA (has left indent on desktop).text-image__body Element: body paragraph <p>.text-image__cta Element: CTA pill button <a>.text-image__image-wrap Element: image container (controls aspect-ratio, overflow, border-radius)Modifier Usage on Root <section>:
- Background:
.text-image--bg-lightor.text-image--bg-medium - Image position:
.text-image--image-rightor.text-image--image-left - Both modifiers are always present (one from each axis)
Example: <section class="text-image text-image--bg-medium text-image--image-left" data-module="textImage" data-module-id="M27">
8. Accessibility Checklist
Section titled “8. Accessibility Checklist”- Root element:
<section data-module="textImage" data-module-id="M27">— semantic sectioning element - Heading: Configurable tag via
titleTagprop (defaulth2), ensuring correct document outline on every page - Image:
imageAltprop is REQUIRED (not optional) — this is a content image, not decorative - Image uses
ResponsiveImagefrom@savoy/uiwithaltattribute - CTA: Rendered as
<a href="...">element — natively keyboard accessible (Tab + Enter) - CTA: Visible
:focus-visibleoutline (inherits global focus styles) - Label:
<p>element in normal text flow for screen readers - Colour contrast: Label on white (purple on white = 7.5:1+), heading on white (gold on white = verify per theme), body on white (black on white = 21:1), button text on gold (white on gold = verify per theme)
- No information conveyed by colour alone — all text is readable without colour
- Reading order: DOM order matches visual order (text first, then image on mobile; text + image in logical order on desktop)
9. Storybook Requirements
Section titled “9. Storybook Requirements”Story title: Modules/M27 — Text Image
Parameters
Section titled “Parameters”parameters: { layout: 'fullscreen', design: { type: 'figma', url: 'https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4020&m=dev', }, docs: { description: { component: [ '## Text Image', '', 'A split-layout content module pairing a text column with a large square image.', 'Supports image placement on left or right, and light or medium (tinted) background variants.', 'The image container shape is fully theme-driven via CSS custom properties.', '', '### Key Features', '- Two-column layout on desktop (text + square image), single-column stacked on mobile', '- Image position: left or right (desktop only; mobile always stacks text-first)', '- Background variants: light (white) and medium (tinted, theme-specific colour)', '- All text elements optional: label, title, body, CTA can each be toggled', '- Image container border-radius varies by theme (square, rounded, asymmetric corner)', '- Pill-shaped CTA button, full-width on mobile, auto-width on desktop', '- Body text has 64px left indent on desktop, no indent on mobile', '- Fully themed via CSS custom properties for all 8 hotel sites', '', '### Links', '- [Figma Desktop — Primary](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4020&m=dev)', '- [Figma Desktop — All Variants](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4019&m=dev)', '- [Figma Mobile](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=908-9973&m=dev)', '- [Figma Mobile — All Variants](https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=908-9972&m=dev)', '- [User Story](TBD)', ].join('\\n'), }, },}Required Story Variants
Section titled “Required Story Variants”| Story Name | background | imagePosition | Content | Purpose |
|---|---|---|---|---|
Default | 'light' | 'right' | All props filled | Primary design state |
ImageLeft | 'light' | 'left' | All props filled | Reversed layout |
MediumBackground | 'medium' | 'right' | All props filled | Tinted background |
MediumBackgroundImageLeft | 'medium' | 'left' | All props filled | Tinted + reversed |
MinimalContent | 'light' | 'right' | Image + alt only (no label, title, body, CTA) | Tests graceful empty text column |
EmptyState | 'light' | 'right' | Empty image URLs, empty alt | Tests rendering with no data |
LongContent | 'light' | 'right' | Very long title, long body, long CTA label | Tests text overflow and wrapping |
AllOptions | 'medium' | 'left' | Every prop filled with rich content | Confirms all options work together |
WithoutCTA | 'light' | 'right' | Label + title + body, no CTA | Tests layout without button |
WithoutLabel | 'light' | 'right' | Title + body + CTA, no label | Tests layout without label |
TitleOnly | 'light' | 'right' | Only title + image | Tests minimal text content |
Mock Data (Realistic Hotel Content)
Section titled “Mock Data (Realistic Hotel Content)”const mockImageDesktop = { url: '/storybook/room-desktop.jpg', width: 960, height: 960,};
const mockImageMobile = { url: '/storybook/room-mobile.jpg', width: 750, height: 750,};
const defaultArgs: TextImageProps = { label: 'Accommodation', title: 'Discover our world of refined luxury and comfort', body: 'Savoy Palace is inspired by all the beauty and uniqueness that Madeira Island grows and offers. Our selection of accommodations is a reflection of this sublime perfection. Each space has been thoughtfully designed to provide an unforgettable experience, blending contemporary design with the island\'s natural elegance.', imageDesktop: mockImageDesktop, imageMobile: mockImageMobile, imageAlt: 'Luxury suite with ocean view terrace at Savoy Palace, Madeira', imagePosition: 'right', background: 'light', cta: { label: 'Explore Rooms', href: '/rooms' }, siteKey: 'savoy-palace', locale: 'pt',};Additional mock data for AllOptions variant:
const allOptionsArgs: TextImageProps = { label: 'Fine Dining', title: 'A culinary journey through the flavours of Madeira', body: 'Our award-winning restaurants offer a gastronomic experience that celebrates the rich flavours of the island, from traditional Madeiran cuisine to innovative international dishes prepared by our world-class chefs. Every meal is an occasion, every dish a masterpiece crafted from locally sourced ingredients.', imageDesktop: { url: '/storybook/restaurant-desktop.jpg', width: 960, height: 960 }, imageMobile: { url: '/storybook/restaurant-mobile.jpg', width: 750, height: 750 }, imageAlt: 'Elegant restaurant interior with ocean views at Savoy Palace', imagePosition: 'left', background: 'medium', cta: { label: 'Reserve a Table', href: '/dining/reservations' }, siteKey: 'savoy-palace', locale: 'pt',};argTypes configuration:
argTypes: { siteKey: { table: { disable: true } }, locale: { table: { disable: true } }, titleTag: { control: 'select', options: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'p'], description: 'HTML element for the title', table: { defaultValue: { summary: 'h2' } }, }, imagePosition: { control: 'radio', options: ['right', 'left'], description: 'Image placement relative to text', table: { defaultValue: { summary: 'right' } }, }, background: { control: 'radio', options: ['light', 'medium'], description: 'Background colour variant', table: { defaultValue: { summary: 'light' } }, },},10. File Structure
Section titled “10. File Structure”packages/modules/src/m27-text-image/ index.ts Named exports TextImage.tsx Server Component (the module itself) TextImage.scss BEM + SASS styles TextImage.types.ts Props interface + supporting types TextImage.mapper.ts Umbraco JSON -> component props TextImage.stories.tsx Storybook stories (11 variants) TextImage.test.tsx Vitest tests (mapper + rendering)NO .client.tsx file — this is a Static Server Component.
Registry Entry
Section titled “Registry Entry”Add to packages/modules/src/registry.ts:
import { TextImage } from './m27-text-image';import { mapTextImage } from './m27-text-image/TextImage.mapper';
// In the moduleRegistry object:textImage: { component: TextImage, mapper: mapTextImage as ModuleRegistryEntry["mapper"], moduleId: "M27" },Export (index.ts)
Section titled “Export (index.ts)”export { TextImage } from './TextImage';export type { TextImageProps, TextImageImageSource, TextImageCta } from './TextImage.types';11. CMS Integration
Section titled “11. CMS Integration”11.1 Element Type Schema
Section titled “11.1 Element Type Schema”Element Type alias: textImage
| Property | Alias | Editor | Required | Tab | Varies by Culture |
|---|---|---|---|---|---|
| Label | label | Textstring | No | Content | Yes |
| Title | title | Textstring | No | Content | Yes |
| Title Tag | titleTag | Dropdown (h1, h2, h3, h4, h5, h6) | No | Settings | No |
| Body | body | Textarea | No | Content | Yes |
| CTA Label | ctaLabel | Textstring | No | Content | Yes |
| CTA Link | ctaLink | URL Picker | No | Content | Yes |
| Image Position | imagePosition | Dropdown (right, left) | No | Settings | No |
| Background | background | Dropdown (light, medium) | No | Settings | No |
| Image Desktop | imageDesktop | Media Picker (Image) | Yes | Images | No |
| Image Mobile | imageMobile | Media Picker (Image) | Yes | Images | No |
| Image Alt Text | imageAlt | Textstring | Yes | Images | Yes |
11.2 Mapper Specification
Section titled “11.2 Mapper Specification”import type { TextImageProps } from './TextImage.types';
export function mapTextImage( element: Record<string, unknown>): Omit<TextImageProps, 'siteKey' | 'locale'> { const props = element as Record<string, any>;
return { label: props.label || undefined, title: props.title || undefined, titleTag: props.titleTag || 'h2', body: props.body || undefined, imageDesktop: { url: props.imageDesktop?.url ?? '', width: props.imageDesktop?.width ?? 960, height: props.imageDesktop?.height ?? 960, focalPoint: props.imageDesktop?.focalPoint ? { top: props.imageDesktop.focalPoint.top, left: props.imageDesktop.focalPoint.left } : undefined, }, imageMobile: { url: props.imageMobile?.url ?? '', width: props.imageMobile?.width ?? 750, height: props.imageMobile?.height ?? 750, focalPoint: props.imageMobile?.focalPoint ? { top: props.imageMobile.focalPoint.top, left: props.imageMobile.focalPoint.left } : undefined, }, imageAlt: props.imageAlt || '', imagePosition: props.imagePosition === 'left' ? 'left' : 'right', background: props.background === 'medium' ? 'medium' : 'light', cta: props.ctaLabel && props.ctaLink ? { label: props.ctaLabel, href: typeof props.ctaLink === 'string' ? props.ctaLink : props.ctaLink?.url || '' } : undefined, };}11.3 Block List Thumbnail
Section titled “11.3 Block List Thumbnail”Create SVG at: apps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m27-text-image.svg
- Format: SVG,
viewBox="0 0 400 250", wireframe style - Content: Left side shows stacked text lines (label, heading, body, button pill), right side shows a square image placeholder
- Palette:
#381e63(primary),#977e54(heading gold),#7d6946(button gold),#ede9e2(background tint)
12. Theme Token Additions
Section titled “12. Theme Token Additions”Each theme CSS file needs the following tokens added. Values are extracted from the Figma designs.
savoy-palace.css
Section titled “savoy-palace.css”/* Text Image module tokens */--color-text-image-bg-light: #ffffff;--color-text-image-bg-medium: #ede9e2;--color-text-image-label: #59437d;--color-text-image-heading: #977e54;--color-text-image-body: #000000;--color-text-image-btn-bg: #7d6946;--color-text-image-btn-text: #ffffff;--radius-container-image: 0;saccharum.css
Section titled “saccharum.css”/* Text Image module tokens */--color-text-image-bg-light: #ffffff;--color-text-image-bg-medium: #ede9e2;--color-text-image-label: #59437d;--color-text-image-heading: #977e54;--color-text-image-body: #000000;--color-text-image-btn-bg: #7d6946;--color-text-image-btn-text: #ffffff;--radius-container-image: 0;calheta-beach.css
Section titled “calheta-beach.css”/* Text Image module tokens */--color-text-image-bg-light: #ffffff;--color-text-image-bg-medium: #d8eeeb;--color-text-image-label: #1a1a1a;--color-text-image-heading: #977e54;--color-text-image-body: #000000;--color-text-image-btn-bg: #7d6946;--color-text-image-btn-text: #ffffff;--radius-container-image: 24px;hotel-next.css
Section titled “hotel-next.css”/* Text Image module tokens */--color-text-image-bg-light: #ffffff;--color-text-image-bg-medium: #f2efe9;--color-text-image-label: #59437d;--color-text-image-heading: #977e54;--color-text-image-body: #000000;--color-text-image-btn-bg: #7d6946;--color-text-image-btn-text: #ffffff;--radius-container-image: 100px 0 0 0;savoy-signature.css, royal-savoy.css, the-reserve.css, gardens.css
Section titled “savoy-signature.css, royal-savoy.css, the-reserve.css, gardens.css”/* Text Image module tokens */--color-text-image-bg-light: #ffffff;--color-text-image-bg-medium: #f2efe9;--color-text-image-label: #59437d;--color-text-image-heading: #977e54;--color-text-image-body: #000000;--color-text-image-btn-bg: #7d6946;--color-text-image-btn-text: #ffffff;--radius-container-image: 0;Note: Token values for themes not individually shown in the Figma designs (Royal Savoy, The Reserve, Gardens) are extrapolated from the default/Savoy Palace values. When the design team provides theme-specific Figma files for those hotels, update tokens accordingly.
13. Development Workflow
Section titled “13. Development Workflow”Phase A — Storybook
Section titled “Phase A — Storybook”- Scaffold files — Create all 7 files in
packages/modules/src/m27-text-image/ - Define types — Write
TextImage.types.tsfirst (copy the interface from Section 4 above) - Write stories — Write
TextImage.stories.tsxwith all 11 variants and realistic mock data (Section 9) - Implement component — Build
TextImage.tsx(Server Component) +TextImage.scss(BEM + tokens) - Add theme tokens — Add the CSS custom properties from Section 12 to all 8 theme files
- Validate in Storybook — Check all 11 stories across all 8 themes at 375px, 768px, 1024px, 1440px
Phase B — React / Next.js
Section titled “Phase B — React / Next.js”- Write mapper —
TextImage.mapper.ts(Section 11.2) - Write tests —
TextImage.test.tsxcovering mapper transformation and component rendering - Register — Add entry to
packages/modules/src/registry.ts - Export — Create
index.tswith named exports
Phase C — Umbraco / CMS
Section titled “Phase C — Umbraco / CMS”- Define Element Type schema — Properties, aliases, editors, tabs per Section 11.1
- Create SVG thumbnail —
apps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m27-text-image.svg - Create Element Type — In Umbraco backoffice (migration or manual)
- Configure Block List — Add as allowed block on target page Document Types
- Create test content — Verify Content Delivery API output
- Validate mapper — Test mapper against real API JSON response
- Test multi-site — Verify in savoy-signature + savoy-palace (minimum)
- Verify cache purge — Confirm webhook fires on publish
14. Validation Criteria
Section titled “14. Validation Criteria”Pixel-Perfect Match
Section titled “Pixel-Perfect Match”- Desktop: Two-column layout with text on one side and square image on the other
- Desktop: 80px vertical padding top and bottom
- Desktop: Inner content area is 1200px max-width, centered
- Desktop: 76px gap between text column and image column
- Desktop: Image is exactly square (1:1 aspect ratio), 588px in Figma at 1440px viewport
- Desktop: Body text has 64px left indent (padding-left)
- Desktop: Label is uppercase, bold, 16px, themed label colour
- Desktop: Title is heading font, 48px, medium weight, themed heading colour
- Desktop: Body is body font, 18px, normal weight, themed body colour
- Desktop: CTA is pill-shaped (full radius), 48px height, 24px horizontal padding, uppercase bold
- Mobile: Full-width stacked layout, text above image
- Mobile: 64px vertical padding, 24px horizontal padding
- Mobile: Title scales to 36px (or 32px per exact Figma measurement)
- Mobile: CTA button spans full container width
- Mobile: No body text indent
- Mobile: Image is full-width square
- Image-left variant: image appears on left, text on right (desktop only)
- Medium background: tinted background colour visible, correct per theme
Responsive Behaviour
Section titled “Responsive Behaviour”- Smooth transition from column (mobile) to row (desktop) at md breakpoint (768px)
-
imagePositionprop only affects layout at md+ breakpoints - No horizontal scroll at any viewport width from 320px to 2560px
- Content does not clip or overflow at intermediate widths (480px, 600px, 900px, 1100px)
- Text wraps gracefully at all widths — no truncation or ellipsis
- Image maintains 1:1 aspect ratio at every viewport width
Theme Compatibility
Section titled “Theme Compatibility”- Renders correctly with
data-theme="savoy-signature"(square image) - Renders correctly with
data-theme="savoy-palace"(square image) - Renders correctly with
data-theme="royal-savoy"(square image) - Renders correctly with
data-theme="saccharum"(square image) - Renders correctly with
data-theme="the-reserve"(square image) - Renders correctly with
data-theme="calheta-beach"(rounded image corners ~24px) - Renders correctly with
data-theme="gardens"(square image) - Renders correctly with
data-theme="hotel-next"(large top-left corner radius ~100px) - Medium background variant shows correct tinted colour per theme
- All text colours (label, heading, body, button) are correct per theme
Performance
Section titled “Performance”- Server-rendered (no
'use client', no client-side JS bundle for this module) - Image uses
ResponsiveImagefrom@savoy/uiwith desktop + mobile sources - Image does NOT have
priority={true}(not a hero/LCP image) - No layout shift from image loading (
aspect-ratio: 1set in CSS) - CSS uses only custom properties — no hardcoded values
15. SCSS Implementation Guide
Section titled “15. SCSS Implementation Guide”Below is the recommended SCSS structure. This is a guide for the implementing developer, not final code.
.text-image { padding-block: var(--space-16); // 64px mobile padding-inline: var(--space-6); // 24px mobile
@media (min-width: 768px) { padding-block: var(--space-20); // 80px desktop padding-inline: 120px; }
// Background modifiers &--bg-light { background-color: var(--color-text-image-bg-light); }
&--bg-medium { background-color: var(--color-text-image-bg-medium); }
// Container &__container { width: 100%; max-width: 1200px; // var(--container-max) if defined margin-inline: auto; }
// Row: column on mobile, row on desktop &__row { display: flex; flex-direction: column; gap: var(--space-12); // 48px mobile align-items: center;
@media (min-width: 768px) { flex-direction: row; gap: 76px; } }
// Image-left modifier reverses row on desktop &--image-left &__row { @media (min-width: 768px) { flex-direction: row-reverse; } }
// Text content column &__content { display: flex; flex-direction: column; gap: var(--space-6); // 24px
@media (min-width: 768px) { flex: 1 1 0; min-width: 0; } }
// Header (label + title) &__header { display: flex; flex-direction: column; gap: var(--space-4); // 16px mobile
@media (min-width: 768px) { gap: var(--space-6); // 24px desktop } }
// Label &__label { font-family: var(--font-body); font-size: var(--text-base); font-weight: var(--font-weight-bold); line-height: 20px; color: var(--color-text-image-label); text-transform: uppercase; letter-spacing: 0; margin: 0; }
// Title &__title { font-family: var(--font-heading); font-size: var(--text-4xl); // 36px mobile font-weight: var(--font-weight-medium); line-height: 1.1; color: var(--color-text-image-heading); margin: 0;
@media (min-width: 768px) { font-size: var(--text-5xl); // 48px desktop line-height: 52px; } }
// Body group (indented on desktop) &__body-group { display: flex; flex-direction: column; gap: var(--space-8); // 32px mobile
@media (min-width: 768px) { padding-left: var(--space-16); // 64px indent gap: var(--space-10); // 40px desktop } }
// Body text &__body { font-family: var(--font-body); font-size: var(--text-lg); font-weight: var(--font-weight-normal); line-height: var(--leading-normal); color: var(--color-text-image-body); margin: 0; }
// CTA button &__cta { display: inline-flex; align-items: center; justify-content: center; width: 100%; // full-width mobile height: 48px; padding-inline: var(--space-6); padding-block: 14px; background-color: var(--color-text-image-btn-bg); color: var(--color-text-image-btn-text); font-family: var(--font-body); font-size: var(--text-base); font-weight: var(--font-weight-bold); line-height: 24px; text-transform: uppercase; text-decoration: none; border: none; border-radius: var(--radius-full); min-width: 40px; overflow: hidden; cursor: pointer; transition: filter var(--transition-fast);
@media (min-width: 768px) { width: auto; white-space: nowrap; }
&:hover { filter: brightness(0.9); } }
// Image wrapper &__image-wrap { width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: var(--radius-container-image, 0); flex-shrink: 0;
@media (min-width: 768px) { width: 588px; }
img { width: 100%; height: 100%; object-fit: cover; } }}16. Open Questions for Design Team
Section titled “16. Open Questions for Design Team”- Saccharum Cut-Top / Cut-Bottom variants: Are these distinct CMS-selectable configurations that content editors can choose, or are they Figma design explorations only? If selectable, a
containerShapeprop and per-corner radius tokens are needed. - Exact theme colours for Royal Savoy, The Reserve, and Gardens: Are they identical to the default/Savoy Signature values, or do they have unique colour palettes for this module?
- CTA hover state: Figma does not show a hover state for the button. Should it darken, lighten, add a shadow, or use opacity? Current recommendation:
filter: brightness(0.9)on hover. - Medium background button differentiation: Figma uses
Button_Lighton light backgrounds andButton_Mediumon medium backgrounds. Are these ever visually different in any theme? If so, separate token pairs (--color-text-image-btn-bg-light/--color-text-image-btn-bg-medium) are needed. - Title line height: Figma shows 52px line-height for 48px text on desktop. Should mobile (32-36px text) use a proportional line-height or a fixed value?
17. Common Pitfalls to Avoid
Section titled “17. Common Pitfalls to Avoid”- Hardcoding colours, fonts, or spacing — use
var(--token-name)frompackages/themes/src/ - Forgetting
imageMobile— every image needs both desktop and mobile variants - Mapper crash on null — Umbraco sends
nullfor optional fields; always use?.and fallbacks - Wrong BEM nesting — use
&__elementinside the block, not standalone.text-image__element - Missing registry entry — module will not render on any page without it
- Desktop-first media queries — use
min-width(mobile-first), NEVERmax-width - Fixed pixel widths on containers — use
%,flex,auto,max-widthfor fluid responsiveness - Lorem ipsum in stories — use realistic hotel context (room names, restaurant descriptions)
- Missing
data-module/data-module-idattributes — root<section>MUST havedata-module="textImage"anddata-module-id="M27" - Hardcoding border-radius for image — MUST use
var(--radius-container-image)token, not a fixed value - Applying image position on mobile —
imagePositionprop must have NO effect below md breakpoint - Forgetting to add tokens to ALL 8 theme files — every theme needs the full set of Text Image tokens