/* ═══════════════════════════════════════════════════════════════════
   ActivationPrimitives.jsx — wiz-canon §4 activation primitives (Bundle B)

   The four Design-owned activation primitives, ported into the app's live
   pattern (React.createElement — no JSX; IIFE; window.WPSB.* namespace).
   Source of truth: _CLAUDE-DESIGN/activation-primitives.jsx (the .jsx source,
   NOT the demo .html harness). They COMPOSE on the live wizard layer:
     · WIcon                (design/WizardIcons.jsx → window.WIcon)
     · the wiz-btn set + wiz-card + wiz-tag + wiz-eyebrow  (design/tokens.css)
     · the net-new --coach-* / --news-* / --intro-* tokens (Phase-0, on main)

   Exports (V2-05/01/04b/04):
     · EmptyState        — the ONE shared illustrated empty-state (replaces the
                           ≥6 forks: AuditSurface/FileManager/Redirects/…)
     · FeaturePageShell  — dismissible intro → template gallery → EmptyState
     · CoachMark         — STANDALONE pin-to-selector tour overlay. NOT a
                           WizardShell fork (locked decision #34).
     · NewsCard + NewsDrawer — dashboard card + slide-out dated changelog
     · Modal             — the ONE shared dialog shell (L12; replaces 14 forks:
                           AccountApiKeys/TeamManagement/Redirects/FileManager/
                           Pages2/Roadmap ×2/CancellationFlow/OnboardingWizard/
                           FleetOnboarding/SiteBuilder/scanner ×2)

   Analytics (§6 #32): client-only UX success points emit fire-and-forget to
   POST /client/events/track (account/user from the JWT — never the body),
   matching HeaderUsageMeters. Keys reserved in api analytics.js KNOWN_EVENT_TYPES:
     activation.tour.started / .completed / .skipped
     activation.news.opened
     activation.feature_shell.template_selected
     activation.empty_state.cta
   Audience: SAAS-CUSTOMER (own dashboard) + WPSB-TEAM (full).

   Standards: React.createElement (no JSX, ASCII); var()-only, no hardcoded
   semantic hex; full light+dark parity; WCAG 2.1 AA (roles, labels, Escape,
   focus). These are SHARED components — placement into specific screens + any
   role-gating is the SEPARATE rolling-adoption pass, not this file.

   Load order: after WizardIcons.jsx (WIcon) + WizardPrimitives.jsx + tokens.
   Registers: bare globals (cross-module refs) + window.WPSB.Activation.*
   ═══════════════════════════════════════════════════════════════════ */
