# Blocks — Two-column card

An image and a solid-coloured text panel side by side, either way round, with a title,
body text and one or two CTAs. Rebuilt from production's `TwoColumnCardBlock` Storyblok component
(rendered by `TwoColumnCardBase.vue` → `TwoColumnCardLayout.vue`, with its two card slots
`TwoColumnCard/TextCard.vue` and `TwoColumnCard/ImageCard.vue`) on Gazelle tokens only. The
Blocks → Two-column card screen emits this exact markup.

## Metadata

- **Category:** Blocks
- **Family:** Cards
- **Status:** Stable (heading role limited to h2–h4; CTA colour is a simplification — see
  Production mapping)
- **Storyblok component:** `TwoColumnCardBlock` (+ nested `TitleTextWithCta`, `Image`)
- **Production source:** `gazelle-int-website/app/storyblok/TwoColumnCardBlock.vue` →
  `app/components/TwoColumnCardBase.vue` → `TwoColumnCardLayout.vue` →
  `app/components/TwoColumnCard/TextCard.vue` + `TwoColumnCard/ImageCard.vue`
- **Source:** `docs/blocks-registry.js` (canonical markup), `docs/atom-classes.js`
  (Button atom), `tailwind.css` (theme)

## When to use

- A promo or hand-off to another flow (a wizard, a finder, a store locator) that needs a
  photo and a short pitch with one clear CTA, or a primary and a secondary CTA.
- Alternate `Image position` between neighbouring instances on a page for rhythm, as
  production pages do.

## When not to use

- For more than two CTAs or a list of links — this block is one photo, one message, at most a
  primary and a secondary button. Use Centered title & text for text-only sections.
- With a text colour that doesn't contrast the background you picked. Nothing enforces a
  pairing; check contrast when previewing a new combination.

## Storyblok fields

Production models this as one `TwoColumnCardBlock` with a `cards` bloks field (min/max 2,
whitelisted to exactly one `Image` and one `TitleTextWithCta`). This block flattens that
pair into a single field set, matching the Hero/Centered title & text pattern.

| Field | Type | Covered | Notes |
| --- | --- | --- | --- |
| `image` | bloks → `Image` (required) | Yes | The photo. Fills its panel with `object-cover`. |
| *(order of `cards`)* | — | Yes | Exposed as **Image position** (left/right). Mobile always shows the text panel first, whichever side the image sits on desktop (production: `reverse-on-mobile` on the layout when the image blok comes first). |
| `title` | text (`TitleTextWithCta.title`, required) | Yes | |
| `heading_type` | option h1–h6, default `h3` | Partial | Only **h2 / h3 / h4** are offered — the type roles this system defines. |
| `text` | richtext (`TitleTextWithCta.text`, required) | Yes | |
| `label` | text (`TitleTextWithCta.label`) | Yes | The CTA. Empty hides the button. |
| `link` | multilink (`TitleTextWithCta.link`) | Yes | The CTA's `href`. |
| `secondary_cta_label`, `secondary_cta_link` | text / multilink — **new**, not in production's schema yet | Yes | Optional second button beside the first. Empty label hides it. Needs adding to `TitleTextWithCta` in Storyblok. |
| `background_color` | option, external datasource `background-colors` | Yes | Grey (default), Oyster, White, Blue — see Production mapping for the token pairing. |
| `text_color` | option, external datasource `theme-colors`, default `main-color-1` | Yes | Black (default), Blue, Light blue, Light green, Pink, Yellow, Sand. |
| `custom_padding`, `padding_top`, `padding_bottom` | boolean | Yes | Applied as **margin**, both off by default — unlike Hero/Centered title & text, production gives this block **no** default rhythm; each side is opt-in. |
| `Image.custom_padding`, `Image.padding_top`, `Image.padding_bottom` | boolean | **No** | Defined on the `Image` blok's schema, but `TwoColumnCardBlock.vue`'s `setData()` never reads them — dead fields in production. Not modelled. |

## Anatomy

Image on the left (text panel on the right):

