Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js

Custom WordPress Theme from a Figma Design Demo

A page from a custom WordPress theme for a fictional dental clinic, built from design tokens in theme.json and ACF flexible content sections. Turn on the inspector to see which template part, ACF fields and theme.json tokens produce each section. Edit fields, reorder or add sections, switch the style variation and preview at mobile width.

Read the case study →

Interactive demo

samiullah.tech/lab/wordpress-theme-from-figma
Acme Dental+92 42 0000 0000

Family dental clinic

Calm, careful dentistry for the whole family

Check-ups, cleaning and treatment explained in plain words. Evening appointments on weekdays.

Book a visit

Treatments

Each treatment has its own page with prices and what to expect.

  • Check-ups

    A full exam and X-rays when needed, in 30 minutes.

  • Hygiene

    Scale and polish with advice you can actually follow.

  • Whitening

    Take-home trays fitted in the clinic.

  • Children

    Gentle first visits for ages 3 and up.

  • Emergencies

    Same-day slots kept free every morning.

  • Implants

    Planning, placement and aftercare in one place.

“They explained every step before doing it. My son actually looks forward to his check-ups now.”
A. Rahman · Patient since 2023

Common questions

Yes. Book online or call, and bring any previous X-rays if you have them.

Ready for a check-up?

Most appointments are available within the week.

Book a visit

Acme Dental

12 Garden Road, Lahore

Opening hours

Mon–Sat 9:00–18:00

Colours, type sizes and spacing in the preview come only from the theme.json custom properties. Fluid sizes scale with the preview width.

## How it works

Design decisions live in theme.json: a palette of eight colours, five font sizes (three of them fluid), five spacing steps and a content and wide width. presetVars() turns them into the same custom properties WordPress prints, such as --wp--preset--color--primary, and every section reads only those variables. Switching to the Evening style variation swaps the palette and nothing else.

The page body is one ACF flexible content field. Each row has a layout (hero, feature grid, testimonial, FAQ, call to action) and its own fields, validated like ACF does (required, max length, min and max rows). The header and footer are block template parts that read phone, address and hours from an options page.

Turn the inspector on (press i) and select any outlined section to see its template part, its fields, the tokens it uses with their resolved values, and the PHP that renders it with esc_html and esc_url.

## View source

The code running in the window above, exactly as deployed.

app/lab/wordpress-theme-from-figma/theme/theme.json76 lines
{
  "$schema": "https://schemas.wp.org/wp/6.6/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "useRootPaddingAwareAlignments": true,
    "layout": { "contentSize": "720px", "wideSize": "1200px" },
    "color": {
      "defaultPalette": false,
      "defaultGradients": false,
      "custom": false,
      "palette": [
        { "slug": "base", "name": "Base", "color": "#fbfaf7" },
        { "slug": "surface", "name": "Surface", "color": "#ffffff" },
        { "slug": "contrast", "name": "Contrast", "color": "#14213d" },
        { "slug": "muted", "name": "Muted", "color": "#5b6474" },
        { "slug": "primary", "name": "Primary", "color": "#0f766e" },
        { "slug": "on-primary", "name": "On primary", "color": "#ffffff" },
        { "slug": "secondary", "name": "Secondary", "color": "#e76f51" },
        { "slug": "tint", "name": "Tint", "color": "#e3f2ef" }
      ]
    },
    "typography": {
      "fluid": { "minViewportWidth": "360px", "maxViewportWidth": "1200px" },
      "defaultFontSizes": false,
      "customFontSize": false,
      "fontFamilies": [
        { "slug": "body", "name": "Body", "fontFamily": "\"Inter\", system-ui, sans-serif" },
        { "slug": "display", "name": "Display", "fontFamily": "\"Fraunces\", Georgia, serif" }
      ],
      "fontSizes": [
        { "slug": "small", "name": "Small", "size": "0.875rem", "fluid": false },
        { "slug": "medium", "name": "Medium", "size": "1rem", "fluid": false },
        { "slug": "large", "name": "Large", "size": "1.25rem", "fluid": { "min": "1.125rem", "max": "1.375rem" } },
        { "slug": "x-large", "name": "Extra large", "size": "2rem", "fluid": { "min": "1.5rem", "max": "2.25rem" } },
        { "slug": "xx-large", "name": "Huge", "size": "3.25rem", "fluid": { "min": "2.125rem", "max": "3.75rem" } }
      ]
    },
    "spacing": {
      "units": ["px", "rem", "%"],
      "defaultSpacingSizes": false,
      "spacingSizes": [
        { "slug": "20", "name": "XS", "size": "0.5rem" },
        { "slug": "30", "name": "S", "size": "1rem" },
        { "slug": "40", "name": "M", "size": "1.5rem" },
        { "slug": "50", "name": "L", "size": "clamp(2rem, 4vw, 3rem)" },
        { "slug": "60", "name": "XL", "size": "clamp(3rem, 7vw, 5rem)" }
      ]
    },
    "custom": {
      "radius": { "small": "6px", "large": "18px" },
      "shadow": { "card": "0 1px 2px rgb(20 33 61 / 0.06), 0 8px 24px rgb(20 33 61 / 0.06)" }
    }
  },
  "styles": {
    "color": { "background": "var:preset|color|base", "text": "var:preset|color|contrast" },
    "typography": { "fontFamily": "var:preset|font-family|body", "fontSize": "var:preset|font-size|medium", "lineHeight": "1.6" },
    "spacing": { "padding": { "left": "var:preset|spacing|40", "right": "var:preset|spacing|40" }, "blockGap": "var:preset|spacing|30" },
    "elements": {
      "heading": { "typography": { "fontFamily": "var:preset|font-family|display", "fontWeight": "600", "lineHeight": "1.15" } },
      "link": { "color": { "text": "var:preset|color|primary" }, ":hover": { "typography": { "textDecoration": "underline" } } },
      "button": {
        "color": { "background": "var:preset|color|primary", "text": "var:preset|color|on-primary" },
        "border": { "radius": "999px" },
        "spacing": { "padding": { "top": "0.75rem", "bottom": "0.75rem", "left": "1.5rem", "right": "1.5rem" } },
        ":focus": { "outline": { "color": "var:preset|color|secondary", "offset": "3px", "style": "solid", "width": "2px" } }
      }
    }
  },
  "templateParts": [
    { "name": "header", "title": "Header", "area": "header" },
    { "name": "footer", "title": "Footer", "area": "footer" }
  ],
  "customTemplates": [{ "name": "page-sections", "title": "Page with ACF sections", "postTypes": ["page"] }]
}
app/lab/wordpress-theme-from-figma/theme/styles/evening.json20 lines
{
  "$schema": "https://schemas.wp.org/wp/6.6/theme.json",
  "version": 3,
  "title": "Evening",
  "settings": {
    "color": {
      "palette": [
        { "slug": "base", "name": "Base", "color": "#0e1621" },
        { "slug": "surface", "name": "Surface", "color": "#15202e" },
        { "slug": "contrast", "name": "Contrast", "color": "#e8eef3" },
        { "slug": "muted", "name": "Muted", "color": "#9aa7b4" },
        { "slug": "primary", "name": "Primary", "color": "#5eead4" },
        { "slug": "on-primary", "name": "On primary", "color": "#062b27" },
        { "slug": "secondary", "name": "Secondary", "color": "#f4a261" },
        { "slug": "tint", "name": "Tint", "color": "#123430" }
      ]
    }
  }
}
app/lab/wordpress-theme-from-figma/theme.ts305 lines
/**
 * The theme's data layer, free of React:
 * - turns theme.json (+ a style variation) into the same CSS custom properties WordPress generates
 *   (--wp--preset--color--primary, --wp--preset--font-size--x-large with fluid clamp(), ...),
 * - describes the ACF flexible content layouts (mirrors acf_add_local_field_group in php/acme-theme.php),
 * - validates field values the way ACF does on save (required, maxlength, min/max rows).
 */

