Skip to content

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)

FieldValue
Module IDM27
Module NameText Image
Folderm27-text-image
PascalCaseTextImage
camelCase (Registry Key / Umbraco Alias)textImage
BEM Block.text-image
Component TypeStatic (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:

VariantURL
Desktop Primary (Light + Image Right)https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4020&m=dev
Mobile Primaryhttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=908-9973&m=dev
Desktop — All 4 Layout Variantshttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4019&m=dev
Mobile — All 4 Layout Variantshttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=908-9972&m=dev
Image Container Shapes (all themes)https://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4006&m=dev
Savoy Palace Themehttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4492&m=dev
Saccharum Defaulthttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=856-4381&m=dev
Saccharum Cut-Tophttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=876-9168&m=dev
Saccharum Cut-Bottomhttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=876-9169&m=dev
Calheta Beachhttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1480-143166&m=dev
Hotel Nexthttps://www.figma.com/design/VFdCObxYyZjSRFdK5pU1df/01_Savoy_library?node-id=1992-84824&m=dev

User Story / Task: TBD (Zoho Project)


  • 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.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:

PropValuesDescription
background"Light" / "Medium"Light = white page background; Medium = tinted alt background
imagePosition"Right" / "Left"Which side the image appears on
labeltrue / falseWhether the uppercase label is shown
titletrue / falseWhether the heading is shown
bodytrue / falseWhether the body text is shown
ctatrue / falseWhether 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 ShapeToken Value
Savoy Signature, Savoy Palace, Royal Savoy, The Reserve, GardensSquare, no radius--radius-container-image: 0
Saccharum — DefaultSquare, no radius--radius-container-image: 0
Saccharum — Cut-TopLarge concave cut at top cornersSee open questions below
Saccharum — Cut-BottomLarge concave cut at bottom cornersSee open questions below
Calheta BeachRounded corners (~24px all corners)--radius-container-image: 24px
Hotel NextLarge 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.

VariantFigma Token PathSavoy Palace ValueCSS Token
Lightcolour/surface/background-page/white#ffffff (white)var(--color-text-image-bg-light)
Mediumcolour/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.

BackgroundFigma Button ComponentPurpose
LightButton_LightButton styled for white backgrounds
MediumButton_MediumButton 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.


TextImage.types.ts
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;
}
FieldRequiredDefaultNotes
imageDesktopYesDesktop image source with url, width, height
imageMobileYesMobile image source with url, width, height
imageAltYesMust be descriptive (content image, not decorative)
siteKeyYesInjected by ModuleRenderer
localeYesInjected by ModuleRenderer
labelNoundefinedUppercase label above heading
titleNoundefinedMain heading
titleTagNo'h2'Semantic heading level
bodyNoundefinedBody paragraph
imagePositionNo'right'Image placement
backgroundNo'light'Background variant
ctaNoundefinedCTA with label + href

+------------------------------------------------------------------+
| 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] | | | | |
| | +-----------------------------+ +-------------+ | |
| | | |
| +----------------------------------------------------------+ |
| |
+------------------------------------------------------------------+
PropertyValueCSS Token / Implementation
Section vertical padding80px top and bottompadding-block: var(--space-20)
Section horizontal padding120px left and rightpadding-inline: 120px (lateral padding from Figma; the inner container provides the effective max-width)
Inner container max-width1200pxmax-width: var(--container-max, 1200px)
Inner container centeringCenteredmargin-inline: auto
Flex directionRowdisplay: flex; flex-direction: row
Gap between text and image76pxCustom gap: gap: 76px (Figma value; falls between --space-16 64px and --space-20 80px)
Vertical alignmentCenteralign-items: center
Image container size588px wide, squarewidth: 588px; flex-shrink: 0; aspect-ratio: 1
Text columnFills remaining spaceflex: 1 1 0; min-width: 0
Column order (image right)Text first, image secondDefault DOM order
Column order (image left)Image first, text secondCSS order or reversed DOM with flex-direction: row-reverse

Text Column Internal Spacing (Desktop):

ElementSpacing RuleToken
Label to title gap24pxgap: var(--space-6)
Header group (label+title) to body group24pxgap: var(--space-6)
Body text left indent64pxpadding-left: var(--space-16)
Body to CTA gap40pxgap: var(--space-10)
+------------------------------------+
| 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 | | |
| | +------------------------+ | |
| +------------------------------+ |
+------------------------------------+
PropertyValueCSS Token / Implementation
Section vertical padding64pxpadding-block: var(--space-16)
Section horizontal padding24pxpadding-inline: var(--space-6)
Layout directionColumn (stacked)flex-direction: column (base/mobile styles)
Text-to-image gap48pxgap: var(--space-12)
Image widthFull container widthwidth: 100%
Image aspect ratio1:1 squareaspect-ratio: 1
Body text indent0 (no indent)padding-left: 0
Label-to-title gap16pxgap: var(--space-4)
Body-to-CTA gap32pxgap: var(--space-8)
CTA button widthFull widthwidth: 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.