```html
<section class="w-full mt-section-sm laptop:mt-section-lg mb-section-sm laptop:mb-section-lg">
  <div class="mx-auto flex w-full max-w-page flex-col laptop:flex-row">
    <div class="flex w-full flex-col items-start justify-center px-gutter py-16 laptop:py-40
                laptop:pl-20 laptop:pr-15 desktop-xl:pl-45 desktop-xl:pr-70
                laptop:w-1/2 bg-surface-brand text-theme-light-blue-500">
      <h3 class="mb-5 laptop:mb-10 type-h3-sm tablet:type-h3-lg">…</h3>
      <div class="rich-text type-body-sm tablet:type-body-lg">…</div>
      <div class="mt-8 flex flex-wrap items-center gap-4">
        <!-- Button atoms as <a>: Small + lg-laptop step (Large from laptop);
             primary CTA, then optional secondary CTA -->
      </div>
    </div>
    <div class="relative aspect-8/7 w-full overflow-hidden laptop:aspect-square laptop:w-1/2 laptop:order-first">
      <img src="…" alt="" class="absolute inset-0 size-full object-cover">
    </div>
  </div>
</section>
```

Both panels are always in **text, then image** DOM order. That is what mobile shows, stacked
in a single column. From laptop the row goes side by side. When **Image position** is Left,
`laptop:order-first` moves the image panel ahead of the text panel without changing the DOM
order, so the desktop choice never affects mobile.

**Frame.** The row is full-bleed up to `max-w-page` (1920px), like production. It is not
limited to the 1360px content column.

