Custom WordPress Customizer Control for Typography Presets renders blank section or fallback , despite correct class registration

Problem
I’m building a WordPress theme and want a Global → Typography → Font Presets control in the Customizer that shows a grid of clickable cards (each card previews a heading/body Google-Font pair). Instead of my custom card UI, the section is either blank or falls back to a basic <select> (or radio list) control. I’ve tried many variations of register_control_type(), direct instantiation, OPcache resets, and cleanup of duplicate classes, but no luck.

What I’ve Done
Autoloader in functions.php (recursively includes /inc/ files):

// in functions.php
$rii = new RecursiveIteratorIterator(
  new RecursiveDirectoryIterator( __DIR__ . '/inc' )
);
foreach ( $rii as $file ) {
  if ( ! $file->isDir() && $file->getExtension() === 'php' ) {
    require_once $file->getPathname();
  }
}

Bootstrap singleton in inc/class-zero-customizer.php:

<?php
if ( ! defined( 'ABSPATH' ) ) exit;

class Zero_Customizer {
  private static $instance = null;

  public static function get_instance() {
    if ( null === self::$instance ) {
      self::$instance = new self();
      self::$instance->hooks();
    }
    return self::$instance;
  }

  private function hooks() {
    error_log( 'Zero: hooks() running' );
    add_action( 'customize_register',                [ $this, 'register_typography_control' ] );
    add_action( 'customize_controls_enqueue_scripts',[ $this, 'enqueue_control_assets' ] );
    add_action( 'customize_preview_init',            [ $this, 'enqueue_preview_assets' ] );
  }

  public function register_typography_control( $wp_customize ) {
    error_log( 'Zero: register_typography_control() fired' );
    require_once __DIR__ . '/customizer/controls/class-zero-control-typography.php';

    // Panel & section
    if ( ! $wp_customize->get_panel( 'zero_global_panel' ) ) {
      $wp_customize->add_panel( 'zero_global_panel', [
        'title'    => __( 'Global Settings', 'zero' ),
        'priority' => 10,
      ] );
    }
    $wp_customize->add_section( 'zero_typography_section', [
      'title'    => __( 'Typography', 'zero' ),
      'panel'    => 'zero_global_panel',
      'priority' => 10,
    ] );
    error_log( 'Zero: added section zero_typography_section' );

    // Presets & setting
    $presets = [
      'playfair-open-sans'         => esc_html__( 'Playfair Display / Open Sans', 'zero' ),
      /* …other 9 pairs… */
    ];
    $wp_customize->add_setting( 'zero_typography_preset', [
      'default'           => 'playfair-open-sans',
      'sanitize_callback' => function( $val ) use ( $presets ) {
        return isset( $presets[ $val ] ) ? $val : 'playfair-open-sans';
      },
      'transport'         => 'postMessage',
    ] );

    // Direct instantiation of custom control
    $wp_customize->add_control( new Zero_Control_Typography(
      $wp_customize,
      'zero_typography_preset',
      [
        'label'       => __( 'Font Presets', 'zero' ),
        'description' => __( 'Click a card to choose your Heading/Body pair.', 'zero' ),
        'section'     => 'zero_typography_section',
        'choices'     => $presets,
      ]
    ) );
    error_log( 'Zero: added custom-control for zero_typography_preset' );
  }

  public function enqueue_control_assets() {
    // Load panel CSS & JS
    wp_enqueue_style(
      'zero-customizer-controls',
      get_stylesheet_directory_uri() . '/assets/dist/css/main.min.css',
      [], filemtime( get_stylesheet_directory() . '/assets/dist/css/main.min.css' )
    );
    wp_enqueue_script(
      'zero-customizer-controls',
      get_stylesheet_directory_uri() . '/assets/dist/js/customizer.bundle.js',
      [ 'jquery','customize-controls' ],
      filemtime( get_stylesheet_directory() . '/assets/dist/js/customizer.bundle.js' ),
      true
    );
  }

  public function enqueue_preview_assets() {
    // Load iframe JS for live preview
    wp_enqueue_script(
      'zero-customizer-preview',
      get_stylesheet_directory_uri() . '/assets/dist/js/customizer.bundle.js',
      [ 'jquery','customize-preview' ],
      filemtime( get_stylesheet_directory() . '/assets/dist/js/customizer.bundle.js' ),
      true
    );
  }
}

add_action( 'after_setup_theme', [ 'Zero_Customizer', 'get_instance' ] );

Custom Control in inc/customizer/controls/class-zero-control-typography.php:

<?php
if ( ! class_exists( 'WP_Customize_Control' ) ) {
  return;
}
if ( ! class_exists( 'Zero_Control_Typography' ) ) {
  class Zero_Control_Typography extends WP_Customize_Control {
    public $type = 'typography';

    public function render_content() {
      error_log( 'Zero: Zero_Control_Typography::render_content()' );
      if ( empty( $this->choices ) ) {
        return;
      }

      echo '<span class="customize-control-title">' . esc_html( $this->label ) . '</span>';
      if ( $this->description ) {
        echo '<span class="description customize-control-description">'
             . esc_html( $this->description ) . '</span>';
      }

      echo '<ul>';
      foreach ( $this->choices as $slug => $name ) {
        $checked = checked( $this->value(), $slug, false );
        list( $h, $b ) = explode( '-', $slug, 2 );
        printf(
          '<li><label class="preset-card">'
          . '<input type="radio" data-customize-setting-link="%1$s" value="%2$s"%3$s />'
          . '<span class="preset-card__heading" style="font-family:'%4$s';">Heading</span>'
          . '<span class="preset-card__body"    style="font-family:'%5$s';">Body text</span>'
          . '</label></li>',
          esc_attr( $this->id ), esc_attr( $slug ), $checked,
          esc_attr( ucwords( str_replace('-', ' ', $h)) ),
          esc_attr( ucwords( str_replace('-', ' ', $b)) )
        );
      }
      echo '</ul>';
    }
  }
}

SCSS & JS

  • Imported into my normal assets/css/sass/main.scss and assets/js/customizer.js builds.
  • Enqueued in the panel via customize_controls_enqueue_scripts and preview via customize_preview_init.

Errors & Symptoms

  • Blank Typography section, despite register_typography_control() firing in the logs.

  • If I try array‐style or register_control_type(), it instead renders a plain <select> or radio list.

  • Encountered duplicate‐class “Cannot declare class Zero_Control_Typography” until I deleted old files.

  • Tried OPcache resets, restarting Docker, multiple include patterns—still no card UI.

Questions

  • Why is WP not rendering my Zero_Control_Typography::render_content() output?
  • Is there a necessary hook priority or missing argument I’m overlooking?
  • What’s the minimal, fool-proof way to ensure WP uses my custom control subclass rather than falling back?
  • Are there any Astra‐style patterns (specific enqueue hooks, control registration order) I should mimic?

Any guidance or working minimal example would be hugely appreciated—thanks!