import themeJson from "./theme/theme.json";
import evening from "./theme/styles/evening.json";

/* ------------------------------------------------------------------ */
/* theme.json → CSS custom properties                                  */
/* ------------------------------------------------------------------ */

type Palette = { slug: string; name: string; color: string }[];
type FontSize = { slug: string; name: string; size: string; fluid: false | { min: string; max: string } };

export const VARIATIONS = { default: "Default", evening: "Evening (style variation)" } as const;
export type Variation = keyof typeof VARIATIONS;

export function palette(v: Variation): Palette {
  return v === "evening" ? evening.settings.color.palette : themeJson.settings.color.palette;
}

const rem = (s: string) => Number.parseFloat(s) * (s.endsWith("px") ? 1 / 16 : 1);

/**
 * WordPress' wp_get_typography_font_size_value(): fluid sizes become clamp(min, preferred, max), with the
 * preferred value interpolated between the min and max viewport widths. The preview is a resizable box, not the
 * browser window, so the demo uses container units (cqi) where WordPress uses vw.
 */
export function fluidSize(f: FontSize): string {
  if (!f.fluid) return f.size;
  const { minViewportWidth, maxViewportWidth } = themeJson.settings.typography.fluid;
  const minVw = rem(minViewportWidth);
  const maxVw = rem(maxViewportWidth);
  const min = rem(f.fluid.min);
  const max = rem(f.fluid.max);
  const slope = (max - min) / (maxVw - minVw);
  const intercept = min - slope * minVw;
  return `clamp(${min}rem, ${intercept.toFixed(4)}rem + ${(slope * 100).toFixed(4)}cqi, ${max}rem)`;
}

export function presetVars(v: Variation): Record<string, string> {
  const s = themeJson.settings;
  const vars: Record<string, string> = {};
  for (const c of palette(v)) vars[`--wp--preset--color--${c.slug}`] = c.color;
  for (const f of s.typography.fontSizes as FontSize[]) vars[`--wp--preset--font-size--${f.slug}`] = fluidSize(f);
  for (const f of s.typography.fontFamilies) vars[`--wp--preset--font-family--${f.slug}`] = f.fontFamily;
  for (const sp of s.spacing.spacingSizes) vars[`--wp--preset--spacing--${sp.slug}`] = sp.size.replace(/vw/g, "cqi");
  vars["--wp--style--global--content-size"] = s.layout.contentSize;
  vars["--wp--style--global--wide-size"] = s.layout.wideSize;
  vars["--wp--custom--radius--small"] = s.custom.radius.small;
  vars["--wp--custom--radius--large"] = s.custom.radius.large;
  vars["--wp--custom--shadow--card"] = s.custom.shadow.card;
  return vars;
}

/** Look up the resolved value of a token like "color.primary" or "font-size.xx-large". */
export function tokenValue(token: string, v: Variation): string {
  const i = token.indexOf(".");
  const kind = token.slice(0, i);
  const slug = token.slice(i + 1);
  return presetVars(v)[kind === "custom" ? `--wp--custom--${slug.replace(".", "--")}` : `--wp--preset--${kind}--${slug}`] ?? "?";
}

/* ------------------------------------------------------------------ */
/* ACF flexible content layouts                                        */
/* ------------------------------------------------------------------ */

export type FieldDef =
  | { name: string; label: string; type: "text"; required?: boolean; maxlength?: number }
  | { name: string; label: string; type: "textarea"; required?: boolean; maxlength?: number }
  | { name: string; label: string; type: "select"; choices: Record<string, string> }
  | { name: string; label: string; type: "range"; min: number; max: number }
  | { name: string; label: string; type: "repeater"; min: number; max: number; subFields: FieldDef[] };

export type LayoutName = "hero" | "feature_grid" | "testimonial" | "faq" | "cta";

export type LayoutDef = {
  label: string;
  file: string; // template part that renders it
  fields: FieldDef[];
  tokens: string[]; // theme.json presets the markup uses
  blocks: string[]; // core blocks / CSS classes in the output, for the inspector
};

export const LAYOUTS: Record<LayoutName, LayoutDef> = {
  hero: {
    label: "Hero",
    file: "template-parts/sections/hero.php",
    fields: [
      { name: "eyebrow", label: "Eyebrow", type: "text", maxlength: 40 },
      { name: "heading", label: "Heading", type: "text", required: true, maxlength: 80 },
      { name: "text", label: "Text", type: "textarea", maxlength: 240 },
      { name: "cta_label", label: "Button label", type: "text", required: true, maxlength: 24 },
      { name: "style", label: "Layout", type: "select", choices: { split: "Image right", centered: "Centered" } },
    ],
    tokens: ["color.tint", "color.contrast", "color.primary", "font-size.xx-large", "font-size.large", "spacing.60", "custom.radius.large"],
    blocks: ["wp_get_attachment_image() → srcset + sizes", ".wp-element-button"],
  },
  feature_grid: {
    label: "Feature grid",
    file: "template-parts/sections/feature-grid.php",
    fields: [
      { name: "heading", label: "Heading", type: "text", required: true, maxlength: 60 },
      { name: "intro", label: "Intro", type: "textarea", maxlength: 200 },
      { name: "columns", label: "Columns (desktop)", type: "range", min: 2, max: 4 },
      {
        name: "items",
        label: "Items",
        type: "repeater",
        min: 2,
        max: 8,
        subFields: [
          { name: "title", label: "Title", type: "text", required: true, maxlength: 40 },
          { name: "text", label: "Text", type: "textarea", maxlength: 140 },
        ],
      },
    ],
    tokens: ["color.surface", "color.muted", "font-size.x-large", "spacing.50", "custom.shadow.card", "custom.radius.small"],
    blocks: ["CSS grid, columns from the field", "service CPT links"],
  },
  testimonial: {
    label: "Testimonial",
    file: "template-parts/sections/testimonial.php",
    fields: [
      { name: "quote", label: "Quote", type: "textarea", required: true, maxlength: 280 },
      { name: "name", label: "Name", type: "text", required: true, maxlength: 40 },
      { name: "role", label: "Role", type: "text", maxlength: 40 },
    ],
    tokens: ["color.secondary", "font-family.display", "font-size.x-large", "spacing.60"],
    blocks: ["<figure> + <blockquote> + <figcaption>"],
  },
  faq: {
    label: "FAQ",
    file: "template-parts/sections/faq.php",
    fields: [
      { name: "heading", label: "Heading", type: "text", required: true, maxlength: 60 },
      {
        name: "items",
        label: "Questions",
        type: "repeater",
        min: 1,
        max: 10,
        subFields: [
          { name: "q", label: "Question", type: "text", required: true, maxlength: 120 },
          { name: "a", label: "Answer", type: "textarea", required: true, maxlength: 400 },
        ],
      },
    ],
    tokens: ["color.surface", "color.primary", "font-size.large", "spacing.50"],
    blocks: ["<button aria-expanded> accordion", "FAQPage JSON-LD"],
  },
  cta: {
    label: "Call to action",
    file: "template-parts/sections/cta.php",
    fields: [
      { name: "heading", label: "Heading", type: "text", required: true, maxlength: 70 },
      { name: "text", label: "Text", type: "textarea", maxlength: 160 },
      { name: "button_label", label: "Button label", type: "text", required: true, maxlength: 24 },
      { name: "background", label: "Background", type: "select", choices: { primary: "Primary", contrast: "Contrast" } },
    ],
    tokens: ["color.primary", "color.contrast", "color.on-primary", "font-size.x-large", "spacing.60"],
    blocks: [".wp-element-button (inverted)"],
  },
};

export type Value = string | number | Record<string, string>[];
export type Section = { id: string; layout: LayoutName; fields: Record<string, Value> };
export type Options = { site_name: string; phone: string; address: string; hours: string };
export type PageData = { sections: Section[]; options: Options };