BreakpointLayout 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.

ElementFigma Style NameFont FamilySize (Desktop)Size (Mobile)WeightLine HeightColour TokenExtra
LabelLabels/Label Mvar(--font-body)var(--text-base) (16px)var(--text-base) (16px)var(--font-weight-bold) (700)20pxvar(--color-text-image-label)text-transform: uppercase; letter-spacing: 0
TitleTitles/H5var(--font-heading)var(--text-5xl) (48px)var(--text-4xl) (36px)var(--font-weight-medium) (500)52px desktop / 40px mobilevar(--color-text-image-heading)
BodyBody Texts/Body M - Bookvar(--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 TextButtons/Button Mvar(--font-body)var(--text-base) (16px)var(--text-base) (16px)var(--font-weight-bold) (700)24pxvar(--color-text-image-btn-text)text-transform: uppercase; white-space: nowrap (desktop only)

Every theme file must define these tokens. Values below are from the Savoy Palace Figma (reference theme):

TokenSavoy Palace ValuePurpose
--color-text-image-bg-light#ffffffLight background variant
--color-text-image-bg-medium#ede9e2Medium/tinted background variant
--color-text-image-label#59437dLabel text colour
--color-text-image-heading#977e54Heading text colour
--color-text-image-body#000000Body text colour
--color-text-image-btn-bg#7d6946CTA button fill
--color-text-image-btn-text#ffffffCTA button text
--radius-container-image0Image container border-radius (theme-driven)
PropertyValueImplementation
BackgroundGold/brownbackground-color: var(--color-text-image-btn-bg)
Text colourWhitecolor: var(--color-text-image-btn-text)
Height48pxheight: 48px
Horizontal padding24pxpadding-inline: var(--space-6)
Vertical padding14pxpadding-block: 14px
Border radiusFully rounded pillborder-radius: var(--radius-full) (9999px)
Min width (desktop)40pxmin-width: 40px
Width (mobile)Full container widthwidth: 100% at base, width: auto at md+
Hover stateSubtle darkening&:hover { filter: brightness(0.9) } (pending design team confirmation)
TextUppercase, boldSee typography table above
DisplayInline flex, centereddisplay: inline-flex; align-items: center; justify-content: center
Element type&lt;a&gt; linkNot a &lt;button&gt; — this navigates to a URL
OverflowHiddenoverflow: hidden
PropertyDesktopMobile
Aspect ratio1:1 (square)1:1 (square)
Container size588px x 588pxFull width x auto (aspect-ratio enforced)
Object fitcovercover
Border radiusvar(--radius-container-image)var(--radius-container-image)
Overflowhidden (clips to border-radius)hidden
Recommended upload size960x960 (retina)750x750
Priority loadingfalse (not hero/LCP)false

.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 &lt;section&gt;:

  • Background: .text-image--bg-light or .text-image--bg-medium
  • Image position: .text-image--image-right or .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">


  • Root element: <section data-module="textImage" data-module-id="M27"> — semantic sectioning element
  • Heading: Configurable tag via titleTag prop (default h2), ensuring correct document outline on every page
  • Image: imageAlt prop is REQUIRED (not optional) — this is a content image, not decorative
  • Image uses ResponsiveImage from @savoy/ui with alt attribute
  • CTA: Rendered as <a href="..."> element — natively keyboard accessible (Tab + Enter)
  • CTA: Visible :focus-visible outline (inherits global focus styles)
  • Label: &lt;p&gt; 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)

Story title: Modules/M27 — Text Image

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'),
},
},
}
Story NamebackgroundimagePositionContentPurpose
Default'light''right'All props filledPrimary design state
ImageLeft'light''left'All props filledReversed layout
MediumBackground'medium''right'All props filledTinted background
MediumBackgroundImageLeft'medium''left'All props filledTinted + reversed
MinimalContent'light''right'Image + alt only (no label, title, body, CTA)Tests graceful empty text column
EmptyState'light''right'Empty image URLs, empty altTests rendering with no data
LongContent'light''right'Very long title, long body, long CTA labelTests text overflow and wrapping
AllOptions'medium''left'Every prop filled with rich contentConfirms all options work together
WithoutCTA'light''right'Label + title + body, no CTATests layout without button
WithoutLabel'light''right'Title + body + CTA, no labelTests layout without label
TitleOnly'light''right'Only title + imageTests minimal text 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' } },
},
},

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.

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 { TextImage } from './TextImage';
export type { TextImageProps, TextImageImageSource, TextImageCta } from './TextImage.types';