(function () {
  'use strict';
  if (typeof window === 'undefined' || !window.React) return;

  const { useState, useRef, useEffect } = React;
  const WIcon = window.WIcon;
  const tx = (k, v, f) => (window.WPSB && window.WPSB.t) ? window.WPSB.t(k, v, f) : (f != null ? f : k);

  /* ── Client analytics ingest (customer-scoped; account/user from JWT) ──
     Fire-and-forget: any failure is swallowed so analytics can never block
     or break the surface. Targets /client/events/track (NOT the SA-only
     /events/track). Same pattern as HeaderUsageMeters. */
  function apiBase() {
    return (window.WPSBD && window.WPSBD.apiBase)
      || (window.WPSB_CONFIG && window.WPSB_CONFIG.RAILWAY_URL)
      || window.RAILWAY_URL
      || 'https://api.wpsitebeam.io';
  }
  function authToken() {
    return (window.WPSB && window.WPSB.getToken && window.WPSB.getToken())
      || (window.WPSBD && window.WPSBD.getToken && window.WPSBD.getToken())
      || null;
  }
  function emitClientEvent(eventType, props) {
    try {
      const token = authToken(); if (!token) return;
      fetch(apiBase() + '/client/events/track', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ event_type: eventType, props: props || {} }),
      }).catch(function () {});
    } catch (_) { /* swallow */ }
  }

  /* ── 4d · SHARED ILLUSTRATED EMPTY-STATE (V2-05) ─────────────────────
     illustration slot + eyebrow + title + one-line body + primary (+ secondary).
     Honest-empty by construction: always offers the next action.

     THE ONE empty state (wiz-canon §4.4). Adopters as of the L11
     consolidation: AuditSurface, FileManager, Redirects, ScanHistory,
     TaskBoard — each previously carried its own fork.

     `icon` — EITHER a WIcon glyph name (string) OR a pre-rendered React
     element. The element form exists because WIcon carries 44 glyphs while
     the app's main `Icon` library (design/Icon.jsx) carries 102, and the
     adopting surfaces use names WIcon does not have (loader / warn /
     archive / redirect / tasks / scanner). WIcon renders an EMPTY <svg>
     for an unknown name — no fallback, no error — so passing those names
     through as strings would silently blank the illustration. Per
     R-SHAREDCORE the core stays icon-library-agnostic and the CALLER
     injects its own glyph; the core never grows a second icon dependency.

     `primary.icon` — same string-or-element contract, same reason (the
     ScanHistory CTA glyph is 'scanner', another name WIcon lacks).

     `children` — arbitrary trailing content slot (rendered last), for
     callers whose next-action isn't expressible as primary/secondary
     button descriptors. Carried over from the AuditSurface fork. */
  function EmptyState({ icon, eyebrow, title, body, primary, secondary, children }) {
    const onPrimary = () => {
      emitClientEvent('activation.empty_state.cta', { action: (primary && (primary.event || primary.label)) || null });
      if (primary && typeof primary.onClick === 'function') primary.onClick();
    };
    const onSecondary = () => { if (secondary && typeof secondary.onClick === 'function') secondary.onClick(); };
    const iconNode = React.isValidElement(icon)
      ? icon
      : React.createElement(WIcon, { name: icon || 'sparkles', size: 30 });
    const primaryIconNode = !primary || !primary.icon ? null
      : (React.isValidElement(primary.icon) ? primary.icon : React.createElement(WIcon, { name: primary.icon, size: 15 }));
    return React.createElement('div', { className: 'wiz-card', style: { background: 'var(--surface)', border: '1px dashed var(--border-2)',
      borderRadius: 'var(--r-xl)', padding: 'var(--sp-7)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--sp-3)' } },
      React.createElement('div', { 'aria-hidden': 'true', style: { width: 64, height: 64, borderRadius: 'var(--r-lg)', display: 'flex', alignItems: 'center', justifyContent: 'center',
        background: 'var(--intro-bg)', color: 'var(--beam)', border: '1px solid var(--intro-border)', marginBottom: 'var(--sp-2)' } },
        iconNode),
      eyebrow && React.createElement('span', { className: 'wiz-eyebrow' }, eyebrow),
      React.createElement('h2', { className: 'wiz-title', style: { margin: 0, fontSize: '1.25rem' } }, title),
      body && React.createElement('p', { style: { margin: 0, maxWidth: 420, color: 'var(--text-2)', fontSize: '.88rem', lineHeight: 1.5 } }, body),
      (primary || secondary) && React.createElement('div', { style: { display: 'flex', gap: 'var(--sp-3)', marginTop: 'var(--sp-3)', flexWrap: 'wrap', justifyContent: 'center' } },
        primary && React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-primary wiz-focusable', onClick: onPrimary }, primaryIconNode, primary.label),
        secondary && React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-secondary wiz-focusable', onClick: onSecondary }, secondary.label)),
      children);
  }

  /* ── 4a · NEVER-EMPTY FEATURE-PAGE SHELL (V2-01) ─────────────────────
     dismissible educational intro → starter-template gallery → content/empty.
     onTemplate(template) fires on a template pick (analytics + caller hook). */
  function FeaturePageShell({ feature, intro, templates, emptyProps, onTemplate }) {
    const [dismissed, setDismissed] = useState(false);
    const pickTemplate = (t) => {
      emitClientEvent('activation.feature_shell.template_selected', { feature: feature || null, template: (t && (t.key || t.title)) || null });
      if (typeof onTemplate === 'function') onTemplate(t);
      else if (t && typeof t.onSelect === 'function') t.onSelect(t);
    };
    return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-5)' } },
      // educational intro card (dismissible)
      !dismissed && React.createElement('div', { className: 'wiz-card', style: { background: 'var(--intro-bg)', border: '1px solid var(--intro-border)',
        borderRadius: 'var(--r-lg)', padding: 'var(--sp-5)', display: 'flex', alignItems: 'center', gap: 'var(--sp-5)', flexWrap: 'wrap' } },
        React.createElement('div', { 'aria-hidden': 'true', style: { width: 52, height: 52, flex: 'none', borderRadius: 'var(--r)', display: 'flex', alignItems: 'center', justifyContent: 'center',
          background: 'color-mix(in oklch, var(--beam) 16%, transparent)', color: 'var(--beam)', border: '1px solid color-mix(in oklch, var(--beam) 30%, transparent)' } },
          React.createElement(WIcon, { name: intro.icon, size: 26 })),
        React.createElement('div', { style: { flex: 1, minWidth: 220 } },
          React.createElement('div', { style: { fontWeight: 'var(--fw-label)', fontSize: '1rem', color: 'var(--text)', marginBottom: 4 } }, intro.title),
          React.createElement('p', { style: { margin: 0, fontSize: '.86rem', color: 'var(--text-2)', lineHeight: 1.45 } }, intro.body)),
        React.createElement('div', { style: { display: 'flex', gap: 'var(--sp-2)', flex: 'none' } },
          React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-tool wiz-btn-sm wiz-focusable', onClick: intro.onLearn || (() => {}) }, React.createElement(WIcon, { name: 'file-text', size: 14 }), tx('primitive.featureshell.learn', { feature: feature }, 'Learn about ' + feature)),
          React.createElement('button', { type: 'button', 'aria-label': tx('common.dismiss', null, 'Dismiss'), className: 'wiz-btn wiz-btn-ghost wiz-btn-sm wiz-focusable', onClick: () => setDismissed(true), style: { padding: 8 } }, React.createElement(WIcon, { name: 'x', size: 16 })))),
      // starter-template gallery
      templates && templates.length > 0 && React.createElement('div', null,
        React.createElement('span', { className: 'wiz-eyebrow', style: { display: 'block', marginBottom: 'var(--sp-3)' } }, tx('primitive.featureshell.start_template', null, 'Start from a template')),
        React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px,1fr))', gap: 'var(--sp-3)' } },
          templates.map((t, i) => React.createElement('button', { key: t.key || i, type: 'button', className: 'wiz-card wiz-focusable', onClick: () => pickTemplate(t), style: { textAlign: 'left', cursor: 'pointer',
            background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 'var(--sp-4)', display: 'flex', flexDirection: 'column', gap: 6 } },
            React.createElement('span', { 'aria-hidden': 'true', style: { width: 34, height: 34, borderRadius: 'var(--r-sm)', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--surface-2)', color: 'var(--beam)', border: '1px solid var(--border)' } }, React.createElement(WIcon, { name: t.icon, size: 17 })),
            React.createElement('span', { style: { fontWeight: 'var(--fw-label)', fontSize: '.88rem', color: 'var(--text)' } }, t.title),
            React.createElement('span', { style: { fontSize: '.78rem', color: 'var(--muted)', lineHeight: 1.4 } }, t.desc)))))
    ,
      // content / empty
      emptyProps && React.createElement(EmptyState, emptyProps));
  }

  /* ── 4b · COACH-MARK GUIDED TOUR (V2-04b — STANDALONE, locked #34) ────
     Pin-to-selector overlay: numbered bubble anchored to a target rect,
     persona avatar, skip/back/next. NOT a WizardShell fork — its own
     primitive (window.WPSB.Activation.CoachMark). Renders the OVERLAY
     (scrim + spotlight ring + pinned dialog), never just a trigger.
     Props: steps [{selector,title,body,persona}], targetsRef (ref → map
     selector→element), onClose. */
  function CoachMark({ steps, targetsRef, onClose }) {
    const [i, setI] = useState(0);
    const [rect, setRect] = useState(null);
    const dialogRef = useRef(null);
    const step = (steps && steps[i]) || {};
    const last = i === (steps ? steps.length : 1) - 1;
    const pad = 8;

    // Emit tour-started once, on mount.
    useEffect(() => { emitClientEvent('activation.tour.started', { steps: steps ? steps.length : 0 }); }, []);

    // Measure the pinned target for the current step (+ reflow on resize).
    useEffect(() => {
      const measure = () => {
        const map = targetsRef && targetsRef.current;
        const el = map && step && step.selector ? map[step.selector] : null;
        setRect(el && el.getBoundingClientRect ? el.getBoundingClientRect() : null);
      };
      measure();
      window.addEventListener('resize', measure);
      return () => window.removeEventListener('resize', measure);
    }, [i]);

    // Move focus to the dialog when the step changes (a11y).
    useEffect(() => { if (dialogRef.current) dialogRef.current.focus(); }, [i]);

    const finish = (reason) => {
      if (reason === 'completed') emitClientEvent('activation.tour.completed', { steps: steps ? steps.length : 0 });
      else emitClientEvent('activation.tour.skipped', { step: i + 1, of: steps ? steps.length : 0 });
      if (typeof onClose === 'function') onClose();
    };
    const onKeyDown = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); finish('skipped'); }
      else if (e.key === 'ArrowRight') { e.preventDefault(); if (!last) setI(i + 1); }
      else if (e.key === 'ArrowLeft') { e.preventDefault(); if (i > 0) setI(i - 1); }
    };

    const bubbleTop = rect ? rect.bottom + 12 : 120;
    const bubbleLeft = rect ? Math.max(12, rect.left) : 24;
    return React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 'var(--z-coach)' }, onKeyDown: onKeyDown },
      // scrim (click to skip)
      React.createElement('div', { 'aria-hidden': 'true', onClick: () => finish('skipped'), style: { position: 'absolute', inset: 0, background: 'var(--coach-scrim)' } }),
      // spotlight ring around the pinned target
      rect && React.createElement('div', { 'aria-hidden': 'true', style: { position: 'absolute', top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2,
        border: '2px solid var(--coach-accent)', borderRadius: 'var(--r)', boxShadow: '0 0 0 9999px var(--coach-scrim)', pointerEvents: 'none', transition: 'all 220ms ease' } }),
      // pinned tooltip dialog
      React.createElement('div', { ref: dialogRef, role: 'dialog', 'aria-modal': 'true', tabIndex: -1,
        'aria-label': tx('primitive.coach.step_label', { n: i + 1, total: steps ? steps.length : 1 }, 'Tour step ' + (i + 1) + ' of ' + (steps ? steps.length : 1)),
        style: { position: 'absolute', top: bubbleTop, left: bubbleLeft, zIndex: 1,
        width: 300, maxWidth: 'calc(100vw - 24px)', background: 'var(--coach-bg)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-lg)', boxShadow: 'var(--shadow-lg)', padding: 'var(--sp-5)' } },
        React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 'var(--sp-3)' } },
          React.createElement('span', { 'aria-hidden': 'true', style: { width: 34, height: 34, flex: 'none', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, var(--beam), var(--rose))', color: 'var(--on-accent)', fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: '.72rem' } }, step.persona || 'JO'),
          React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 26, height: 22, padding: '0 8px', borderRadius: 999, background: 'var(--beam-dim)', color: 'var(--beam)', fontFamily: 'var(--font-mono)', fontSize: '.66rem', fontWeight: 700 } }, (i + 1) + ' / ' + (steps ? steps.length : 1))),
        React.createElement('div', { style: { fontWeight: 'var(--fw-label)', fontSize: '.95rem', color: 'var(--text)', marginBottom: 4 } }, step.title),
        React.createElement('p', { style: { margin: '0 0 var(--sp-4)', fontSize: '.84rem', color: 'var(--text-2)', lineHeight: 1.5 } }, step.body),
        React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--sp-3)' } },
          React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-ghost wiz-btn-sm wiz-focusable', onClick: () => finish('skipped') }, tx('primitive.coach.skip', null, 'Skip tour')),
          React.createElement('div', { style: { display: 'flex', gap: 'var(--sp-2)' } },
            i > 0 && React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-secondary wiz-btn-sm wiz-focusable', onClick: () => setI(i - 1) }, tx('primitive.coach.back', null, 'Back')),
            React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-primary wiz-btn-sm wiz-focusable', onClick: () => last ? finish('completed') : setI(i + 1) }, last ? tx('primitive.coach.done', null, 'Done') : tx('primitive.coach.next', null, 'Next'))))));
  }

  /* ── 4c · NEWS / WHAT'S-NEW (V2-04) ──────────────────────────────────
     dashboard news card + slide-out dated changelog. Value feed, not ads.
     NEWS_ITEMS is the default placeholder feed; a real adopter passes items. */
  const NEWS_ITEMS = [
    { date: 'Jul 8', kind: 'shipped', tag: 'Shipped', title: 'Migrations: dual-side rollback', body: 'Every migration now snapshots source and destination; revert both in one click.', isNew: true },
    { date: 'Jul 1', kind: 'shipped', tag: 'Shipped', title: 'Client Reports white-label', body: 'Send reports under your own logo and domain on the Agency plan.', isNew: true },
    { date: 'Jun 24', kind: 'roadmap', tag: 'Roadmap', title: 'Media Manager image editor', body: 'Crop, compress, and convert formats in a guided editor — in build.' },
    { date: 'Jun 18', kind: 'blog', tag: 'Blog', title: 'Core Web Vitals field guide', body: 'How WPSiteBeam measures LCP/INP/CLS and what moves them.' },
  ];
  function newsAccent(kind) { return kind === 'shipped' ? 'var(--green)' : kind === 'roadmap' ? 'var(--beam)' : 'var(--purple)'; }

  function NewsCard({ onOpen, items }) {
    const feed = items || NEWS_ITEMS;
    const unread = feed.filter(n => n.isNew).length;
    return React.createElement('div', { className: 'wiz-card', style: { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 'var(--sp-5)' } },
      React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 'var(--sp-4)' } },
        React.createElement('span', { className: 'wiz-eyebrow', style: { display: 'inline-flex', alignItems: 'center', gap: 7 } },
          React.createElement('span', { 'aria-hidden': 'true', style: { width: 7, height: 7, borderRadius: '50%', background: 'var(--news-dot)' } }), tx('primitive.news.title', null, "What's new")),
        unread > 0 && React.createElement('span', { className: 'wiz-tag wiz-tag-new' }, tx('primitive.news.unread', { n: unread }, unread + ' new'))),
      React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-3)' } },
        feed.slice(0, 2).map((n, i) => React.createElement('div', { key: i, style: { display: 'flex', gap: 'var(--sp-3)', alignItems: 'flex-start' } },
          React.createElement('span', { 'aria-hidden': 'true', style: { width: 8, height: 8, marginTop: 6, borderRadius: 2, flex: 'none', background: newsAccent(n.kind) } }),
          React.createElement('div', { style: { minWidth: 0 } },
            React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
              React.createElement('span', { style: { fontWeight: 'var(--fw-label)', fontSize: '.86rem', color: 'var(--text)' } }, n.title),
              n.isNew && React.createElement('span', { style: { fontFamily: 'var(--font-mono)', fontSize: '.54rem', fontWeight: 700, letterSpacing: '.08em', color: 'var(--news-new)', background: 'var(--green-dim)', border: '1px solid color-mix(in oklch, var(--green) 30%, transparent)', borderRadius: 999, padding: '1px 6px' } }, tx('primitive.news.new_badge', null, 'NEW'))),
            React.createElement('p', { style: { margin: '2px 0 0', fontSize: '.8rem', color: 'var(--text-2)', lineHeight: 1.4 } }, n.body)))),
        React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-ghost wiz-btn-sm wiz-focusable', style: { alignSelf: 'flex-start' }, onClick: onOpen }, tx('primitive.news.see_all', null, 'See all updates'), React.createElement(WIcon, { name: 'arrow-up-right', size: 14 }))));
  }

  /* NewsDrawer — the one overlay in this file NOT routed through the §4e
     Modal, and the reason is its mount model, not an oversight. It stays
     MOUNTED while closed and animates on `open` (scrim opacity + panel
     translateX). The shared primitive mounts and unmounts with the
     caller, so routing this through it would either drop the slide
     animation or force an always-mounted mode into the core for exactly
     one caller — a caller-welded core, which R-SHAREDCORE forbids as
     surely as a fork does.

     What it DID adopt from the consolidation is the part that was
     actually broken: the z-scale. It sat at 150/151, BELOW shell.css's
     400 sidebar, so the drawer slid out UNDERNEATH the sidebar it was
     supposed to cover. It is now on --z-drawer.

     If an always-mounted transition is ever wanted by a second caller,
     that is the moment to add the mode to the core — not before. */
  function NewsDrawer({ open, onClose, items }) {
    const feed = items || NEWS_ITEMS;
    const wasOpen = useRef(false);
    // Emit news.opened on the false→true transition (covers every open path).
    useEffect(() => {
      if (open && !wasOpen.current) emitClientEvent('activation.news.opened', { unread: feed.filter(n => n.isNew).length });
      wasOpen.current = open;
    }, [open]);
    // Escape closes the drawer.
    useEffect(() => {
      if (!open) return;
      const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
      window.addEventListener('keydown', onKey);
      return () => window.removeEventListener('keydown', onKey);
    }, [open]);
    return React.createElement(React.Fragment, null,
      React.createElement('div', { 'aria-hidden': 'true', onClick: onClose, style: { position: 'fixed', inset: 0, zIndex: 'var(--z-drawer)', background: 'var(--coach-scrim)', opacity: open ? 1 : 0, pointerEvents: open ? 'auto' : 'none', transition: 'opacity 200ms ease' } }),
      React.createElement('aside', { role: 'dialog', 'aria-modal': open ? 'true' : undefined, 'aria-label': tx('primitive.news.title', null, "What's new"), style: { position: 'fixed', top: 0, bottom: 0, right: 0, zIndex: 'calc(var(--z-drawer) + 1)', width: 'min(92vw, 420px)', background: 'var(--surface)', borderLeft: '1px solid var(--border)',
        transform: open ? 'none' : 'translateX(100%)', transition: 'transform 220ms ease', overflowY: 'auto', boxShadow: 'var(--shadow-lg)' } },
        React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: 'var(--sp-5)', borderBottom: '1px solid var(--border)', position: 'sticky', top: 0, background: 'var(--surface)' } },
          React.createElement('h2', { className: 'wiz-title', style: { margin: 0, fontSize: '1.1rem' } }, tx('primitive.news.title', null, "What's new")),
          React.createElement('button', { type: 'button', 'aria-label': tx('common.close', null, 'Close'), className: 'wiz-btn wiz-btn-ghost wiz-btn-sm wiz-focusable', style: { padding: 8 }, onClick: onClose }, React.createElement(WIcon, { name: 'x', size: 18 }))),
        React.createElement('div', { style: { padding: 'var(--sp-5)', display: 'flex', flexDirection: 'column', gap: 'var(--sp-5)' } },
          feed.map((n, i) => {
            const c = newsAccent(n.kind);
            return React.createElement('div', { key: i, style: { display: 'flex', gap: 'var(--sp-4)' } },
              React.createElement('div', { 'aria-hidden': 'true', style: { flex: 'none', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 } },
                React.createElement('span', { style: { width: 10, height: 10, borderRadius: '50%', background: c } }),
                i < feed.length - 1 && React.createElement('span', { style: { flex: 1, width: 2, background: 'var(--border)' } })),
              React.createElement('div', { style: { minWidth: 0, paddingBottom: 'var(--sp-3)' } },
                React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 3 } },
                  React.createElement('span', { style: { fontFamily: 'var(--font-mono)', fontSize: '.66rem', color: 'var(--muted)' } }, n.date),
                  React.createElement('span', { style: { fontFamily: 'var(--font-mono)', fontSize: '.6rem', fontWeight: 700, letterSpacing: '.05em', color: c, background: 'color-mix(in oklch, ' + c + ' 12%, transparent)', border: '1px solid color-mix(in oklch, ' + c + ' 30%, transparent)', borderRadius: 999, padding: '2px 8px' } }, n.tag),
                  n.isNew && React.createElement('span', { style: { fontFamily: 'var(--font-mono)', fontSize: '.54rem', fontWeight: 700, letterSpacing: '.08em', color: 'var(--news-new)' } }, tx('primitive.news.new_badge', null, 'NEW'))),
                React.createElement('div', { style: { fontWeight: 'var(--fw-label)', fontSize: '.92rem', color: 'var(--text)', marginBottom: 3 } }, n.title),
                React.createElement('p', { style: { margin: 0, fontSize: '.84rem', color: 'var(--text-2)', lineHeight: 1.5 } }, n.body),
                n.href && React.createElement('button', { type: 'button', className: 'wiz-btn wiz-btn-ghost wiz-btn-sm wiz-focusable', style: { marginTop: 6 }, onClick: () => { try { window.open(n.href, '_blank', 'noopener'); } catch (e) {} } }, tx('primitive.news.read_more', null, 'Read more'), React.createElement(WIcon, { name: 'arrow-up-right', size: 13 }))));
          })))
    );
  }

  /* ── 4e · MODAL — THE ONE SHARED DIALOG SHELL (L12 consolidation) ────
     Consolidates 14 hand-rolled modal forks onto one primitive. Built by
     EXTENDING the CoachMark overlay above rather than starting fresh
     (R-SHAREDCORE: extend the core, never fork it) — CoachMark was the
     only fork carrying role/aria-modal/tabIndex/dialogRef/keyboard/resize
     teardown, and under master §3 #1 a dialog without focus management is
     an accessibility defect. What is NET-NEW here, and was in NO fork:

       · A REAL FOCUS TRAP. Every prior fork — CoachMark included — at
         best moved focus INTO the dialog and then let Tab walk straight
         back out into the page behind the scrim. Tab/Shift+Tab now cycle
         within the dialog.
       · FOCUS RESTORE on close, back to the element that opened it.
       · Escape + scrim-click on every consumer, uniformly. Several forks
         had one, some had neither.

     LIBRARY-AGNOSTIC BY CONSTRUCTION. The close affordance is an inline
     SVG this file owns; it reaches for neither WIcon nor Icon. That is
     deliberate: WIcon renders an EMPTY <svg> for an unknown name — no
     fallback, no throw, no console warning, CI green — so a primitive
     that took an icon NAME would silently blank its own close button for
     any caller whose glyph lives in the other library. `closeIcon` takes
     a RENDERED ELEMENT, so the caller injects its own glyph from its own
     library and the core stays ignorant of both.

     RESPONSIVE IS NOT OPTIONAL (master §3 #26). The card carries
     .wpsb-modal and the scrolling region .wpsb-modal__body — both were
     DEFINED in tokens.css (~:790, ~:848) with zero consumers, so every
     fork was a fixed-width box on a 320px phone. Consuming them here is
     what lets any screen carrying a modal reach "v2-adopted" at all.

     Props:
       title       string|node   header slot; omit for a bodyless dialog
       children    node          body
       onClose     fn            Escape / scrim / close button
       danger      bool          red accent border + red title
       variant     'center'|'fullscreen'|'drawer'   default 'center'
       align       'center'|'top'                   default 'center'
       width       number|string card max-width (default 520)
       ariaLabel   string        label when `title` is a node or absent
       labelledBy  string        id, when the caller renders its own <h_>
       zIndex      string        a --z-* token (default var(--z-modal))
       closeIcon   element       RENDERED element, never a name
       showClose   bool          default: true when a title is present
       bodyPad     string        body padding override
       cardStyle   object        escape hatch for per-caller card tweaks
       scrimClose  bool          default true                            */
  const CLOSE_GLYPH = React.createElement('svg', {
    width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
    strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
    style: { display: 'block' },
  }, React.createElement('path', { d: 'M18 6 6 18' }), React.createElement('path', { d: 'M6 6l12 12' }));

  /* Focusable descendants, in DOM order, excluding anything inert. */
  const FOCUSABLE = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';

  function Modal(props) {
    const p = props || {};
    const onClose = typeof p.onClose === 'function' ? p.onClose : function () {};
    const variant = p.variant || 'center';
    const dialogRef = useRef(null);
    const restoreRef = useRef(null);

    /* Remember what had focus, move focus in, and put it back on unmount.
       Restoring matters most for the keyboard path: without it focus falls
       to <body> and the next Tab restarts from the top of the document.

       AUTOFOCUS IS HONOURED FIRST, and it is not a nicety: several
       adopters declare `autoFocus` on the field they want (Pages2's
       "Submit an idea" title input, scanner/shared's Cancel button for
       non-destructive confirms). Blindly focusing the first tabbable
       node would move focus to the header close button and silently
       cancel that intent, converting a consolidation into a small
       usability regression on every such surface. Checking the DOM for
       a declared autofocus keeps the rule general — the core reads what
       the caller marked up, rather than growing a per-caller prop. */
    useEffect(function () {
      restoreRef.current = (typeof document !== 'undefined' && document.activeElement) || null;
      const node = dialogRef.current;
      if (node) {
        const declared = node.querySelector('[autofocus]');
        const first = declared || node.querySelector(FOCUSABLE);
        (first || node).focus();
      }
      return function () {
        const back = restoreRef.current;
        if (back && typeof back.focus === 'function' && document.contains(back)) {
          try { back.focus(); } catch (e) {}
        }
      };
    }, []);

    /* Escape + the focus trap. Bound to the dialog subtree, not window, so
       nested dialogs do not both close on one Escape. */
    const onKeyDown = function (e) {
      if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return; }
      if (e.key !== 'Tab') return;
      const node = dialogRef.current;
      if (!node) return;
      const items = Array.prototype.slice.call(node.querySelectorAll(FOCUSABLE))
        .filter(function (el) { return el.offsetParent !== null || el === document.activeElement; });
      if (!items.length) { e.preventDefault(); node.focus(); return; }   // nothing tabbable — hold focus
      const first = items[0], last = items[items.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    };

    const labelProps = {};
    if (p.labelledBy) labelProps['aria-labelledby'] = p.labelledBy;
    else labelProps['aria-label'] = p.ariaLabel || (typeof p.title === 'string' ? p.title : tx('common.dialog', null, 'Dialog'));

    const showClose = p.showClose != null ? p.showClose : (p.title != null);
    const accent = p.danger ? 'var(--red)' : 'var(--border)';
    const zIndex = p.zIndex || (variant === 'fullscreen' ? 'var(--z-fullscreen)' : variant === 'drawer' ? 'var(--z-drawer)' : 'var(--z-modal)');

    /* The card. .wpsb-modal turns this box into a full-bleed 100dvh sheet
       on phone; on desktop the inline rules below govern. */
    const cardBase = variant === 'fullscreen'
      ? { background: 'var(--surface)', display: 'flex', flexDirection: 'column', width: '100%', height: '100%', border: 0, borderRadius: 0 }
      : variant === 'drawer'
        ? { background: 'var(--surface)', borderLeft: '1px solid ' + accent, width: 'min(92vw, 420px)', marginLeft: 'auto', height: '100%', display: 'flex', flexDirection: 'column' }
        : { background: 'var(--surface)', border: '1px solid ' + accent, borderRadius: 'var(--r-lg)',
            width: '100%', maxWidth: typeof p.width === 'number' ? p.width + 'px' : (p.width || '520px'),
            maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: 'var(--shadow-lg)' };

    const header = (p.title != null || showClose) && React.createElement('div', {
      style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--sp-3)',
        padding: '16px 20px', borderBottom: '1px solid var(--border)' },
    },
      p.title != null
        ? React.createElement('h2', { id: p.titleId, style: { margin: 0, fontSize: '1.05rem', fontWeight: 'var(--fw-semibold)', color: p.danger ? 'var(--red)' : 'var(--text)', minWidth: 0 } }, p.title)
        : React.createElement('span', null),
      showClose && React.createElement('button', {
        type: 'button', onClick: onClose, className: 'wiz-btn wiz-btn-ghost wiz-btn-sm wiz-focusable',
        'aria-label': tx('common.close', null, 'Close'),
        style: { flex: 'none', padding: 6, lineHeight: 0, minWidth: 32, minHeight: 32, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
      }, p.closeIcon || CLOSE_GLYPH));

    return React.createElement('div', {
      onKeyDown: onKeyDown,
      onClick: function (e) { if (p.scrimClose !== false && e.target === e.currentTarget) onClose(); },
      style: {
        position: 'fixed', inset: 0, zIndex: zIndex,
        background: 'var(--coach-scrim)',
        display: 'flex',
        alignItems: variant === 'fullscreen' || variant === 'drawer' ? 'stretch' : (p.align === 'top' ? 'flex-start' : 'center'),
        justifyContent: 'center',
        padding: variant === 'fullscreen' || variant === 'drawer' ? 0 : (p.align === 'top' ? '8vh 16px' : 20),
      },
    },
      React.createElement('div', Object.assign({
        ref: dialogRef, role: 'dialog', 'aria-modal': 'true', tabIndex: -1,
        className: 'wpsb-modal' + (p.className ? ' ' + p.className : ''),
        onClick: function (e) { e.stopPropagation(); },
        style: Object.assign({}, cardBase, p.cardStyle || null),
      }, labelProps),
        header,
        React.createElement('div', {
          className: 'wpsb-modal__body',
          style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: p.bodyPad != null ? p.bodyPad : '18px 20px' },
        }, p.children)));
  }

  const exports = { EmptyState, FeaturePageShell, CoachMark, Modal, NewsCard, NewsDrawer, NEWS_ITEMS };
  Object.assign(window, exports);                                     // back-compat globals (cross-module refs)
  window.WPSB = window.WPSB || {};
  window.WPSB.Activation = Object.assign(window.WPSB.Activation || {}, exports);
  if (window.console && console.log) console.log('[WPSB Activation] primitives loaded (EmptyState, FeaturePageShell, CoachMark, Modal, NewsCard/Drawer).');
})();