export const OPTION_FIELDS: { name: keyof Options; label: string; part: "header" | "footer" }[] = [
  { name: "site_name", label: "Site name", part: "header" },
  { name: "phone", label: "Phone", part: "header" },
  { name: "address", label: "Address", part: "footer" },
  { name: "hours", label: "Opening hours", part: "footer" },
];

export function defaults(layout: LayoutName): Record<string, Value> {
  const d: Record<LayoutName, Record<string, Value>> = {
    hero: { eyebrow: "New patients welcome", heading: "Calm, careful dentistry", text: "", cta_label: "Book a visit", style: "split" },
    feature_grid: { heading: "What we do", intro: "", columns: 3, items: [{ title: "Check-ups", text: "" }, { title: "Hygiene", text: "" }] },
    testimonial: { quote: "Write the quote here.", name: "Patient name", role: "" },
    faq: { heading: "Questions", items: [{ q: "Your question?", a: "The answer." }] },
    cta: { heading: "Ready when you are", text: "", button_label: "Get in touch", background: "primary" },
  };
  return structuredClone(d[layout]);
}

export function seedPage(): PageData {
  return {
    options: { site_name: "Acme Dental", phone: "+92 42 0000 0000", address: "12 Garden Road, Lahore", hours: "Mon–Sat 9:00–18:00" },
    sections: [
      {
        id: "s1",
        layout: "hero",
        fields: {
          eyebrow: "Family dental clinic",
          heading: "Calm, careful dentistry for the whole family",
          text: "Check-ups, cleaning and treatment explained in plain words. Evening appointments on weekdays.",
          cta_label: "Book a visit",
          style: "split",
        },
      },
      {
        id: "s2",
        layout: "feature_grid",
        fields: {
          heading: "Treatments",
          intro: "Each treatment has its own page with prices and what to expect.",
          columns: 3,
          items: [
            { title: "Check-ups", text: "A full exam and X-rays when needed, in 30 minutes." },
            { title: "Hygiene", text: "Scale and polish with advice you can actually follow." },
            { title: "Whitening", text: "Take-home trays fitted in the clinic." },
            { title: "Children", text: "Gentle first visits for ages 3 and up." },
            { title: "Emergencies", text: "Same-day slots kept free every morning." },
            { title: "Implants", text: "Planning, placement and aftercare in one place." },
          ],
        },
      },
      {
        id: "s3",
        layout: "testimonial",
        fields: { quote: "They explained every step before doing it. My son actually looks forward to his check-ups now.", name: "A. Rahman", role: "Patient since 2023" },
      },
      {
        id: "s4",
        layout: "faq",
        fields: {
          heading: "Common questions",
          items: [
            { q: "Do you take new patients?", a: "Yes. Book online or call, and bring any previous X-rays if you have them." },
            { q: "How long is a first visit?", a: "About 45 minutes, including a full check and a treatment plan." },
            { q: "Can I pay in instalments?", a: "For treatment over a set amount, yes. Ask at reception for the options." },
          ],
        },
      },
      { id: "s5", layout: "cta", fields: { heading: "Ready for a check-up?", text: "Most appointments are available within the week.", button_label: "Book a visit", background: "primary" } },
    ],
  };
}

/* ------------------------------------------------------------------ */
/* Validation (ACF's acf/validate_value rules)                         */
/* ------------------------------------------------------------------ */

export function validate(def: FieldDef, value: Value | undefined): string | null {
  if (def.type === "repeater") {
    const rows = Array.isArray(value) ? value : [];
    if (rows.length < def.min) return `${def.label}: at least ${def.min} row${def.min === 1 ? "" : "s"}.`;
    if (rows.length > def.max) return `${def.label}: at most ${def.max} rows.`;
    for (const [i, row] of rows.entries()) {
      for (const sf of def.subFields) {
        const e = validate(sf, row[sf.name] ?? "");
        if (e) return `Row ${i + 1}: ${e}`;
      }
    }
    return null;
  }
  if (def.type === "text" || def.type === "textarea") {
    const s = typeof value === "string" ? value : "";
    if (def.required && !s.trim()) return `${def.label} is required.`;
    if (def.maxlength && s.length > def.maxlength) return `${def.label}: ${s.length}/${def.maxlength} characters.`;
  }
  return null;
}

export function sectionErrors(sec: Section): Record<string, string> {
  const out: Record<string, string> = {};
  for (const f of LAYOUTS[sec.layout].fields) {
    const e = validate(f, sec.fields[f.name]);
    if (e) out[f.name] = e;
  }
  return out;
}

/** FAQPage JSON-LD, as the FAQ template part prints it with wp_json_encode. */
export function faqJsonLd(items: Record<string, string>[]) {
  return {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    mainEntity: items.map((i) => ({ "@type": "Question", name: i.q, acceptedAnswer: { "@type": "Answer", text: i.a } })),
  };
}

/* ------------------------------------------------------------------ */
/* Flexible content row operations                                     */
/* ------------------------------------------------------------------ */

export function moveSection(list: Section[], from: number, to: number): Section[] {
  if (to < 0 || to >= list.length || from === to) return list;
  const next = [...list];
  const [item] = next.splice(from, 1);
  next.splice(to, 0, item!);
  return next;
}

