/**
* URBAN RENDER LIBRARY v1.2
*
* The frontend twin of the Urban Studio Framework. The engine made ADMIN
* screens universal — one engine, one config per site. Templates stayed
* per-site forks, and forking templates is where this project's worst bugs
* were born: Raymond's taxonomy inside Hiranandani's hub, a CSS class whose
* rules existed in only one of the two stacks. This library exists to make
* forking frontend code extinct the same way.
*
* ── THE THREE LAWS ─────────────────────────────────────────────────────
*
* 1. COMPONENTS OWN THEIR CSS.
* A component emits its own styles, once per request. There is no "which
* style block does this rule belong in?" question to get wrong, and a
* component copied to another site cannot arrive unstyled.
*
* 2. COMPONENTS SPEAK ONLY IN TOKENS — with one stated exception.
*
* THIRD-PARTY BRAND COLOURS STAY LITERAL. YouTube red, the Instagram
* gradient and Google Maps green are not part of any site's palette and no
* site may change them: a green YouTube button is wrong on every site that
* could ever use this library. Tokenising them would invite exactly that.
* They are hard-coded deliberately, and they are the ONLY hex literals
* permitted in a component.
*
* (The first draft tokenised the Maps green while leaving YouTube red
* literal — an inconsistency caught by the audit, not by review.)
*
* No hex literal may appear in a component. Every colour is a --up-*
* custom property supplied by the site's theme. This makes the Contrast
* Sweep structural: a palette is validated once per site rather than per
* selector, so white-on-cream cannot be introduced by a new component.
*
* 3. SAME BY DEFAULT, DIVERGENT ONLY WHEN DECLARED.
* A site renders the default component. It may differ only by naming a
* variant in its theme config. A named variant must consume the IDENTICAL
* data contract as the default and honour the same empty-hides behaviour —
* it may differ in markup and styling only. The moment a variant needs its
* own field, it is not a variant, it is a second component and gets its own
* name. Presentation may diverge; the contract may not.
*
* ── WHAT IS NOT HERE ───────────────────────────────────────────────────
* Header, footer, sticky bar and floating elements stay with each site.
* They are the most site-personal things on the page and absorbing them
* would make the library a theme rather than a component set.
*
* ── SCOPE OF v1.0 ──────────────────────────────────────────────────────
* The seven components the facet page needs. The library is born with new
* code, not by extracting the live project and variant templates: the facet
* page is new, so nothing can break while the library proves itself. The
* existing templates migrate onto these components afterwards, one at a
* time, each with its own before/after check.
*/
if ( ! defined( 'ABSPATH' ) ) { exit; }
/* ═══════════════════════════════════════════════════════════════════════
* THEME LAYER — a site declares its palette; components consume names only
* ═══════════════════════════════════════════════════════════════════════ */
if ( ! isset( $GLOBALS['up_theme'] ) ) { $GLOBALS['up_theme'] = array(); }
if ( ! function_exists( 'up_theme_register' ) ) {
function up_theme_register( array $tokens, array $opts = array() ): void {
$GLOBALS['up_theme'] = array( 'tokens' => $tokens, 'opts' => $opts );
}
}
/** The site's chosen variant for a component, or '' for the default. */
if ( ! function_exists( 'up_variant_of' ) ) {
function up_variant_of( string $component ): string {
return (string) ( $GLOBALS['up_theme']['opts']['components'][ $component ] ?? '' );
}
}
/**
* Emit the palette once. Every component depends on this having run, so each
* one calls it; the static guard makes the repetition free.
*/
if ( ! function_exists( 'up_theme_css' ) ) {
function up_theme_css(): void {
static $done = false;
if ( $done ) { return; }
$done = true;
$defaults = array(
'primary' => '#7B1E2E',
'primary-deep' => '#5A1220',
'accent' => '#C9A45C',
'accent-soft' => '#E7D3A8',
'ink' => '#3E4A5E',
'mute' => '#8A8378',
'line' => '#EBD9C0',
'dark' => '#0B0B0D',
'surface' => '#FFFFFF',
'surface-warm' => '#FBF3EC',
'surface-alt' => '#F6E7D4',
'on-primary' => '#FFFFFF', // text ON a primary-coloured surface
/* Added for the listing components. Every one has a real use; a token
* that exists for tidiness rather than need is a token that drifts. */
'accent' => '#C9A45C', // aliased below for sites that call it that
'accent-ink' => '#7A4E00', // accent, dark enough to read as text
'on-accent' => '#FFFFFF',
'soft-accent' => '#FDF1E1',
'soft-green' => '#E1F7E8',
'verified' => '#1D9E75',
'verified-ink' => '#0E6B50',
'whatsapp' => '#25D366',
'line-soft' => '#F0EDDF',
'radius-sm' => '8px',
'radius-md' => '12px',
'radius-lg' => '16px',
'radius-pill' => '999px',
'shadow-sm' => '0 4px 12px rgba(0,0,0,.06)',
'shadow-md' => '0 12px 30px rgba(0,0,0,.08)',
'font-display' => "'Playfair Display',serif",
'font-body' => "'Inter',sans-serif",
'container' => '1160px',
);
$tokens = array_merge( $defaults, (array) ( $GLOBALS['up_theme']['tokens'] ?? array() ) );
echo '';
}
}
/**
* Emit a component's CSS exactly once per request.
*
* This is Law 1 made mechanical: a component cannot be rendered without its
* styles, and rendering it fifty times cannot emit them fifty times.
*/
if ( ! function_exists( 'up_css' ) ) {
function up_css( string $id, string $css ): void {
static $seen = array();
if ( isset( $seen[ $id ] ) ) { return; }
$seen[ $id ] = true;
up_theme_css();
echo '';
}
}
/** Shared container rule, needed by nearly every component. */
if ( ! function_exists( 'up_wrap_css' ) ) {
function up_wrap_css(): void {
up_css( 'wrap', '.up-wrap{max-width:var(--up-container);margin:0 auto;padding:0 24px}'
. '.up-eyebrow{font-size:.7rem;letter-spacing:.32em;text-transform:uppercase;color:var(--up-accent);font-weight:600}'
. '.up-sec-head{text-align:center;margin-bottom:40px}'
. '.up-sec-head h2{font-family:var(--up-font-display);font-weight:600;color:var(--up-primary);font-size:clamp(1.6rem,2.6vw,2.2rem);margin:12px 0 0}'
. '.up-sec-head h2 em{font-style:italic;color:var(--up-accent)}' );
}
}
/* ═══════════════════════════════════════════════════════════════════════
* COMPONENT 1 — BREADCRUMB
* data: [ ['name'=>string, 'url'=>string|''], … ] last item renders plain
* ═══════════════════════════════════════════════════════════════════════ */
if ( ! function_exists( 'up_render_breadcrumb' ) ) {
function up_render_breadcrumb( array $crumbs ): void {
$crumbs = array_values( array_filter( $crumbs, function ( $c ) { return '' !== trim( (string) ( $c['name'] ?? '' ) ); } ) );
if ( ! $crumbs ) { return; } // empty hides
up_wrap_css();
up_css( 'crumb',
'.up-crumb{font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--up-mute);margin-bottom:22px}'
. '.up-crumb a{color:var(--up-mute);text-decoration:none}'
. '.up-crumb a:hover{color:var(--up-primary)}'
. '.up-crumb span{color:var(--up-primary)}' );
echo '';
}
}
/* ═══════════════════════════════════════════════════════════════════════
* COMPONENT 2 — PAGE HERO (authored)
* data: eyebrow, heading (may contain ), intro (plain text, \n\n = para)
* ═══════════════════════════════════════════════════════════════════════ */
if ( ! function_exists( 'up_render_page_hero' ) ) {
function up_render_page_hero( array $d ): void {
$heading = trim( (string) ( $d['heading'] ?? '' ) );
$intro = trim( (string) ( $d['intro'] ?? '' ) );
$eyebrow = trim( (string) ( $d['eyebrow'] ?? '' ) );
if ( '' === $heading && '' === $intro ) { return; } // empty hides
up_wrap_css();
up_css( 'hero',
'.up-hero{background:var(--up-surface-warm);padding:56px 0 48px;border-bottom:1px solid var(--up-accent-soft)}'
. '.up-hero h1{font-family:var(--up-font-display);font-weight:600;color:var(--up-primary);'
. 'font-size:clamp(2rem,4vw,3rem);line-height:1.15;margin:14px 0 18px}'
. '.up-hero h1 em{font-style:italic;color:var(--up-accent)}'
. '.up-hero-intro{max-width:720px;color:var(--up-ink);font-size:1rem;line-height:1.7}'
. '.up-hero-intro p{margin:0 0 12px}'
. '.up-hero-intro p:last-child{margin-bottom:0}' );
echo '
';
return $schema;
}
}
/* ═══════════════════════════════════════════════════════════════════════
* COMPONENT 7 — ENQUIRY FORM
*
* The library owns the MECHANICS: markup, styling, the hidden fields, the
* return-URL field, the honeypot. The site supplies where a lead LANDS via
* 'action' and its own hidden fields — because the lead post type differs per
* site and the library must never learn a post type name.
* ═══════════════════════════════════════════════════════════════════════ */
if ( ! function_exists( 'up_render_enquiry' ) ) {
function up_render_enquiry( array $d ): void {
$action = (string) ( $d['action'] ?? '' );
if ( '' === $action ) { return; } // no handler declared → no form
up_wrap_css();
up_css( 'enq',
'.up-enq{background:var(--up-dark);padding:64px 0;color:var(--up-accent-soft)}'
. '.up-enq-grid{display:grid;grid-template-columns:1fr 1fr;gap:48px;align-items:center}'
. '.up-enq h2{font-family:var(--up-font-display);color:var(--up-accent-soft);font-size:1.8rem;margin:12px 0 14px}'
. '.up-enq h2 em{font-style:italic;color:var(--up-accent)}'
. '.up-enq p{color:var(--up-mute);font-size:.92rem;line-height:1.7}'
. '.up-enq input,.up-enq select{width:100%;padding:13px 2px;border:0;border-bottom:1px solid var(--up-mute);'
. 'background:transparent;color:var(--up-accent-soft);margin-bottom:16px;font-family:var(--up-font-body);font-size:.94rem}'
. '.up-enq input::placeholder{color:var(--up-mute)}'
. '.up-enq button{background:var(--up-primary);color:var(--up-on-primary);border:1px solid var(--up-accent);padding:14px 30px;'
. 'font-size:.74rem;letter-spacing:.2em;text-transform:uppercase;font-weight:600;cursor:pointer;'
. '-webkit-user-select:none;user-select:none;transition:background .2s ease}'
. '.up-enq button:hover{background:var(--up-primary-deep)}'
. '.up-enq-hp{position:absolute;left:-10000px;opacity:0;pointer-events:none}'
. '@media(max-width:900px){.up-enq-grid{grid-template-columns:1fr;gap:28px}}' );
echo '
';
}
}
/* ═══ 14. MEDIA LINKS ═════════════════════════════════════════════════
* YouTube and Instagram, in their own brand colours so they read as the
* platforms they are. No video is ever hosted here — these hand off, and on
* a phone the OS opens the app. Absent URL, absent button.
* ═════════════════════════════════════════════════════════════════════ */
if ( ! function_exists( 'up_render_media_links' ) ) {
function up_render_media_links( $youtube = '', $instagram = '' ) {
$youtube = trim( (string) $youtube );
$instagram = trim( (string) $instagram );
if ( '' === $youtube && '' === $instagram ) { return; }
up_wrap_css();
up_css( 'medialinks',
'.up-media{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}'
. '.up-media a{display:flex;align-items:center;justify-content:center;gap:9px;padding:13px;'
. 'border-radius:var(--up-radius-pill);text-decoration:none;font-weight:600;font-size:14.5px;color:#fff}'
. '.up-media .yt{background:#FF0000}'
. '.up-media .ig{background:linear-gradient(45deg,#F09433,#E6683C 25%,#DC2743 50%,#CC2366 75%,#BC1888)}'
. '.up-media a:hover{filter:brightness(1.07)}' );
echo '