**Image.** The image box is 8:7 when stacked (`aspect-8/7`, production's `2 / 1.75`) and
square from laptop (`laptop:aspect-square`). The row stretches both columns to the taller one.
The `<img>` is absolutely positioned inside its column, so a text panel taller than the square
(long copy at 1024px) makes the image grow instead of leaving a gap.

**Panel spacing.** Mobile padding is `px-gutter py-16` (20 / 64px), and from laptop it is
`py-40` (160px). From laptop the horizontal padding depends on the side. The **outer edge**
(away from the image) and the **inner edge** (next to the image) get:

| Width | Outer | Inner |
| --- | --- | --- |
| < laptop | `px-gutter` (20px) | `px-gutter` (20px) |
| laptop → desktop | `15` (60px) | `20` (80px) |
| desktop-xl (≥1920) | `70` (280px, lines the text up with the 1360px content column) | `45` (180px) |

For image-left the classes are `laptop:pl-20 laptop:pr-15 desktop-xl:pl-45 desktop-xl:pr-70`.
For image-right they are mirrored. The gaps between elements are `mb-5 laptop:mb-10` under the
heading (20 → 40px) and `mt-8` above the button row (32px).

**Spacing** — the block is full-bleed, so its rhythm is margin. Unlike Hero, there is no
default:

| `custom_padding` | `padding_top` | `padding_bottom` | Classes |
| --- | --- | --- | --- |
| off | — | — | *(none)* |
| on | on | — | `mt-section-sm laptop:mt-section-lg` |
| on | — | on | `mb-section-sm laptop:mb-section-lg` |
| on | on | on | both |

## Tokens

- Layout: `max-w-page`, `px-gutter`, `*-section-sm` / `*-section-lg`; 4px-grid steps
  `py-16` / `py-40`, `pl/pr-15` / `20` / `45` / `70`, `mb-5` / `mb-10`, `mt-8`; `aspect-8/7`,
  `aspect-square`.
- Type: `type-h2-sm` → `type-h4-lg` (heading, by role), `type-body-sm` → `type-body-lg`
  (rich text).
- Colour — background: `bg-surface-muted`, `bg-surface-oyster`, `bg-white`,
  `bg-surface-brand`. Text: `text-fg-primary`, `text-fg-brand`,
  `text-theme-light-blue-500`, `text-theme-light-green-500`, `text-theme-pink-500`,
  `text-theme-yellow-500`, `text-theme-sand-500`.
- Atoms: Button, Small with the `lg-laptop` size step (Large from laptop), from
  `docs/atom-classes.js`, unchanged. The primary CTA is Primary on light panels and White on
  the Blue panel. The secondary CTA is Secondary (Grey/White), White (Oyster) or
  Tertiary (Blue).

## Production mapping

Production's spacing scale is its own: `05` = 20px, `08` = 40px, `10` = 64px, `13` = 160px.
Tailwind's stock scale uses different values.

| Part | Production | Design system | Decision |
| --- | --- | --- | --- |
| Background colour | `main-grey-100` (`#f2f2f2`, default) / `main-oyster-500` (`#eef7ef`) / `main-white-500` / `main-blue-500` (`#033a7e`) | `bg-surface-muted` / `bg-surface-oyster` / `bg-white` / `bg-surface-brand` | Exact — every production swatch already exists as a Gazelle colour token |
| Text colour | `main-black-500` (`#1c1c1c`, default) / `main-blue-500` / `theme-light-blue-500` (`#96e6f0`) / `theme-light-green-500` / `theme-pink-500` (`#f5beeb`) / `theme-yellow-500` / `theme-sand-500` (`#d2d2a0`) | `text-fg-primary` / `text-fg-brand` / `text-theme-light-blue-500` / `text-theme-light-green-500` / `text-theme-pink-500` / `text-theme-yellow-500` / `text-theme-sand-500` | Exact — same values, already Gazelle theme colours |
| Frame | `mx-auto max-w-[1920px]` | `mx-auto max-w-page` | Exact |
| Panel padding Y | `py-10 laptop:py-13` (64 → 160px) | `py-16 laptop:py-40` | Exact |
| Panel padding X | `px-05` (20px); from laptop, fluid `max()/min()` on `100vw`: outer `max((100vw − 1360 + 20) / 2, 60px)`, inner `min(max((100vw − 1360 + 40) / 2, 80px), 180px)`; capped at 280 / 180px from 1920 | `px-gutter`; `laptop:` 60 / 80px; `desktop-xl:` 280 / 180px, per side | Viewport maths isn't allowed in blocks, so the fluid range is stepped. It is exact at 1024–1440 and at ≥1920. Between ~1460 and 1919 the outer edge stays at 60px, where production grows to 280px |
| Mobile order | `reverse-on-mobile` (`flex-col-reverse`) when the image blok is first, so text always shows first | DOM order is always text-then-image; `laptop:order-first` moves the image ahead from laptop | Same visible result without reversing the whole column |
| Image box | `aspect-[2/1.75]` at every width | `aspect-8/7` below laptop, `laptop:aspect-square` | Same ratio when stacked (plain ratio, no arbitrary value); **square side by side by design decision** (production is 8:7 there too) |
| Heading | `<h1>`–`<h6>` via `heading_type`, `h3 laptop:h2`, `leading-9` (24px / 36 → 32px / 32) | `<h2>`/`<h3>`/`<h4>` with the matching role; default H3 = 28 → 32px, switching at tablet | Only the roles this system defines are offered. The default is 4px larger on mobile because of the Small → Large rule |
| Heading → text | `pb-05 laptop:pb-08` (20 → 40px) | `mb-5 laptop:mb-10` | Exact |
| Body | `text-sm laptop:text-md`, light, `leading-normal` (16 → 18px) | `type-body-sm tablet:type-body-lg` (16 → 18px) | Same sizes; switches at tablet |
| Text → CTA | `pb-05` on the text + `mt-03` on the button (32px) | `mt-8` on the button row | Exact |
| CTA | `base-button`, theme resolved from `text_color`: `main-color-1/2/4` → `blue` theme (solid blue, white label); any theme colour (e.g. `theme-color-1`) → that colour's own filled theme (e.g. light-blue fill, blue label) | Button atom **Primary** (blue fill) on Grey/Oyster/White panels; **White** on the Blue panel | The Button atom has no per-theme-colour fill yet (light-blue/light-green/pink/yellow/sand); reusing its two closest existing styles keeps every class atom-owned instead of inventing a one-off fill. **Open question:** add themed Button atom styles if this fill is needed elsewhere |
| Secondary CTA | not supported | Button atom **Tertiary** on Blue, **White** on Oyster, **Secondary** on Grey/White | The Secondary style shares Oyster's fill and would vanish on an Oyster panel, so White is used there. Both buttons sit in a `mt-8 flex flex-wrap items-center gap-4` row |
| CTA size | ~39px below tablet, 48px from laptop | Button atom **Small** (40px) + `lg-laptop` step → **Large** (48px) from laptop | Closest atom sizes; the step is part of the Button atom (`specs/atoms/button.md`) |

## Usage

- Pick **Image position** to alternate with a neighbouring instance of this block, or
  with Centered title & text, for rhythm down the page.
- Prefer the Grey/Oyster/White + Blue text pairing for a calm section, and the Blue +
  theme-colour-text pairing (as in this block's default) for a stronger call to action.
- Use the secondary CTA for a lower-priority alternative (e.g. "Find a dealer"); never more than two buttons.

## Cross-references

- `specs/foundations/layout.md` — `max-w-content`, gutter, section rhythm.
- `specs/foundations/typography.md` — the H2–H4 and Body roles.
- `specs/atoms/button.md` — the Primary and White styles.
- `specs/blocks/hero-block.md` — the other full-bleed, margin-rhythm block.