export function newId(list: Section[]) {
  return `s${Math.max(0, ...list.map((s) => Number(s.id.slice(1)) || 0)) + 1}`;
}
app/lab/wordpress-theme-from-figma/theme/templates/page-sections.html11 lines
<!-- wp:template-part {"slug":"header","area":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- ACF block: renders the page's flexible content rows (hero, feature grid, testimonial, faq, cta) in order -->
	<!-- wp:acf/page-sections {"name":"acf/page-sections","mode":"preview","align":"full"} /-->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","area":"footer","tagName":"footer"} /-->
app/lab/wordpress-theme-from-figma/theme/parts/header.html8 lines
<!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|30","bottom":"var:preset|spacing|30"}}},"layout":{"type":"flex","justifyContent":"space-between"}} -->
<div class="wp-block-group alignfull">
	<!-- wp:site-title {"level":0,"style":{"typography":{"fontWeight":"700"}},"fontFamily":"display"} /-->
	<!-- wp:navigation {"ref":4,"overlayMenu":"mobile","layout":{"type":"flex","justifyContent":"right"}} /-->
	<!-- wp:acf/contact-phone {"name":"acf/contact-phone"} /-->
</div>
<!-- /wp:group -->
app/lab/wordpress-theme-from-figma/php/acme-theme.php252 lines
<?php
/**
 * Acme Dental theme: functions.php plus the section template parts, in one file for reading.
 * In the theme these live in functions.php, inc/acf.php and template-parts/sections/*.php.
 *
 * Production design for the theme demo. Lines starting with "// @part" are markers the demo uses to show
 * the PHP behind each section in the inspector.
 */

// @part setup
defined( 'ABSPATH' ) || exit;

add_action( 'after_setup_theme', 'acme_theme_setup' );
function acme_theme_setup(): void {
	add_theme_support( 'wp-block-styles' );
	add_theme_support( 'editor-styles' );
	add_editor_style( 'assets/editor.css' );
	add_image_size( 'acme-hero', 1200, 900, true );
	add_image_size( 'acme-card', 600, 400, true );
}

// One small stylesheet built by Vite; no jQuery on the front end.
add_action(
	'wp_enqueue_scripts',
	static function (): void {
		$asset = include get_theme_file_path( 'build/theme.asset.php' ); // version hash from the build
		wp_enqueue_style( 'acme-theme', get_theme_file_uri( 'build/theme.css' ), array(), $asset['version'] );
		wp_enqueue_script( 'acme-faq', get_theme_file_uri( 'build/faq.js' ), array(), $asset['version'], array( 'strategy' => 'defer' ) );
	}
);

// ACF JSON: field groups are saved in the theme, so they are versioned with the code.
add_filter( 'acf/settings/save_json', static fn() => get_theme_file_path( 'acf-json' ) );
add_filter( 'acf/settings/load_json', static fn( array $paths ) => array_merge( $paths, array( get_theme_file_path( 'acf-json' ) ) ) );

// The template calls one ACF block that renders the flexible content rows in order.
add_action(
	'init',
	static function (): void {
		register_block_type( get_theme_file_path( 'blocks/page-sections' ) ); // block.json: "acf": { "renderTemplate": "render.php" }
	}
);

// @part options
add_action( 'acf/init', 'acme_theme_options_page' );
function acme_theme_options_page(): void {
	if ( function_exists( 'acf_add_options_page' ) ) {
		acf_add_options_page(
			array(
				'page_title' => __( 'Site details', 'acme' ),
				'menu_slug'  => 'acme-site-details',
				'capability' => 'edit_theme_options',
				'redirect'   => false,
			)
		);
	}
}

// Header template part: the ACF block acf/contact-phone renders this.
function acme_render_contact_phone(): void {
	$phone = (string) get_field( 'phone', 'option' );
	if ( $phone ) {
		printf(
			'<a class="acme-phone" href="tel:%1$s">%2$s</a>',
			esc_attr( preg_replace( '/[^\d+]/', '', $phone ) ),
			esc_html( $phone )
		);
	}
}

// Footer template part: acf/contact-details and acf/opening-hours.
function acme_render_contact_details(): void {
	printf( '<address>%s</address>', esc_html( (string) get_field( 'address', 'option' ) ) );
	printf( '<p>%s</p>', esc_html( (string) get_field( 'hours', 'option' ) ) );
}

// @part fields
add_action( 'acf/include_fields', 'acme_theme_fields' );
function acme_theme_fields(): void {
	$text = static fn( string $name, string $label, int $max, bool $req = false ) => array(
		'key' => "field_acme_{$name}_" . wp_unique_id(), 'name' => $name, 'label' => $label, 'type' => 'text', 'maxlength' => $max, 'required' => $req,
	);
	acf_add_local_field_group(
		array(
			'key'      => 'group_acme_sections',
			'title'    => 'Page sections',
			'location' => array( array( array( 'param' => 'page_template', 'operator' => '==', 'value' => 'page-sections' ) ) ),
			'fields'   => array(
				array(
					'key'          => 'field_acme_sections',
					'name'         => 'sections',
					'type'         => 'flexible_content',
					'button_label' => 'Add section',
					'layouts'      => array(
						'hero'         => array( 'key' => 'layout_hero', 'name' => 'hero', 'label' => 'Hero', 'sub_fields' => array(
							$text( 'eyebrow', 'Eyebrow', 40 ),
							$text( 'heading', 'Heading', 80, true ),
							array( 'key' => 'field_acme_hero_text', 'name' => 'text', 'label' => 'Text', 'type' => 'textarea', 'maxlength' => 240, 'rows' => 3 ),
							array( 'key' => 'field_acme_hero_image', 'name' => 'image', 'label' => 'Image', 'type' => 'image', 'return_format' => 'id' ),
							$text( 'cta_label', 'Button label', 24, true ),
							array( 'key' => 'field_acme_hero_link', 'name' => 'cta_link', 'label' => 'Button link', 'type' => 'page_link' ),
							array( 'key' => 'field_acme_hero_style', 'name' => 'style', 'label' => 'Layout', 'type' => 'select', 'choices' => array( 'split' => 'Image right', 'centered' => 'Centered' ) ),
						) ),
						'feature_grid' => array( 'key' => 'layout_grid', 'name' => 'feature_grid', 'label' => 'Feature grid', 'sub_fields' => array(
							$text( 'heading', 'Heading', 60, true ),
							array( 'key' => 'field_acme_grid_cols', 'name' => 'columns', 'label' => 'Columns', 'type' => 'range', 'min' => 2, 'max' => 4, 'default_value' => 3 ),
							array( 'key' => 'field_acme_grid_items', 'name' => 'items', 'label' => 'Items', 'type' => 'repeater', 'min' => 2, 'max' => 8, 'layout' => 'block', 'sub_fields' => array(
								$text( 'title', 'Title', 40, true ),
								array( 'key' => 'field_acme_grid_text', 'name' => 'text', 'label' => 'Text', 'type' => 'textarea', 'maxlength' => 140 ),
								array( 'key' => 'field_acme_grid_link', 'name' => 'service', 'label' => 'Service page', 'type' => 'post_object', 'post_type' => array( 'service' ) ),
							) ),
						) ),
						// testimonial, faq and cta follow the same pattern; see LAYOUTS in theme.ts for their fields.
					),
				),
			),
		)
	);
}

// @part hero
/** template-parts/sections/hero.php — $args = the flexible content row. */
function acme_section_hero( array $args ): void {
	$style = in_array( $args['style'] ?? '', array( 'split', 'centered' ), true ) ? $args['style'] : 'split';
	?>
	<section class="acme-hero acme-hero--<?php echo esc_attr( $style ); ?> has-tint-background-color alignfull">
		<div class="acme-hero__text">
			<?php if ( ! empty( $args['eyebrow'] ) ) : ?>
				<p class="acme-eyebrow"><?php echo esc_html( $args['eyebrow'] ); ?></p>
			<?php endif; ?>
			<h1 class="has-xx-large-font-size"><?php echo esc_html( $args['heading'] ); ?></h1>
			<?php if ( ! empty( $args['text'] ) ) : ?>
				<p class="has-large-font-size"><?php echo esc_html( $args['text'] ); ?></p>
			<?php endif; ?>
			<a class="wp-element-button" href="<?php echo esc_url( get_permalink( $args['cta_link'] ?? 0 ) ?: home_url( '/book/' ) ); ?>">
				<?php echo esc_html( $args['cta_label'] ); ?>
			</a>
		</div>
		<?php
		if ( ! empty( $args['image'] ) ) {
			// Outputs width, height, srcset and sizes. The hero is above the fold: eager and high priority.
			echo wp_get_attachment_image( (int) $args['image'], 'acme-hero', false, array( 'loading' => 'eager', 'fetchpriority' => 'high', 'sizes' => '(min-width: 782px) 50vw, 100vw' ) );
		}
		?>
	</section>
	<?php
}

// @part feature_grid
/** template-parts/sections/feature-grid.php */
function acme_section_feature_grid( array $args ): void {
	$cols = max( 2, min( 4, (int) ( $args['columns'] ?? 3 ) ) );
	?>
	<section class="acme-grid alignwide" style="--acme-cols: <?php echo absint( $cols ); ?>">
		<h2 class="has-x-large-font-size"><?php echo esc_html( $args['heading'] ); ?></h2>
		<?php if ( ! empty( $args['intro'] ) ) : ?>
			<p class="has-muted-color"><?php echo esc_html( $args['intro'] ); ?></p>
		<?php endif; ?>
		<ul class="acme-grid__items" role="list">
			<?php foreach ( (array) ( $args['items'] ?? array() ) as $item ) : ?>
				<li class="acme-card">
					<h3><?php echo esc_html( $item['title'] ); ?></h3>
					<p><?php echo esc_html( $item['text'] ?? '' ); ?></p>
					<?php if ( ! empty( $item['service'] ) ) : ?>
						<a href="<?php echo esc_url( get_permalink( $item['service'] ) ); ?>"><?php esc_html_e( 'Learn more', 'acme' ); ?><span class="screen-reader-text"> <?php echo esc_html( $item['title'] ); ?></span></a>
					<?php endif; ?>
				</li>
			<?php endforeach; ?>
		</ul>
	</section>
	<?php
}

// @part testimonial
/** template-parts/sections/testimonial.php */
function acme_section_testimonial( array $args ): void {
	?>
	<figure class="acme-quote">
		<blockquote class="has-display-font-family has-x-large-font-size"><p><?php echo esc_html( $args['quote'] ); ?></p></blockquote>
		<figcaption>
			<strong><?php echo esc_html( $args['name'] ); ?></strong>
			<?php if ( ! empty( $args['role'] ) ) : ?>
				<span><?php echo esc_html( $args['role'] ); ?></span>
			<?php endif; ?>
		</figcaption>
	</figure>
	<?php
}

// @part faq
/** template-parts/sections/faq.php — accordion with real buttons, plus FAQPage structured data. */
function acme_section_faq( array $args ): void {
	$items = (array) ( $args['items'] ?? array() );
	?>
	<section class="acme-faq">
		<h2 class="has-x-large-font-size"><?php echo esc_html( $args['heading'] ); ?></h2>
		<?php foreach ( $items as $i => $item ) : $id = wp_unique_id( 'faq-' ); ?>
			<h3>
				<button type="button" aria-expanded="<?php echo 0 === $i ? 'true' : 'false'; ?>" aria-controls="<?php echo esc_attr( $id ); ?>">
					<?php echo esc_html( $item['q'] ); ?>
				</button>
			</h3>
			<div id="<?php echo esc_attr( $id ); ?>" <?php echo 0 === $i ? '' : 'hidden'; ?>><?php echo wp_kses_post( wpautop( $item['a'] ) ); ?></div>
		<?php endforeach; ?>
	</section>
	<script type="application/ld+json">
	<?php
	echo wp_json_encode(
		array(
			'@context'   => 'https://schema.org',
			'@type'      => 'FAQPage',
			'mainEntity' => array_map(
				static fn( $item ) => array( '@type' => 'Question', 'name' => wp_strip_all_tags( $item['q'] ), 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => wp_strip_all_tags( $item['a'] ) ) ),
				$items
			),
		),
		JSON_HEX_TAG | JSON_UNESCAPED_SLASHES
	);
	?>
	</script>
	<?php
}