Element Type alias: textImage

PropertyAliasEditorRequiredTabVaries by Culture
LabellabelTextstringNoContentYes
TitletitleTextstringNoContentYes
Title TagtitleTagDropdown (h1, h2, h3, h4, h5, h6)NoSettingsNo
BodybodyTextareaNoContentYes
CTA LabelctaLabelTextstringNoContentYes
CTA LinkctaLinkURL PickerNoContentYes
Image PositionimagePositionDropdown (right, left)NoSettingsNo
BackgroundbackgroundDropdown (light, medium)NoSettingsNo
Image DesktopimageDesktopMedia Picker (Image)YesImagesNo
Image MobileimageMobileMedia Picker (Image)YesImagesNo
Image Alt TextimageAltTextstringYesImagesYes
TextImage.mapper.ts
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,
};
}

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)

Each theme CSS file needs the following tokens added. Values are extracted from the Figma designs.

/* 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;
/* 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;
/* 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;
/* 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.


  1. Scaffold files — Create all 7 files in packages/modules/src/m27-text-image/
  2. Define types — Write TextImage.types.ts first (copy the interface from Section 4 above)
  3. Write stories — Write TextImage.stories.tsx with all 11 variants and realistic mock data (Section 9)
  4. Implement component — Build TextImage.tsx (Server Component) + TextImage.scss (BEM + tokens)
  5. Add theme tokens — Add the CSS custom properties from Section 12 to all 8 theme files
  6. Validate in Storybook — Check all 11 stories across all 8 themes at 375px, 768px, 1024px, 1440px
  1. Write mapperTextImage.mapper.ts (Section 11.2)
  2. Write testsTextImage.test.tsx covering mapper transformation and component rendering
  3. Register — Add entry to packages/modules/src/registry.ts
  4. Export — Create index.ts with named exports
  1. Define Element Type schema — Properties, aliases, editors, tabs per Section 11.1
  2. Create SVG thumbnailapps/cms/Savoy.Cms/wwwroot/assets/thumbnails/m27-text-image.svg
  3. Create Element Type — In Umbraco backoffice (migration or manual)
  4. Configure Block List — Add as allowed block on target page Document Types
  5. Create test content — Verify Content Delivery API output
  6. Validate mapper — Test mapper against real API JSON response
  7. Test multi-site — Verify in savoy-signature + savoy-palace (minimum)
  8. Verify cache purge — Confirm webhook fires on publish

  • 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
  • Smooth transition from column (mobile) to row (desktop) at md breakpoint (768px)
  • imagePosition prop 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
  • 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
  • Server-rendered (no 'use client', no client-side JS bundle for this module)
  • Image uses ResponsiveImage from @savoy/ui with desktop + mobile sources
  • Image does NOT have priority={true} (not a hero/LCP image)
  • No layout shift from image loading (aspect-ratio: 1 set in CSS)
  • CSS uses only custom properties — no hardcoded values

Below is the recommended SCSS structure. This is a guide for the implementing developer, not final code.

TextImage.scss
.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;
}
}
}

  1. 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 containerShape prop and per-corner radius tokens are needed.
  2. 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?
  3. 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.
  4. Medium background button differentiation: Figma uses Button_Light on light backgrounds and Button_Medium on 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.
  5. 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?

  1. Hardcoding colours, fonts, or spacing — use var(--token-name) from packages/themes/src/
  2. Forgetting imageMobile — every image needs both desktop and mobile variants
  3. Mapper crash on null — Umbraco sends null for optional fields; always use ?. and fallbacks
  4. Wrong BEM nesting — use &__element inside the block, not standalone .text-image__element
  5. Missing registry entry — module will not render on any page without it
  6. Desktop-first media queries — use min-width (mobile-first), NEVER max-width
  7. Fixed pixel widths on containers — use %, flex, auto, max-width for fluid responsiveness
  8. Lorem ipsum in stories — use realistic hotel context (room names, restaurant descriptions)
  9. Missing data-module / data-module-id attributes — root &lt;section&gt; MUST have data-module="textImage" and data-module-id="M27"
  10. Hardcoding border-radius for image — MUST use var(--radius-container-image) token, not a fixed value
  11. Applying image position on mobileimagePosition prop must have NO effect below md breakpoint
  12. Forgetting to add tokens to ALL 8 theme files — every theme needs the full set of Text Image tokens