// @part cta
/** template-parts/sections/cta.php */
function acme_section_cta( array $args ): void {
	$bg = 'contrast' === ( $args['background'] ?? '' ) ? 'contrast' : 'primary';
	?>
	<section class="acme-cta alignwide has-<?php echo esc_attr( $bg ); ?>-background-color">
		<h2 class="has-x-large-font-size"><?php echo esc_html( $args['heading'] ); ?></h2>
		<?php if ( ! empty( $args['text'] ) ) : ?>
			<p><?php echo esc_html( $args['text'] ); ?></p>
		<?php endif; ?>
		<a class="wp-element-button is-style-inverted" href="<?php echo esc_url( home_url( '/book/' ) ); ?>"><?php echo esc_html( $args['button_label'] ); ?></a>
	</section>
	<?php
}

// @part render
/** blocks/page-sections/render.php — loops the rows and loads the matching template part. */
function acme_render_page_sections(): void {
	if ( ! have_rows( 'sections' ) ) {
		return;
	}
	while ( have_rows( 'sections' ) ) {
		the_row();
		$layout = get_row_layout(); // hero | feature_grid | testimonial | faq | cta
		$file   = 'template-parts/sections/' . str_replace( '_', '-', sanitize_key( $layout ) );
		get_template_part( $file, null, get_row( true ) ); // $args arrive in the part as $args
	}
}
app/lab/wordpress-theme-from-figma/Demo.tsx589 lines
"use client";

import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { CodePanel } from "@/components/lab/wp/LogPanel";
import { usePersistentState } from "@/components/lab/wp/usePersistentState";
import {
  LAYOUTS,
  OPTION_FIELDS,
  VARIATIONS,
  defaults,
  faqJsonLd,
  moveSection,
  newId,
  presetVars,
  sectionErrors,
  seedPage,
  tokenValue,
  validate,
  type FieldDef,
  type LayoutName,
  type PageData,
  type Section,
  type Value,
  type Variation,
} from "./theme";

const KEY = "lab:wordpress-theme-from-figma:v1";
type Persisted = { page: PageData; variation: Variation };
const seed = (): Persisted => ({ page: seedPage(), variation: "default" });

const VIEWPORTS = { desktop: { label: "Desktop", width: "100%" }, tablet: { label: "Tablet 768", width: "768px" }, mobile: { label: "Mobile 375", width: "375px" } } as const;
type Viewport = keyof typeof VIEWPORTS;
type Target = string | "header" | "footer";

const v = (name: string) => `var(--wp--preset--${name})`;
const input = "w-full rounded border border-border bg-bg px-2 py-1.5 text-sm outline-none focus:border-accent";

export default function Demo({ snippets }: { snippets: Record<string, string> }) {
  const [state, setState, reset] = usePersistentState<Persisted>(KEY, seed);
  const { page, variation } = state;
  const [inspect, setInspect] = useState(true);
  const [selected, setSelected] = useState<Target>("s1");
  const [viewport, setViewport] = useState<Viewport>("desktop");
  const [panel, setPanel] = useState<"fields" | "tokens" | "php">("fields");
  const [dragging, setDragging] = useState<number | null>(null);
  const [announce, setAnnounce] = useState("");
  const [addLayout, setAddLayout] = useState<LayoutName>("cta");

  const vars = useMemo(() => presetVars(variation), [variation]);

  // "i" toggles the inspector (ignored while typing).
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement;
      if (e.key.toLowerCase() === "i" && !e.metaKey && !e.ctrlKey && !["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName)) setInspect((x) => !x);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  const setPage = (fn: (p: PageData) => PageData) => setState((s) => ({ ...s, page: fn(s.page) }));
  const setField = (id: string, name: string, value: Value) =>
    setPage((p) => ({ ...p, sections: p.sections.map((s) => (s.id === id ? { ...s, fields: { ...s.fields, [name]: value } } : s)) }));

  function move(from: number, to: number) {
    if (to < 0 || to >= page.sections.length) return;
    const sec = page.sections[from]!;
    setPage((p) => ({ ...p, sections: moveSection(p.sections, from, to) }));
    setAnnounce(`${LAYOUTS[sec.layout].label} moved to position ${to + 1} of ${page.sections.length}.`);
  }
  function remove(id: string) {
    const sec = page.sections.find((s) => s.id === id)!;
    setPage((p) => ({ ...p, sections: p.sections.filter((s) => s.id !== id) }));
    setAnnounce(`${LAYOUTS[sec.layout].label} removed.`);
    if (selected === id) setSelected("header");
  }
  function duplicate(id: string) {
    setPage((p) => {
      const i = p.sections.findIndex((s) => s.id === id);
      const copy: Section = { ...structuredClone(p.sections[i]!), id: newId(p.sections) };
      const next = [...p.sections];
      next.splice(i + 1, 0, copy);
      return { ...p, sections: next };
    });
  }
  function add() {
    const id = newId(page.sections);
    setPage((p) => ({ ...p, sections: [...p.sections, { id, layout: addLayout, fields: defaults(addLayout) }] }));
    setSelected(id);
    setAnnounce(`${LAYOUTS[addLayout].label} added at the end.`);
  }

  const sel = page.sections.find((s) => s.id === selected);
  const selIndex = page.sections.findIndex((s) => s.id === selected);

  return (
    <div className="grid gap-0 xl:grid-cols-[1fr_22rem]">
      {/* ---------------- Preview ---------------- */}
      <div className="min-w-0 p-3 sm:p-4">
        <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
          <button
            type="button"
            onClick={() => setInspect((x) => !x)}
            aria-pressed={inspect}
            className={`rounded border px-2 py-1 font-mono ${inspect ? "border-accent bg-accent-soft text-accent" : "border-border hover:border-accent"}`}
          >
            inspector {inspect ? "on" : "off"} <kbd className="ml-1 rounded border border-border px-1 text-[10px]">i</kbd>
          </button>
          <div role="group" aria-label="Preview width" className="flex overflow-hidden rounded border border-border">
            {(Object.keys(VIEWPORTS) as Viewport[]).map((k) => (
              <button key={k} type="button" aria-pressed={viewport === k} onClick={() => setViewport(k)} className={`px-2 py-1 font-mono ${viewport === k ? "bg-accent text-accent-fg" : "hover:bg-bg-muted"}`}>
                {VIEWPORTS[k].label}
              </button>
            ))}
          </div>
          <label className="flex items-center gap-1">
            Style
            <select value={variation} onChange={(e) => setState((s) => ({ ...s, variation: e.target.value as Variation }))} className="rounded border border-border bg-bg-elev px-1.5 py-1">
              {(Object.keys(VARIATIONS) as Variation[]).map((k) => (
                <option key={k} value={k}>
                  {VARIATIONS[k]}
                </option>
              ))}
            </select>
          </label>
          <button type="button" onClick={reset} className="rounded border border-border px-2 py-1 font-mono hover:border-accent">
            reset demo
          </button>
        </div>

        <div className="overflow-x-auto rounded-md border border-border bg-bg-muted p-2">
          <div
            className="@container mx-auto overflow-hidden rounded shadow-sm transition-[max-width] duration-300"
            style={{
              ...(vars as CSSProperties),
              maxWidth: VIEWPORTS[viewport].width,
              background: v("color--base"),
              color: v("color--contrast"),
              fontFamily: v("font-family--body"),
              fontSize: v("font-size--medium"),
              lineHeight: 1.6,
            }}
          >
            <Frame on={inspect} active={selected === "header"} onSelect={() => setSelected("header")} label="parts/header.html" tag="template part">
              <header className="flex flex-wrap items-center justify-between gap-2" style={{ padding: `${v("spacing--30")} ${v("spacing--40")}`, borderBottom: `1px solid color-mix(in srgb, ${v("color--contrast")} 12%, transparent)` }}>
                <span style={{ fontFamily: v("font-family--display"), fontWeight: 700, fontSize: v("font-size--large") }}>{page.options.site_name}</span>
                <nav aria-label="Site (preview)" className="hidden gap-4 text-sm @2xl:flex">
                  <span>Treatments</span>
                  <span>About</span>
                  <span>Contact</span>
                </nav>
                <span className="text-sm" style={{ color: v("color--primary"), fontWeight: 600 }}>
                  {page.options.phone}
                </span>
              </header>
            </Frame>

            {page.sections.length === 0 && (
              <p className="p-10 text-center text-sm" style={{ color: v("color--muted") }}>
                No sections. Add one from the inspector.
              </p>
            )}
            {page.sections.map((s) => (
              <Frame key={s.id} on={inspect} active={selected === s.id} onSelect={() => setSelected(s.id)} label={LAYOUTS[s.layout].file} tag={`acf: ${s.layout}`} warn={Object.keys(sectionErrors(s)).length > 0}>
                <SectionView s={s} />
              </Frame>
            ))}

            <Frame on={inspect} active={selected === "footer"} onSelect={() => setSelected("footer")} label="parts/footer.html" tag="template part">
              <footer className="grid gap-4 text-sm @xl:grid-cols-2" style={{ background: v("color--contrast"), color: v("color--base"), padding: `${v("spacing--50")} ${v("spacing--40")}` }}>
                <div>
                  <p style={{ fontFamily: v("font-family--display"), fontWeight: 700, fontSize: v("font-size--large") }}>{page.options.site_name}</p>
                  <address className="not-italic opacity-80">{page.options.address}</address>
                </div>
                <div>
                  <p className="font-semibold">Opening hours</p>
                  <p className="opacity-80">{page.options.hours}</p>
                </div>
              </footer>
            </Frame>
          </div>
        </div>
        <p className="mt-2 text-xs text-fg-muted">Colours, type sizes and spacing in the preview come only from the theme.json custom properties. Fluid sizes scale with the preview width.</p>
      </div>

      {/* ---------------- Inspector ---------------- */}
      <aside aria-label="Inspector" className="min-w-0 space-y-4 border-t border-border bg-bg-elev p-3 sm:p-4 xl:border-t-0 xl:border-l">
        <section aria-label="Flexible content rows">
          <h3 className="font-mono text-sm font-semibold">page sections (ACF flexible content)</h3>
          <p className="text-xs text-fg-muted">Drag rows, or use the arrow buttons.</p>
          <ol className="mt-2 space-y-1">
            {page.sections.map((s, i) => (
              <li
                key={s.id}
                draggable
                onDragStart={(e) => {
                  setDragging(i);
                  e.dataTransfer.effectAllowed = "move";
                }}
                onDragOver={(e) => e.preventDefault()}
                onDrop={(e) => {
                  e.preventDefault();
                  if (dragging !== null) move(dragging, i);
                  setDragging(null);
                }}
                onDragEnd={() => setDragging(null)}
                className={`flex items-center gap-1 rounded border px-1.5 py-1 text-sm ${selected === s.id ? "border-accent bg-accent-soft/50" : "border-border"} ${dragging === i ? "opacity-50" : ""}`}
              >
                <span aria-hidden="true" className="cursor-grab px-1 text-fg-muted">
                  ⋮⋮
                </span>
                <button type="button" onClick={() => setSelected(s.id)} className="min-w-0 flex-1 truncate text-left hover:text-accent">
                  <span className="font-mono text-xs text-fg-muted">{i + 1}.</span> {LAYOUTS[s.layout].label}
                  {Object.keys(sectionErrors(s)).length > 0 && <span className="ml-1 text-xs text-danger">needs attention</span>}
                </button>
                <IconBtn label={`Move ${LAYOUTS[s.layout].label} up`} onClick={() => move(i, i - 1)} disabled={i === 0}>
                  ↑
                </IconBtn>
                <IconBtn label={`Move ${LAYOUTS[s.layout].label} down`} onClick={() => move(i, i + 1)} disabled={i === page.sections.length - 1}>
                  ↓
                </IconBtn>
                <IconBtn label={`Duplicate ${LAYOUTS[s.layout].label}`} onClick={() => duplicate(s.id)}>
                  ⧉
                </IconBtn>
                <IconBtn label={`Remove ${LAYOUTS[s.layout].label}`} onClick={() => remove(s.id)} danger>
                  ×
                </IconBtn>
              </li>
            ))}
          </ol>
          <div className="mt-2 flex gap-1.5">
            <label htmlFor="add-layout" className="sr-only">
              Layout to add
            </label>
            <select id="add-layout" value={addLayout} onChange={(e) => setAddLayout(e.target.value as LayoutName)} className="min-w-0 flex-1 rounded border border-border bg-bg px-2 py-1 text-sm">
              {(Object.keys(LAYOUTS) as LayoutName[]).map((k) => (
                <option key={k} value={k}>
                  {LAYOUTS[k].label}
                </option>
              ))}
            </select>
            <button type="button" onClick={add} className="rounded bg-accent px-3 py-1 text-sm font-medium text-accent-fg hover:opacity-90">
              Add section
            </button>
          </div>
          <p role="status" aria-live="polite" className="sr-only">
            {announce}
          </p>
        </section>

        <section aria-label="Selected element" className="border-t border-border pt-3">
          <p className="font-mono text-xs text-fg-muted">selected</p>
          <h3 className="font-semibold">
            {sel ? `${LAYOUTS[sel.layout].label} (row ${selIndex + 1})` : selected === "header" ? "Header template part" : "Footer template part"}
          </h3>
          <p className="font-mono text-xs break-all text-accent-2">{sel ? LAYOUTS[sel.layout].file : `parts/${selected}.html`}</p>

          <div role="tablist" aria-label="Inspector panels" className="mt-2 flex gap-1 border-b border-border">
            {(["fields", "tokens", "php"] as const).map((p) => (
              <button key={p} type="button" role="tab" aria-selected={panel === p} onClick={() => setPanel(p)} className={`-mb-px border-b-2 px-2.5 py-1 font-mono text-xs ${panel === p ? "border-accent text-accent" : "border-transparent text-fg-muted"}`}>
                {p === "fields" ? "ACF fields" : p === "tokens" ? "theme.json tokens" : "PHP"}
              </button>
            ))}
          </div>

          <div className="mt-3">
            {panel === "fields" &&
              (sel ? (
                <div className="space-y-3">
                  {LAYOUTS[sel.layout].fields.map((f) => (
                    <FieldEditor key={f.name} def={f} value={sel.fields[f.name]} onChange={(val) => setField(sel.id, f.name, val)} idPrefix={`${sel.id}-${f.name}`} />
                  ))}
                </div>
              ) : (
                <div className="space-y-3">
                  <p className="text-xs text-fg-muted">Global fields from the ACF options page “Site details”, read with get_field( …, &apos;option&apos; ).</p>
                  {OPTION_FIELDS.filter((o) => o.part === selected || o.name === "site_name").map((o) => (
                    <label key={o.name} className="block text-sm">
                      {o.label}
                      <input value={page.options[o.name]} onChange={(e) => setPage((p) => ({ ...p, options: { ...p.options, [o.name]: e.target.value } }))} className={`${input} mt-1`} />
                    </label>
                  ))}
                </div>
              ))}
            {panel === "tokens" && (
              <ul className="space-y-1.5">
                {(sel ? LAYOUTS[sel.layout].tokens : selected === "header" ? ["color.contrast", "color.primary", "font-family.display", "font-size.large", "spacing.30", "spacing.40"] : ["color.contrast", "color.base", "font-family.display", "spacing.50"]).map((t) => {
                  const val = tokenValue(t, variation);
                  return (
                    <li key={t} className="flex items-center gap-2 text-xs">
                      {t.startsWith("color.") && <span className="size-4 shrink-0 rounded border border-border" style={{ background: val }} aria-hidden="true" />}
                      <code className="font-mono">{t.startsWith("custom.") ? `--wp--custom--${t.slice(7).replace(".", "--")}` : `var:preset|${t.replace(".", "|")}`}</code>
                      <span className="ml-auto max-w-[45%] truncate font-mono text-fg-muted" title={val}>
                        {val}
                      </span>
                    </li>
                  );
                })}
                {sel && (
                  <li className="pt-2 text-xs text-fg-muted">
                    Output: {LAYOUTS[sel.layout].blocks.join(" · ")}
                  </li>
                )}
              </ul>
            )}
            {panel === "php" && (
              <div className="space-y-2">
                <CodePanel code={snippets[sel ? sel.layout : "options"] ?? "// not found"} label={`php/acme-theme.php · ${sel ? sel.layout : "options page + parts"}`} className="max-h-[28rem]" />
                {sel?.layout === "faq" && <CodePanel label="JSON-LD this row prints" code={JSON.stringify(faqJsonLd((sel.fields.items as Record<string, string>[]) ?? []), null, 2)} className="max-h-48" />}
              </div>
            )}
          </div>
        </section>
      </aside>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Inspector overlay frame                                             */
/* ------------------------------------------------------------------ */

function Frame({ on, active, onSelect, label, tag, warn, children }: { on: boolean; active: boolean; onSelect: () => void; label: string; tag: string; warn?: boolean; children: ReactNode }) {
  if (!on) return <>{children}</>;
  return (
    <div className={`relative outline-offset-[-2px] ${active ? "outline-2 outline-[#2563eb]" : "outline-1 outline-dashed outline-[#2563eb]/50 hover:outline-[#2563eb]"}`}>
      <button
        type="button"
        onClick={onSelect}
        className={`absolute top-1 left-1 z-10 max-w-[calc(100%-0.5rem)] truncate rounded px-1.5 py-0.5 font-mono text-[10px] text-white shadow ${warn ? "bg-[#b91c1c]" : "bg-[#2563eb]"}`}
        aria-label={`Inspect ${tag} (${label})`}
      >
        {tag} · {label}
      </button>
      {children}
    </div>
  );
}

function IconBtn({ label, onClick, disabled, danger, children }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: ReactNode }) {
  return (
    <button type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled} className={`size-6 shrink-0 rounded border border-border text-xs disabled:opacity-30 ${danger ? "hover:border-danger hover:text-danger" : "hover:border-accent hover:text-accent"}`}>
      {children}
    </button>
  );
}

/* ------------------------------------------------------------------ */
/* ACF field editor                                                    */
/* ------------------------------------------------------------------ */

function FieldEditor({ def, value, onChange, idPrefix }: { def: FieldDef; value: Value | undefined; onChange: (v: Value) => void; idPrefix: string }) {
  const err = validate(def, value);
  const errId = err ? `${idPrefix}-err` : undefined;
  const label = (
    <span className="flex justify-between gap-2 text-sm">
      <span>
        {def.label}
        {"required" in def && def.required && <span className="text-danger"> *</span>}
      </span>
      {"maxlength" in def && def.maxlength && (
        <span className="font-mono text-[11px] text-fg-muted">
          {String(value ?? "").length}/{def.maxlength}
        </span>
      )}
    </span>
  );
  const error = err && (
    <span id={errId} className="mt-0.5 block text-xs text-danger">
      {err}
    </span>
  );

  if (def.type === "repeater") {
    const rows = Array.isArray(value) ? value : [];
    const setRow = (i: number, k: string, val: string) => onChange(rows.map((r, j) => (j === i ? { ...r, [k]: val } : r)));
    return (
      <fieldset aria-describedby={errId}>
        <legend className="text-sm">
          {def.label} <span className="text-xs text-fg-muted">({rows.length}, min {def.min}, max {def.max})</span>
        </legend>
        <ol className="mt-1 space-y-2">
          {rows.map((r, i) => (
            <li key={i} className="rounded border border-border p-2">
              <div className="mb-1 flex items-center justify-between text-xs text-fg-muted">
                <span>Row {i + 1}</span>
                <span className="flex gap-1">
                  <IconBtn label={`Move row ${i + 1} up`} onClick={() => onChange(moveRows(rows, i, i - 1))} disabled={i === 0}>
                    ↑
                  </IconBtn>
                  <IconBtn label={`Remove row ${i + 1}`} onClick={() => onChange(rows.filter((_, j) => j !== i))} danger>
                    ×
                  </IconBtn>
                </span>
              </div>
              {def.subFields.map((sf) => (
                <label key={sf.name} className="mb-1 block text-xs">
                  {sf.label}
                  {sf.type === "textarea" ? (
                    <textarea rows={2} value={r[sf.name] ?? ""} onChange={(e) => setRow(i, sf.name, e.target.value)} className={`${input} mt-0.5`} />
                  ) : (
                    <input value={r[sf.name] ?? ""} onChange={(e) => setRow(i, sf.name, e.target.value)} className={`${input} mt-0.5`} />
                  )}
                </label>
              ))}
            </li>
          ))}
        </ol>
        <button
          type="button"
          disabled={rows.length >= def.max}
          onClick={() => onChange([...rows, Object.fromEntries(def.subFields.map((sf) => [sf.name, ""]))])}
          className="mt-1 rounded border border-border px-2 py-0.5 text-xs hover:border-accent disabled:opacity-40"
        >
          Add row
        </button>
        {error}
      </fieldset>
    );
  }
  if (def.type === "select") {
    return (
      <label className="block">
        {label}
        <select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} className={`${input} mt-1`}>
          {Object.entries(def.choices).map(([k, l]) => (
            <option key={k} value={k}>
              {l}
            </option>
          ))}
        </select>
      </label>
    );
  }
  if (def.type === "range") {
    return (
      <label className="block">
        {label}
        <span className="mt-1 flex items-center gap-2">
          <input type="range" min={def.min} max={def.max} value={Number(value ?? def.min)} onChange={(e) => onChange(Number(e.target.value))} className="flex-1 accent-[var(--accent)]" />
          <span className="w-4 font-mono text-sm">{String(value)}</span>
        </span>
      </label>
    );
  }
  return (
    <label className="block">
      {label}
      {def.type === "textarea" ? (
        <textarea rows={3} value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} aria-invalid={!!err} aria-describedby={errId} className={`${input} mt-1 ${err ? "border-danger" : ""}`} />
      ) : (
        <input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} aria-invalid={!!err} aria-describedby={errId} className={`${input} mt-1 ${err ? "border-danger" : ""}`} />
      )}
      {error}
    </label>
  );
}

function moveRows<T>(rows: T[], from: number, to: number): T[] {
  if (to < 0 || to >= rows.length) return rows;
  const next = [...rows];
  const [x] = next.splice(from, 1);
  next.splice(to, 0, x!);
  return next;
}

/* ------------------------------------------------------------------ */
/* Section renderers: the React twin of template-parts/sections/*.php  */
/* ------------------------------------------------------------------ */

const str = (x: Value | undefined) => (typeof x === "string" ? x : "");
const rows = (x: Value | undefined) => (Array.isArray(x) ? x : []);
const pad = (y: string) => ({ padding: `${v(`spacing--${y}`)} ${v("spacing--40")}` });
const btnStyle = (bg: string, fg: string): CSSProperties => ({ background: bg, color: fg, borderRadius: 999, padding: "0.75rem 1.5rem", fontWeight: 600, display: "inline-block" });

function SectionView({ s }: { s: Section }) {
  const f = s.fields;
  switch (s.layout) {
    case "hero": {
      const split = f.style !== "centered";
      return (
        <section style={{ background: v("color--tint"), ...pad("60") }}>
          <div className={`mx-auto grid items-center gap-8 ${split ? "@3xl:grid-cols-[1.1fr_1fr]" : "text-center"}`} style={{ maxWidth: "var(--wp--style--global--wide-size)" }}>
            <div className={split ? "" : "mx-auto max-w-2xl"}>
              {str(f.eyebrow) && (
                <p className="text-sm font-semibold tracking-wide uppercase" style={{ color: v("color--primary") }}>
                  {str(f.eyebrow)}
                </p>
              )}
              <h2 style={{ fontFamily: v("font-family--display"), fontSize: v("font-size--xx-large"), lineHeight: 1.1, fontWeight: 600, marginTop: v("spacing--20") }}>{str(f.heading)}</h2>
              {str(f.text) && <p style={{ fontSize: v("font-size--large"), color: v("color--muted"), marginTop: v("spacing--30") }}>{str(f.text)}</p>}
              <p style={{ marginTop: v("spacing--40") }}>
                <span style={btnStyle(v("color--primary"), v("color--on-primary"))}>{str(f.cta_label)}</span>
              </p>
            </div>
            {split && (
              <div
                role="img"
                aria-label="Clinic reception (placeholder image)"
                className="aspect-[4/3] w-full"
                style={{ borderRadius: "var(--wp--custom--radius--large)", background: `linear-gradient(140deg, color-mix(in srgb, ${v("color--primary")} 70%, white), ${v("color--primary")})` }}
              />
            )}
          </div>
        </section>
      );
    }
    case "feature_grid":
      return (
        <section style={pad("60")}>
          <div className="mx-auto" style={{ maxWidth: "var(--wp--style--global--wide-size)" }}>
            <h2 style={{ fontFamily: v("font-family--display"), fontSize: v("font-size--x-large"), fontWeight: 600 }}>{str(f.heading)}</h2>
            {str(f.intro) && <p style={{ color: v("color--muted"), marginTop: v("spacing--20") }}>{str(f.intro)}</p>}
            <ul className="mt-6 grid grid-cols-1 gap-4 @xl:grid-cols-2 @3xl:[grid-template-columns:repeat(var(--cols),minmax(0,1fr))]" style={{ "--cols": Number(f.columns) || 3 } as CSSProperties}>
              {rows(f.items).map((it, i) => (
                <li key={i} style={{ background: v("color--surface"), boxShadow: "var(--wp--custom--shadow--card)", borderRadius: "var(--wp--custom--radius--small)", padding: v("spacing--40") }}>
                  <h3 style={{ fontWeight: 600, fontSize: v("font-size--large") }}>{it.title}</h3>
                  {it.text && <p style={{ color: v("color--muted"), fontSize: v("font-size--small"), marginTop: "0.25rem" }}>{it.text}</p>}
                </li>
              ))}
            </ul>
          </div>
        </section>
      );
    case "testimonial":
      return (
        <section style={{ ...pad("60"), borderTop: `4px solid ${v("color--secondary")}` }}>
          <figure className="mx-auto" style={{ maxWidth: "var(--wp--style--global--content-size)" }}>
            <blockquote style={{ fontFamily: v("font-family--display"), fontSize: v("font-size--x-large"), lineHeight: 1.3 }}>“{str(f.quote)}”</blockquote>
            <figcaption style={{ marginTop: v("spacing--30"), color: v("color--muted") }}>
              <strong style={{ color: v("color--contrast") }}>{str(f.name)}</strong>
              {str(f.role) && ` · ${str(f.role)}`}
            </figcaption>
          </figure>
        </section>
      );
    case "faq":
      return <FaqView heading={str(f.heading)} items={rows(f.items)} />;
    case "cta": {
      const dark = f.background === "contrast";
      return (
        <section style={pad("60")}>
          <div
            className="mx-auto text-center"
            style={{ maxWidth: "var(--wp--style--global--wide-size)", background: dark ? v("color--contrast") : v("color--primary"), color: dark ? v("color--base") : v("color--on-primary"), borderRadius: "var(--wp--custom--radius--large)", padding: v("spacing--50") }}
          >
            <h2 style={{ fontFamily: v("font-family--display"), fontSize: v("font-size--x-large"), fontWeight: 600 }}>{str(f.heading)}</h2>
            {str(f.text) && <p style={{ opacity: 0.85, marginTop: v("spacing--20") }}>{str(f.text)}</p>}
            <p style={{ marginTop: v("spacing--40") }}>
              <span style={btnStyle(dark ? v("color--base") : v("color--on-primary"), dark ? v("color--contrast") : v("color--primary"))}>{str(f.button_label)}</span>
            </p>
          </div>
        </section>
      );
    }
  }
}

function FaqView({ heading, items }: { heading: string; items: Record<string, string>[] }) {
  const [open, setOpen] = useState(0);
  return (
    <section style={pad("60")}>
      <div className="mx-auto" style={{ maxWidth: "var(--wp--style--global--content-size)" }}>
        <h2 style={{ fontFamily: v("font-family--display"), fontSize: v("font-size--x-large"), fontWeight: 600 }}>{heading}</h2>
        <div className="mt-4">
          {items.map((it, i) => {
            const id = `faq-${i}-${heading.length}`;
            return (
              <div key={i} style={{ borderBottom: `1px solid color-mix(in srgb, ${v("color--contrast")} 15%, transparent)` }}>
                <h3>
                  <button type="button" aria-expanded={open === i} aria-controls={id} onClick={() => setOpen(open === i ? -1 : i)} className="flex w-full items-center justify-between gap-3 py-3 text-left font-semibold" style={{ fontSize: v("font-size--large") }}>
                    {it.q}
                    <span aria-hidden="true" style={{ color: v("color--primary") }}>
                      {open === i ? "−" : "+"}
                    </span>
                  </button>
                </h3>
                <div id={id} hidden={open !== i} className="pb-3" style={{ color: v("color--muted") }}>
                  {it.a}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}