/* global React, window, document */
//
// signed-out-sidebar.jsx — the portal preview rail on the PUBLIC calculator.
// 2026-08-07. Ships DARK behind window.GG_FEATURE_SOS. Preview with ?sos=1.
//
// WHAT THIS IS. On app.gogorilla.com a visitor is never signed in, by Alexander's
// own definition of the host (LOOM 16 at 24:56, "app is just this, pre-login state").
// REFERRALS IS THE EXCEPTION AND IT IS DELIBERATE, 19 Aug 2026. That surface now
// renders LIVE, unblurred and interactive, because Alexander asked for exactly that
// in FREELANCER 18 and because the portal's own zero state IS the whole page with
// zeros in it. Everything else below still describes the other six surfaces.
//
// After they pick a client type, a portal-style rail slides in from the left so they
// can see the shape of the product they would be signing into. Grow is the calculator
// they are already standing on, so it is real. Everything else is a blurred glimpse
// behind a sign-in prompt, which is the difference Alexander draws against Mercor at
// LOOM 16 02:09, where a click "just goes straight to sign in" and he wants a blur
// "giving you like a glimpse of what you can expect".
//
// THE RAILS. Only ONE of the three has a live source of truth.
//   PARTNER is copied item for item from the portal. THE SOURCE OF TRUTH IS
//   lib/account/freelancer-nav.ts IN aonslow/gogorilla-platform, the FREELANCER_NAV array,
//   confirmed against the running portal at portal.gogorilla.com/partners on 7 August:
//   Home, Grow, Get Work, Projects, Referrals, Earnings.
//   UPDATED 19 Aug 2026 (Nicole) and checked against GET /api/v1/public/nav/partners,
//   which serves this order with Grow second. PROFILE IS GONE: it was merged into
//   account settings on the portal, and account settings is not reachable from the
//   standalone page, so the item led nowhere. The old line is replaced rather than
//   left above the new one, because a stale description sitting beside a current one
//   is this repo's signature rot and has bitten three times.
//   NOTE two harmless leftovers, recorded so nobody reads them as live: the profile
//   icon at the icon map and the profile preview surface are now unreachable, since
//   no rail item points at either. The 'Profile strength' panel inside the HOME
//   preview is NOT one of them and must stay, because the portal's Home card shows it.
//   RE-SYNC RULE, same shape as the service-teasers one. This is a MIRROR of another repo,
//   and a mirror drifts. If FREELANCER_NAV changes, this changes. Note that array also
//   carries a Services item which is NOT in the live rail because its flag is off, so read
//   the flags as well as the labels before copying anything across.
//   THE REAL FIX IS AN ENDPOINT, NOT A TIGHTER MIRROR. Two repos, and this one has no build
//   step, so a .tsx component cannot be imported here however much we would like it to be.
//   The portal exposing FREELANCER_NAV as public JSON, the way it already exposes prices,
//   would make a rename there propagate here with no deploy. Asked for in the 7 August note.
//   DO NOT EDIT THESE LABELS TO TASTE. The preview exists to look like the real thing, and a
//   mismatch is caught by the user at the exact moment they sign in. If the portal renames
//   something, this follows it, never the other way round.
//   FOUNDER is designed, not mirrored, because that sidebar does not exist yet (LOOM 15 at
//   08:16). Its shape follows the Founders Engine design-inheritance law: Home and Find
//   Investors are founders-specific, everything else inherits the partner rail.
//   INVESTOR is designed too, and the portal is on hold (LOOM 15 at 07:51). Its items are
//   derived from the live pricing page's own feature groups at /pricing/investor-portal.
//
// NO WAITING LIST BADGES IN THE RAIL. Ruled by Nicole 7 August. The investor tier cards in
// the calculator already say it, so a badge on every rail item is the third telling of the
// same fact. It is said once, in the overlay, where it is the call to action rather than
// decoration. Locked items use a padlock in all three variants, which is one visual language
// and is Alexander's own (LOOM 9 at 19:32 and 39:35, "maybe we have a padlock over it").

(function () {
  'use strict';

  var e = React.createElement;

  // *** NEVER RENDER WHEN EMBEDDED. THIS CHECK COMES FIRST AND BEATS BOTH THE FLAG AND
  // THE PREVIEW PARAM, ON PURPOSE. *** Inside the portal Grow tab the host already has its
  // own rail, so rendering ours would put two sidebars side by side. It is also simply
  // wrong on its own terms: this is a SIGNED-OUT preview of a portal, and anybody seeing
  // the embedded calculator is signed into that portal already. Same reasoning as the
  // checkout call to action, where being embedded IS the signed-in signal.
  //
  // window.GG_EMBED is the contract the host sets before our scripts load, per
  // assets/embed-manifest.v1.json. GG_EMBED_CONTRACT is NOT the signal, we set that
  // ourselves and it is always present.
  //
  // Raised by Nicole on 7 August before the flag was ever flipped, which is the only
  // reason it never shipped as a defect.
  function isEmbedded() {
    try { return !!(window.GG_EMBED && typeof window.GG_EMBED === 'object'); }
    catch (err) { return false; }
  }

  function enabled() {
    try {
      if (isEmbedded()) return false;
      if (window.GG_FEATURE_SOS === true) return true;
      return new URLSearchParams(window.location.search || '').get('sos') === '1';
    } catch (err) { return false; }
  }

  // ── ICONS. THESE ARE THE PORTAL'S OWN, NOT LOOK-ALIKES. ──────────────────
  // Extracted verbatim from lucide-static, the same icon set FreelancerRail.tsx
  // imports from lucide-react, and mapped with the SAME KEYS that file uses:
  //   home: Home · explore: Compass · clients: Users · grow: TrendingUp
  //   referrals: Share2 · addservice: Package · earnings: PoundSterling
  //   profile: UserRound
  // search and portfolio are the two the portal has no entry for, because the
  // founder and investor rails do not exist there yet. Same family, so they do
  // not read as borrowed from somewhere else.
  //
  // WHY COPIES AND NOT AN IMPORT. lucide-react is a node module and this repo
  // has no build step, so there is nothing here that could resolve it. Copying
  // the paths is the closest thing to using the component itself, and it is a
  // mirror like the rail labels are, so the same re-sync rule applies.
  var ICON_PATHS = {
      "home": [
          {
              "t": "path",
              "a": {
                  "d": "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
              }
          }
      ],
      "explore": [
          {
              "t": "circle",
              "a": {
                  "cx": "12",
                  "cy": "12",
                  "r": "10"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"
              }
          }
      ],
      "clients": [
          {
              "t": "path",
              "a": {
                  "d": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M16 3.128a4 4 0 0 1 0 7.744"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M22 21v-2a4 4 0 0 0-3-3.87"
              }
          },
          {
              "t": "circle",
              "a": {
                  "cx": "9",
                  "cy": "7",
                  "r": "4"
              }
          }
      ],
      "grow": [
          {
              "t": "path",
              "a": {
                  "d": "M16 7h6v6"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "m22 7-8.5 8.5-5-5L2 17"
              }
          }
      ],
      "referrals": [
          {
              "t": "circle",
              "a": {
                  "cx": "18",
                  "cy": "5",
                  "r": "3"
              }
          },
          {
              "t": "circle",
              "a": {
                  "cx": "6",
                  "cy": "12",
                  "r": "3"
              }
          },
          {
              "t": "circle",
              "a": {
                  "cx": "18",
                  "cy": "19",
                  "r": "3"
              }
          },
          {
              "t": "line",
              "a": {
                  "x1": "8.59",
                  "x2": "15.42",
                  "y1": "13.51",
                  "y2": "17.49"
              }
          },
          {
              "t": "line",
              "a": {
                  "x1": "15.41",
                  "x2": "8.59",
                  "y1": "6.51",
                  "y2": "10.49"
              }
          }
      ],
      "addservice": [
          {
              "t": "path",
              "a": {
                  "d": "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M12 22V12"
              }
          },
          {
              "t": "polyline",
              "a": {
                  "points": "3.29 7 12 12 20.71 7"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "m7.5 4.27 9 5.15"
              }
          }
      ],
      "earnings": [
          {
              "t": "path",
              "a": {
                  "d": "M18 7c0-5.333-8-5.333-8 0"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M10 7v14"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M6 21h12"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M6 13h10"
              }
          }
      ],
      "profile": [
          {
              "t": "circle",
              "a": {
                  "cx": "12",
                  "cy": "8",
                  "r": "5"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M20 21a8 8 0 0 0-16 0"
              }
          }
      ],
      "search": [
          {
              "t": "path",
              "a": {
                  "d": "m21 21-4.34-4.34"
              }
          },
          {
              "t": "circle",
              "a": {
                  "cx": "11",
                  "cy": "11",
                  "r": "8"
              }
          }
      ],
      "portfolio": [
          {
              "t": "path",
              "a": {
                  "d": "M21 12c.552 0 1.005-.449.95-.998a10 10 0 0 0-8.953-8.951c-.55-.055-.998.398-.998.95v8a1 1 0 0 0 1 1z"
              }
          },
          {
              "t": "path",
              "a": {
                  "d": "M21.21 15.89A10 10 0 1 1 8 2.83"
              }
          }
      ]
  };

  function Icon(name) {
    var kids = (ICON_PATHS[name] || []).map(function (n, i) {
      return e(n.t, Object.assign({ key: i }, n.a));
    });
    return e('svg', {
      className: 'gg-sos__icon', viewBox: '0 0 24 24', fill: 'none',
      stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round',
      strokeLinejoin: 'round', 'aria-hidden': 'true'
    }, kids);
  }

  // PanelLeft / PanelLeftClose, the two lucide glyphs FreelancerRail imports for the
  // toggle. Drawn here rather than imported because this repo has no bundler.
  // PanelIcon removed 19 Aug 2026 with the collapse toggle it drew. Its only caller was
  // that button; a defined-but-uncalled icon helper is the next reader's false lead.

  // THE RAIL NO LONGER COLLAPSES, ON ANY DEVICE. FREELANCER 18 at 0:51, 18 Aug 2026,
  // recxV7Yek8GJ0oW9M. Alexander, verbatim: "I don't think this should be collapsible on
  // any devices because it just doesn't look good. It's not enterprise grade. So let's keep
  // this as it is because we're not also going to have collapsible icons. So just completely
  // remove that functionality." Message Thread 12 adds that it applies to all portals and
  // the pricing calculator, so the portal is making the same change on their rail.
  //
  // THE OLD RATIONALE IS DELETED RATHER THAN LEFT ABOVE THIS, because a comment explaining
  // why a control behaves a certain way, sitting above no control, is the exact rot that has
  // bitten this file twice: the stacked cache-bust blocks on 17 Aug, and the SET_REFER_MODE
  // pair in app.v2.jsx. For the record, it argued that collapse state was deliberately NOT
  // persisted here whilst the portal persists it under gg:customerSidebarCollapsed, because
  // the rail IS the conversion surface and remembering a collapse would hide it forever on
  // that browser. That reasoning is now moot: it cannot be collapsed at all.
  //
  // The `collapsed` state, the toggle button, the inert/aria-hidden effect and the
  // gg-sos-collapsed class are all gone. The CSS rules keyed on gg-sos-collapsed are left in
  // place deliberately: they are inert with no class to match, and removing them is a
  // separate change to a shared stylesheet that this one does not need.

  /* ── RAIL TOOLTIPS. Loom 20 at 09:16, 25 Aug 2026. ─────────────────────────
     Alexander, looking at the locked rail: "all of these, which you can't click
     on, there should be a tool, a bit like we've got tooltips here, there should
     be side tooltips, maybe with a pointy arrow, so you go on this, tooltip,
     tooltip, tooltip." Nicole scoped it to the rail items on the left.

     KEYED ON id, NOT ON label, AND THAT IS THE WHOLE REASON THIS IS A MAP.
     useLiveRailLabels rewrites every label at runtime from
     GET /api/v1/public/nav/partners, so a label is a value the portal owns and can
     change without telling us. The ids are ours. Keying on label would silently
     drop every tip the next time Bea renames a tab there.

     AN UNKNOWN id GETS NO TIP RATHER THAN A GUESS. If the portal adds a rail item
     we have no copy for, the item renders exactly as it does today. A missing tip
     is invisible; a wrong tip is a promise made to somebody we have not met, which
     is Nicole's forms rule applied to a public surface.

     COPY DESCRIBES WHAT THE TAB IS AND PROMISES NOTHING ELSE. Same rule. The only
     number is the flat 10%, which is already the visible figure on the referrals
     surface and is the ratified one. */
  var RAIL_TIPS = {
    home:      'Your dashboard, with live work and quick links to everything else.',
    grow:      'Where you are now. Price up services and see what you would earn.',
    opps:      'Roles and projects you can apply to take on.',
    projects:  'The clients you run through GoGorilla.com, and the work in progress on each.',
    refer:     'Refer a business and earn 10% recurring commission for the length of their minimum commitment.',
    earn:      'What you have earned and what is still due.',
    billing:   'Your subscriptions and invoices.',
    invest:    'Investors matched to your stage and sector.',
    founders:  'Founders matched to what you invest in.',
    portfolio: 'The companies you have backed, in one place.'
  };

  function Padlock() {
    return e('span', { className: 'gg-sos__lock', 'aria-hidden': 'true' },
      e('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round' },
        e('rect', { x: 5, y: 11, width: 14, height: 9, rx: 2 }),
        e('path', { d: 'M8 11V8a4 4 0 0 1 8 0v3' })));
  }

  // ── The rails. `kind` drives the blurred skeleton, never the copy. ──
  var RAILS = {
    // COPIED FROM THE LIVE PORTAL. Do not edit to taste.
    // TABS ADDED 7 AUG 2026, and they are a MIRROR exactly as the labels above are.
    // Source is the tabs[] of each entry in FREELANCER_NAV, lib/account/freelancer-nav.ts
    // in aonslow/gogorilla-platform, and every one was additionally confirmed page by page
    // against the running portal on 7 August. SAME RE-SYNC RULE AS THE RAIL LABELS: if a tab
    // is renamed there it is renamed here, never the other way round. Two names in that file
    // are load-bearing and have each already been corrected once, so do not tidy them.
    // Earnings is "Client work" and NEVER "Task earnings", and My Projects tab two is "Board"
    // and never "Projects". Grow carries none because Grow is the live item and never opens
    // a glimpse.
    /* 2026-08-19 (Nicole): GROW SECOND, PROJECTS NOT MY PROJECTS, NO PROFILE.
       VERIFIED AGAINST THE PORTAL'S OWN ENDPOINT rather than taken on trust.
       GET /api/v1/public/nav/partners returns, in order:
         home, grow, explore "Get Work", clients "Projects", referrals, earnings
       so all three of her changes are already true on their side, and this baked list
       was the thing out of step.

       WHY IT MATTERED EVEN THOUGH THE ENDPOINT DRIVES ORDER AND LABELS AT RUNTIME.
       An item whose portalId the portal does NOT list keeps its relative position
       rather than being dropped, which is deliberate and documented below. So
       'profile' survived every sync and kept rendering. THE CODE HAD ALREADY NOTICED:
       the console assertion has been logging "Ours with no portal match: [profile]"
       on every load, and nobody read it. The warning worked; the reading of it did not.

       "Get Work" NOT "Opportunities", ruled by Nicole the same evening after this was
       flagged. It matches what GET /api/v1/public/nav/partners already serves, so the
       fallback and the live label now agree instead of diverging the moment their nav
       fetch fails. The previous note here said this was deliberately left alone; it has
       been REPLACED rather than left above the new text, per the rule at the top. */
    agency: [
      { id: 'home', portalId: 'home',     label: 'Home',          icon: 'home',     kind: 'dash',
        tabs: ['Overview', 'Tasks'] },
      { id: 'grow', portalId: 'grow',     label: 'Grow',          icon: 'grow',     kind: 'live' },
      { id: 'opps', portalId: 'explore',     label: 'Get Work',      icon: 'explore',  kind: 'list',
        tabs: ['Account management', 'One-off projects', 'Talent network', 'My interests'] },
      { id: 'projects', portalId: 'clients', label: 'Projects',      icon: 'clients', kind: 'board',
        tabs: ['Active', 'Board', 'Assets', 'Meeting prep'] },
      { id: 'refer', portalId: 'referrals',    label: 'Referrals',     icon: 'referrals', kind: 'list',
        tabs: ['Refer a business', 'Refer a candidate'] },
      { id: 'earn', portalId: 'earnings',     label: 'Earnings',      icon: 'earnings',    kind: 'dash',
        tabs: ['Overview', 'Client work', 'Referrals', 'Payouts'] },
      /* 2026-08-20: BILLING ADDED because the portal started publishing it. Their nav
         version moved from 373e6c1fd4375480 to 24b62594755b9cce overnight and the item
         appeared. OUR OWN DRIFT DETECTOR CAUGHT IT AGAIN, this time from the other
         direction: "Portal items we do not render: [billing]". Yesterday it was an item
         of ours they had dropped; today it is one of theirs we were missing. The
         assertion has now found both halves of the same class of drift inside 24 hours,
         which is the argument for consuming their preview endpoint rather than
         maintaining a list by hand.
         NO TABS, deliberately. We have no traced Billing surface, so it falls through to
         the description-plus-sign-up panel exactly as Find Investors and Portfolio do on
         the founder and investor rails. Inventing a tab bar here would assert an
         information architecture we have not seen. */
      { id: 'billing', portalId: 'billing', label: 'Billing',       icon: 'earnings',    kind: 'dash' }
    ],
    // FOUNDER AND INVESTOR CARRY NO TABS, DELIBERATELY. Neither portal exists, so there is
    // no tabs[] to mirror and anything written here would be invented. Nicole, 7 August:
    // use the portal source rather than guessing. The rails themselves are designed rather
    // than mirrored, which is already recorded at the top of this file, but a rail label is
    // one word whilst a tab bar asserts an entire information architecture. When those
    // sidebars are built, mirror them the same way and delete this note.
    /* Same three changes applied here and to investor. These two rails carry NO
       portalId, so nothing syncs them and the baked order IS the order. Profile is
       gone for the same reason as above: it was merged into account settings on the
       portal, and account settings is not reachable from the standalone page at all,
       so the item pointed at nothing. */
    founder: [
      { id: 'home',     label: 'Home',           icon: 'home',     kind: 'dash' },
      { id: 'grow',     label: 'Grow',           icon: 'grow',     kind: 'live' },
      { id: 'projects', label: 'Projects',       icon: 'clients', kind: 'board' },
      { id: 'invest',   label: 'Find Investors', icon: 'search',   kind: 'list' },
      { id: 'refer',    label: 'Referrals',      icon: 'referrals', kind: 'list' },
      { id: 'billing',  label: 'Billing',        icon: 'earnings',    kind: 'dash' }
    ],
    investor: [
      { id: 'home',      label: 'Home',          icon: 'home',     kind: 'dash' },
      { id: 'grow',      label: 'Grow',          icon: 'grow',     kind: 'live' },
      { id: 'founders',  label: 'Find Founders', icon: 'search',   kind: 'list' },
      { id: 'portfolio', label: 'Portfolio',     icon: 'portfolio',      kind: 'board' },
      { id: 'refer',     label: 'Referrals',     icon: 'referrals', kind: 'list' },
      { id: 'billing',   label: 'Billing',       icon: 'earnings',    kind: 'dash' }
    ]
  };

  // One line per page, so the overlay says what is behind the blur rather than
  // repeating the page name back at the visitor.
  var BLURB = {
    home:      'Your dashboard, next steps, and what needs your attention.',
    opps:      'Roles we are recruiting for, with rates and requirements.',
    projects:  'Every project you are running, on one board.',
    invest:    'Investors matched to your stage, sector, and raise.',
    founders:  'Founders actively raising, matched to your thesis.',
    portfolio: 'Your portfolio companies and how they are tracking.',
    refer:     'Refer a business or a candidate, and track what you have sent us.',
    earn:      'What you have earned, what is pending, and when it pays out.',
    billing:   'Your plan, invoices, and payment method.',
    profile:   'Your details, and how you appear to everyone else.'
  };

  function Skeleton(kind) {
    var rows = [];
    if (kind === 'board') {
      for (var c = 0; c < 3; c++) {
        var cards = [];
        for (var i = 0; i < 3; i++) cards.push(e('div', { key: i, className: 'gg-sos__sk-card' }));
        rows.push(e('div', { key: c, className: 'gg-sos__sk-col' },
          e('div', { className: 'gg-sos__sk-colhead' }), cards));
      }
      return e('div', { className: 'gg-sos__sk gg-sos__sk--board' }, rows);
    }
    if (kind === 'list') {
      for (var r = 0; r < 5; r++) rows.push(e('div', { key: r, className: 'gg-sos__sk-row' },
        e('div', { className: 'gg-sos__sk-avatar' }),
        e('div', { className: 'gg-sos__sk-lines' },
          e('div', { className: 'gg-sos__sk-line' }),
          e('div', { className: 'gg-sos__sk-line gg-sos__sk-line--short' })),
        e('div', { className: 'gg-sos__sk-pill' })));
      return e('div', { className: 'gg-sos__sk' }, rows);
    }
    if (kind === 'form') {
      for (var f = 0; f < 5; f++) rows.push(e('div', { key: f, className: 'gg-sos__sk-field' },
        e('div', { className: 'gg-sos__sk-line gg-sos__sk-line--short' }),
        e('div', { className: 'gg-sos__sk-input' })));
      return e('div', { className: 'gg-sos__sk gg-sos__sk--form' }, rows);
    }
    var tiles = [];
    for (var t = 0; t < 4; t++) tiles.push(e('div', { key: t, className: 'gg-sos__sk-tile' }));
    return e('div', { className: 'gg-sos__sk' },
      e('div', { className: 'gg-sos__sk-tiles' }, tiles),
      e('div', { className: 'gg-sos__sk-wide' }));
  }

  /* ── PAGE REPLICAS ────────────────────────────────────────────────────────
     Structural replicas of the six real agency portal pages, drawn as divs and
     styled by assets/sos-previews.css.

     Traced from aonslow/gogorilla-platform, which serves /partners as a rewrite
     of /account. Copy, tab labels and figures are that repo's own committed
     fixtures (the Earnings months, the twelve role cards, the kanban) or its
     documented zero state (the referral stats). Nothing here is real user data.

     These replace the four generic archetypes above, for the agency rail only.
     Skeleton(kind) is still what the founder and investor rails get, which is
     correct while neither of those portals has been built.

     Why real text: the glimpse blurs whatever sits behind it. Grey bars blurred
     read as a loading state, because that is what they are. Real strings at
     real sizes give the ragged line lengths and type rhythm of a populated
     page. It is not meant to be legible, it is meant to be shaped like text.

     Spec format. A bare string is an empty div with that className.
     ['className', child, ...] is a div with children.
     T('className', 'text') is a div with text.
     R(n, node) repeats one node n times.
     ───────────────────────────────────────────────────────────────────────── */
  function T(cls, text) { return { c: cls, t: text }; }
  function R(n, node) { var a = []; for (var i = 0; i < n; i++) a.push(node); return a; }

  function sosNode(node, key) {
    if (typeof node === 'string') return e('div', { className: node, key: key });
    if (node && node.c !== undefined) return e('div', { className: node.c, key: key }, node.t);
    var kids = [];
    for (var i = 1; i < node.length; i++) {
      var c = node[i];
      // R() hands back an array of nodes. Splice those in rather than nesting
      // them, which is why the test is "an array whose head is itself an array
      // or a text node" - a normal child always has a string className at [0].
      if (Array.isArray(c) && (Array.isArray(c[0]) || (c[0] && c[0].c !== undefined))) {
        for (var j = 0; j < c.length; j++) kids.push(sosNode(c[j], i + '_' + j));
      } else {
        kids.push(sosNode(c, i));
      }
    }
    return e('div', { className: node[0], key: key }, kids);
  }

  var SOS_PAGES = {
    home: ['sp sp--home',['sp__hrow',T('sp__h1','Welcome back, Bea')],['sp__cols',['sp__main',
      ['sp__pane sp__stepper',['sp__step','sp__stepico',['sp__steptxt',T('sp__eyebrow sp__eyebrow--b','Step 1'),T('sp__steplbl','Applied')]],'sp__steprail sp__steprail--on',['sp__step','sp__stepico',['sp__steptxt',T('sp__eyebrow sp__eyebrow--b','Step 2'),T('sp__steplbl','Call booked')]],'sp__steprail',['sp__step','sp__stepico',['sp__steptxt',T('sp__eyebrow sp__eyebrow--b','Step 3'),T('sp__steplbl','Get set up')]]],
      ['sp__pane',['sp__srow',T('sp__h2','Your runway to the call'),T('sp__meta','1 of 5 done')],['sp__task','sp__node',['sp__tl',T('sp__tt','Join our Telegram channel'),T('sp__ts','Where new projects get posted first')],T('sp__link','Open in new tab →')],['sp__task','sp__node',['sp__tl',T('sp__tt','Explore the pricing calculator'),T('sp__ts','See what you can quote a client')],T('sp__link','Open in new tab →')],['sp__task','sp__node',['sp__tl',T('sp__tt','Explore GoGorilla.com'),T('sp__ts','What we do, in your own words later')],T('sp__link','Open in new tab →')],['sp__task sp__task--opt','sp__node',['sp__tl',T('sp__eyebrow sp__eyebrow--am','Optional'),T('sp__tt sp__tt--lg','Complete the assessment centre'),T('sp__ts','Six short exercises. It is the single biggest thing you can do to strengthen your application before your call.')],T('sp__btn sp__btn--orange','Start now')],['sp__task sp__task--call','sp__node',['sp__tl',T('sp__eyebrow sp__eyebrow--am','Assessment Call'),T('sp__tt sp__tt--lg','Your assessment call'),T('sp__ts','We will confirm by email as soon as it is scheduled.')]],['sp__srow sp__srow--top',['sp__tl',T('sp__tt','1 completed'),T('sp__ts','Last: Bookmark the platform')],T('sp__link','Show')]]],
      /* 2026-08-19 (Nicole): DUMMY FIGURES ARE ALLOWED ON THE BLURRED SURFACES, ruled
         tonight, and this was the only blurred panel still reading as empty. Everything
         else already carries them: opps has twelve role cards with day rates, projects has
         a full board with named dummy clients, earn has four months of figures.
         MIRRORED FROM THE PORTAL'S OWN HOME CARD rather than invented, which is the whole
         point of this file. Read off the live signed-in portal tonight: 80% complete,
         Portfolio links "6 added", Specialisms "3 selected", Verification "Not open yet",
         and the EXAMPLE caption is theirs too. The old caption admitted we hold no figure,
         which is honest for a real account and wrong for a preview.
         THE EARNINGS PANE BELOW STAYS AT £0 ON PURPOSE. The portal shows £0 there too, so
         £0 IS the faithful value and changing it would be drifting away from the thing we
         mirror rather than towards it. Emptiness and fidelity are not the same test.
         REFERRALS KEEPS ITS ZEROS, ruled separately and for a different reason: that
         surface is NOT blurred, so the premise behind this ruling does not reach it. */
        ['sp__side',['sp__pane',T('sp__eyebrow','Profile strength'),T('sp__eyebrow sp__eyebrow--r','Example'),['sp__figrow',T('sp__fig','80%'),T('sp__figsuf','complete')],['sp__segs','sp__seg sp__seg--on','sp__seg sp__seg--on','sp__seg sp__seg--on','sp__seg sp__seg--on','sp__seg'],['sp__srow',T('sp__ts','Portfolio links'),T('sp__ts sp__ts--r','6 added')],['sp__srow',T('sp__ts','Specialisms'),T('sp__ts sp__ts--r','3 selected')],['sp__srow',T('sp__ts','Verification'),T('sp__ts sp__ts--r','Not open yet')],T('sp__fine','An example of how this reads. Your own figure appears once Profile is wired.'),T('sp__link','Finish profile →')],
      ['sp__pane',T('sp__eyebrow','Earnings'),['sp__figrow',T('sp__fig','£0'),T('sp__figsuf','to date')],T('sp__ts','Earnings start once you are approved and matched to a project.'),T('sp__link','Go to Earnings →')]]]],
    opps: ['sp sp--opps',['sp__hrow',T('sp__h1','Explore opportunities'),T('sp__btn sp__btn--primary',
      'Refer & earn')],['sp__tabs',T('sp__tab sp__tab--on','Account management'),T('sp__tab',
      'One-off projects'),T('sp__tab','Talent network'),T('sp__tab','My interests')],['sp__pane',
      T('sp__ts','Account management roles, UK based, remote. More specialisms follow.'),
      ['sp__ctl',T('sp__search','Type to search'),T('sp__btn sp__btn--ghost','⇅  Recommended'),
      T('sp__btn sp__btn--ghost','☷  Filter')],['sp__wrap',['sp__pane sp__pane--in sp__role',T('sp__rt','SEO Account Manager'),T('sp__rpay','£300–450 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Marketing · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','A'),T('sp__av','M'),T('sp__av','R')],T('sp__rmeta','18 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Sales and Demand Generation Account Manager'),T('sp__rpay','£350–500 a day'),T('sp__rshape','Sessions, paid per meeting held'),T('sp__rmeta','Sales · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','J'),T('sp__av','P'),T('sp__av','K')],T('sp__rmeta','24 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Content and Copy Account Manager'),T('sp__rpay','£250–400 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Creative · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','S'),T('sp__av','D'),T('sp__av','L')],T('sp__rmeta','12 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Paid Ads Account Manager'),T('sp__rpay','£300–500 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Marketing · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','T'),T('sp__av','N'),T('sp__av','B')],T('sp__rmeta','31 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Email Marketing Account Manager'),T('sp__rpay','£280–420 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Marketing · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','J'),T('sp__av','K')],T('sp__rmeta','9 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Social Media Account Manager'),T('sp__rpay','£260–400 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Creative · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','P'),T('sp__av','L'),T('sp__av','S')],T('sp__rmeta','14 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','CRM Account Manager'),T('sp__rpay','£320–480 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Sales · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','D'),T('sp__av','N')],T('sp__rmeta','6 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Outbound Strategy Sessions'),T('sp__rpay','£150–250 a session'),T('sp__rshape','Sessions, paid per meeting held'),T('sp__rmeta','Sales · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','T'),T('sp__av','B'),T('sp__av','H')],T('sp__rmeta','21 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Brand Positioning Sessions'),T('sp__rpay','£180–300 a session'),T('sp__rshape','Sessions, paid per meeting held'),T('sp__rmeta','Creative · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','A'),T('sp__av','C')],T('sp__rmeta','7 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Analytics Account Manager'),T('sp__rpay','£340–500 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Marketing · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','M'),T('sp__av','R'),T('sp__av','J')],T('sp__rmeta','11 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Content Audit Sessions'),T('sp__rpay','£140–240 a session'),T('sp__rshape','Sessions, paid per meeting held'),T('sp__rmeta','Creative · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','S'),T('sp__av','W')],T('sp__rmeta','5 hired this month')],T('sp__link','View role ↗')]],
      ['sp__pane sp__pane--in sp__role',T('sp__rt','Partnerships Account Manager'),T('sp__rpay','£300–460 a day'),T('sp__rshape','Ongoing, paid per managed client, per month'),T('sp__rmeta','Sales · UK · Remote'),T('sp__rmeta','Independent contractor, part-time'),['sp__cfoot',['sp__favs',['sp__avs',T('sp__av','K'),T('sp__av','P'),T('sp__av','D')],T('sp__rmeta','16 hired this month')],T('sp__link','View role ↗')]]]]],
    projects: ['sp sp--projects',['sp__hrow',T('sp__h1','Projects')],['sp__tabs',T('sp__tab',
      'Active'),T('sp__tab sp__tab--on','Board'),T('sp__tab','Assets'),T('sp__tab','Meeting prep')],
      ['sp__pane',T('sp__ts','Your account manager moves cards between columns.'),['sp__board',
      ['sp__kcol',['sp__khead',T('sp__kdot sp__kdot--b',''),T('sp__kname','Brief'),T('sp__kcount','2')],T('sp__kdate','31 JUL 2026'),['sp__kcard',T('sp__kt','August paid ads plan'),T('sp__ktag sp__ktag--pink','Aurora Cosmetics'),['sp__kfoot',T('sp__kdue','□ Aug 12')]],['sp__kcard',T('sp__kt','Keyword refresh'),T('sp__ktag sp__ktag--blue','Northwind Interiors'),['sp__kfoot',T('sp__kdue','□ Aug 11')]]],
      ['sp__kcol',['sp__khead',T('sp__kdot sp__kdot--a',''),T('sp__kname','In progress'),T('sp__kcount','3')],T('sp__kdate','1 AUG 2026'),['sp__kcard',T('sp__kt','Landing page copy'),T('sp__ktag sp__ktag--green','Kestrel Coffee'),['sp__kfoot',T('sp__kdue','□ Aug 9')]],['sp__kcard',T('sp__kt','Creative refresh, set 3'),T('sp__ktag sp__ktag--pink','Aurora Cosmetics'),['sp__kfoot',T('sp__kdue','□ Aug 9'),T('sp__kchk','2/4')]],['sp__kcard',T('sp__kt','Technical audit fixes'),T('sp__ktag sp__ktag--blue','Northwind Interiors'),['sp__kfoot',T('sp__kdue','□ Aug 14')]]],
      ['sp__kcol',['sp__khead',T('sp__kdot sp__kdot--v',''),T('sp__kname','Review'),T('sp__kcount','1')],T('sp__kdate','2 AUG 2026'),['sp__kcard',T('sp__kt','Monthly report, July'),T('sp__ktag sp__ktag--green','Kestrel Coffee'),['sp__kfoot',T('sp__kdue','□ Aug 1')]]],
      ['sp__kcol',['sp__khead',T('sp__kdot sp__kdot--g',''),T('sp__kname','Done'),T('sp__kcount','2')],T('sp__kdate','29 JUL 2026'),['sp__kcard',T('sp__kt','Onboarding call notes'),T('sp__ktag sp__ktag--pink','Aurora Cosmetics'),['sp__kfoot',T('sp__kdue','□ Jul 29')]],T('sp__kdate','23 JUL 2026'),['sp__kcard',T('sp__kt','Analytics access'),T('sp__ktag sp__ktag--blue','Northwind Interiors'),['sp__kfoot',T('sp__kdue','□ Jul 23')]]]]]],
    earn: ['sp sp--earn',['sp__hrow',T('sp__h1','Earnings')],['sp__tabs',T('sp__tab sp__tab--on',
      'Overview'),T('sp__tab','Client work'),T('sp__tab','Referrals'),T('sp__tab','Payouts')],
      ['sp__pane',T('sp__ts sp__ts--mut','July 2026'),['sp__cols2',['sp__pane sp__pane--in sp__grow2',T('sp__cardt','Client work'),T('sp__fig','£1,245'),T('sp__ts','4 managed clients and 1 meeting book'),T('sp__link','View lines')],
      ['sp__pane sp__pane--in sp__grow2',T('sp__cardt','Referrals'),T('sp__fig','£186'),T('sp__ts','10% recurring · 3 referrals tracked'),T('sp__btn sp__btn--primary sp__btn--sm','Claim £186')]],
      ['sp__pane sp__pane--in',T('sp__tt','Self-billed invoices are generated for you. Nothing to chase.'),
      T('sp__ts','We raise the invoice in your name each month and file it against the payout.'),
      ['sp__srow sp__srow--top',T('sp__ts sp__ts--mut','Next payout run · monthly · nothing to submit'),T('sp__link','Payouts')]],
      ['sp__pane sp__pane--in',T('sp__tt','Earlier months'),['sp__srow sp__srow--top',['sp__tl',T('sp__tt2','June 2026'),T('sp__ts','4 managed clients · 1 meeting book')],['sp__amt',T('sp__badge','Paid'),T('sp__tt2','£1,180')]],
      ['sp__srow sp__srow--top',['sp__tl',T('sp__tt2','May 2026'),T('sp__ts','3 managed clients · 1 meeting book')],['sp__amt',T('sp__badge','Paid'),T('sp__tt2','£920')]],
      ['sp__srow sp__srow--top',['sp__tl',T('sp__tt2','April 2026'),T('sp__ts','3 managed clients')],['sp__amt',T('sp__badge','Paid'),T('sp__tt2','£865')]]]]],
    profile: ['sp sp--profile',['sp__hrow',T('sp__h1','Profile')],['sp__tabs',T('sp__tab sp__tab--on',
      'About & specialisms'),T('sp__tab','Portfolio'),T('sp__tab','Rates & availability')],
      ['sp__pane',['sp__pane sp__pane--in',T('sp__tt','Specialisms'),T('sp__ts','What you want to be matched on. Pulled from your application.'),
      ['sp__chips',T('sp__chip','Paid advertising'),T('sp__chip','SEO'),T('sp__chip','Email marketing')]],
      ['sp__pane sp__pane--in',T('sp__tt','About'),T('sp__ts','Capacity is set on the Rates tab.'),
      ['sp__wrap sp__wrap--2',['sp__field',T('sp__flabel','Niche'),T('sp__fval','DTC brands')],['sp__field',T('sp__flabel','Location'),T('sp__fval','United Kingdom')],['sp__field',T('sp__flabel','Availability'),T('sp__fval','Open to new work')],['sp__field',T('sp__flabel','Notice period'),T('sp__fval','Two weeks')],['sp__field',T('sp__flabel','Working hours'),T('sp__fval','UK hours, GMT/BST')],['sp__field',T('sp__flabel','Remote or office'),T('sp__fval','Remote, occasional London office')]]]]]
  };
  /* Agency rail only. Any other client type, or any id without a spec, falls
     straight through to the generic archetypes, so a founder or investor can
     never be shown a freelancer layout by accident.

     aria-hidden because this is decorative scenery behind a lock card; the
     glimpse itself is the labelled dialog, and none of this copy should reach
     a screen reader. .sp-root is display:contents so the wrapper adds no box. */
  /* ── THE REFERRALS SURFACE IS REAL, NOT A GLIMPSE ─────────────────────────
     FREELANCER 18 at 1:47, Alexander, verbatim: "the referrals tab to be
     functional without a login, at least partially... when you click refer a
     business, you should be able to submit the referral". At 2:31: "it actually
     needs to be functional so that it adds it here... this should be viewable".

     SO THIS ONE SURFACE OPTS OUT OF THE BLUR-AND-PADLOCK MODEL. Every other
     surface stays a glimpse, which is still right: they are pages we cannot
     honour without a session. Referrals we CAN honour, because the portal's own
     zero state is the whole page with zeros in it, and a signed-out visitor is
     genuinely at zero. Nothing is hidden, so there is nothing to obscure.

     TRACED FROM THE PORTAL SOURCE, NOT DRAWN BY EYE. Every string below is
     copied from aonslow/gogorilla-platform main:
       app/account/referrals/page.tsx          the hero and sub
       lib/referrals/persona-copy.ts           the affiliate variants
       _components/ValueRail.tsx + lib/referrals/value-rail.ts   the four cells
       _components/ShareCodePanel.tsx          both branches
       _components/EarningsCard.tsx            the zero render
       _components/SubmitReferralForm.tsx      every field label
       _components/ReferralsTable.tsx          the columns and empty state
     Their grid is lg:3 (rail x2 + share) then lg:2 (earnings + submit) then a
     full-width panel, and that is reproduced rather than reinterpreted.

     WE SHOW THE AFFILIATE TRACK. isAffiliate on their side flips the hero, the
     sub, the empty-state line, and REMOVES the "Credit earned" column, so this
     is four columns not five. A visitor referring businesses is the affiliate
     case by definition.

     THE SHARE PANEL USES THEIR NO-CODE BRANCH ON PURPOSE. A signed-out visitor
     has no referral code, because minting one is what signing up does. Their
     own no-data branch says exactly that, so it is both faithful and honest,
     and it means this surface invents no code. That also retires the BEA10
     hard-code from here. (The Home surface still carries BEA10 at its own line
     and is Nicole's open decision, untouched by this.)
     ───────────────────────────────────────────────────────────────────────── */
  /* RL_STAGES WAS DELETED HERE, 25 Aug 2026, with the pipeline rail it fed. Its three
     stage figures derived from `rows`, which was permanently empty on this surface, so
     they were structurally zero rather than zero today. A defined and uncalled helper
     is, in this file's own words about PanelIcon, the next reader's false lead. */

  /* ── PORTAL-SERVED PREVIEWS, BEHIND ?preview=live. Added 20 Aug 2026 ──────────
     A SIDE-BY-SIDE SO NICOLE CAN LOOK BEFORE CHOOSING, not a migration. Default is
     unchanged: the traced surfaces still render for everybody. Add ?preview=live to
     the URL to see the portal's own components instead.

     WHAT THE CHOICE IS. Their previews are the REAL portal components called with no
     data, so the structure is exactly right and it updates when they deploy. But they
     contain NO TEXT AT ALL - home renders as eight grey bars and zero characters. Our
     traced versions carry twelve role cards, a kanban board with named clients and four
     months of earnings figures. Behind a blur the text is illegible either way, which is
     the argument for theirs; "do not let it look empty" was a specific instruction on
     19 August, which is the argument for ours.

     THE CSP TRAP, AND IT IS THE REASON THIS IS NOT A <link>. The endpoint returns `css`
     as a URL. Linking it is BLOCKED by our own Content-Security-Policy, whose style-src
     does not include portal.gogorilla.com, and the console error names style-src-elem
     falling back to style-src. Fetching the same URL as TEXT is governed by connect-src
     and CORS instead, both of which already allow it, and injecting the result into a
     <style> is covered by the 'unsafe-inline' we already carry. Proven before building:
     53,080 characters fetched and .gg-blank then computed to rgba(148,163,184,0.4).
     ONE SHEET SERVES EVERY SURFACE, so it is fetched once and cached on window.

     ONLY FIVE SURFACES ARE BACKED. referrals and opportunities are in the portal's
     UNBACKED_SURFACES list and 404 by design, with the error body naming its own reason.
     Anything not in the map below keeps the traced version, so this cannot blank a
     surface. */
  /* EVERY BACKED SURFACE, NOT FOUR. Probed 24 Aug: home, projects, earnings, billing, profile AND
     referrals all answer 200; only `opportunities` is still 404. Referrals was the 404 on 19 August
     that made this rail hand-build it in the first place -- that reason has expired.
     `grow` is deliberately absent: it mounts the real calculator, not a picture of one. */
  var PORTAL_PREVIEW_SURFACE = {
    home: 'home', projects: 'projects', earn: 'earnings', billing: 'billing'
    /* REFERRALS IS DELIBERATELY NOT HERE. REVERTED 24 Aug on Nicole's correction, same day I added it.
       Their preview is a BLURRED PICTURE with no form behind it. Referrals is the one surface that is
       supposed to be FUNCTIONAL for a signed-out visitor -- her 19 August ruling, and the reason the
       padlock came off it in two places. Swapping it for a picture made the surface accurate and
       useless at the same time, which is the wrong trade on the one surface where it matters.
       If the portal ever publishes something MOUNTABLE rather than a rendered snapshot, revisit. */
  };
  /* ── THE DEFAULT FLIPPED ON 25 AUGUST. Nicole's ruling. ──────────────────────
     From 20 to 24 August the portal's own panels rendered only with ?preview=live and
     every real visitor got our traced skeletons. Home, earnings and billing now default
     to THEIRS. PROJECTS IS DELIBERATELY HELD ON OURS, and it is the one surface where
     the direction reverses: their projects payload is a 66-character empty shell whilst
     ours is a full traced board with named clients, so flipping it would trade a real
     board for an empty one. Billing is the opposite and the strongest case for flipping,
     because our traced fallback there is only grey tiles.

     THREE STATES, NOT TWO, AND THE THIRD IS THE POINT. Default serves the ruling above.
     ?preview=live additionally serves projects, so the held surface can still be looked
     at without a deploy. ?preview=traced forces everything back to ours, which is how a
     fault gets diagnosed, or a regression confirmed, without shipping anything. A flag
     that can only be turned on is only half an instrument.

     ANYTHING NOT IN PORTAL_PREVIEW_SURFACE IS UNAFFECTED IN EVERY STATE, so this cannot
     blank a surface, and referrals stays ours and functional for the reasons on that map. */
  var PORTAL_PREVIEW_DEFAULT_ON = { home: true, earn: true, billing: true };

  function livePreviewRequested() {
    try { return new URLSearchParams(window.location.search || '').get('preview') === 'live'; }
    catch (e) { return false; }
  }
  function tracedPreviewRequested() {
    try { return new URLSearchParams(window.location.search || '').get('preview') === 'traced'; }
    catch (e) { return false; }
  }
  /* THE SINGLE PREDICATE. It returns the portal surface name to serve, or null for ours.
     It exists because the old test was written inline at three call sites and they would
     have drifted apart the moment one was edited -- the same reasoning as _referStaysLive
     directly below, and the same mistake this file has already made once. */
  function portalPreviewFor(id) {
    var surface = PORTAL_PREVIEW_SURFACE[id];
    if (!surface) return null;
    if (tracedPreviewRequested()) return null;
    if (PORTAL_PREVIEW_DEFAULT_ON[id]) return surface;
    return livePreviewRequested() ? surface : null;
  }
  /* FLATTEN CASCADE LAYERS OUT OF THE PORTAL STYLESHEET BEFORE IT COMPETES.
     Added 20 Aug after the portal lane diagnosed this and we confirmed it by experiment.

     THE CASCADE RULE THAT CAUSES IT: an UNLAYERED declaration beats a LAYERED one no
     matter the specificity. Tailwind v4 emits its whole build inside
     @layer properties, theme, base, utilities. Our own stylesheets are hand-written and
     unlayered, so every reset we own outranks their ENTIRE sheet. Their margins were
     never missing - they were defined and losing.

     WHY WE GOT THIS WRONG THE FIRST TIME, KEPT AS THE WARNING: we scanned the injected
     sheet for utility rules by walking styleSheet.cssRules and reading selectorText.
     That only ever sees the TOP level, and everything inside @layer{} is one level down,
     so the scan reported 63 classes as undefined when all 63 were present. 228 top-level
     rules against 543 once you recurse. AN INSTRUMENT THAT DOES NOT RECURSE WILL TELL
     YOU A THING IS ABSENT WHEN IT IS MERELY NESTED.

     Flattening is done with the browser own CSSOM rather than a regex, because @layer
     blocks nest and brace-matching by hand is how you corrupt a stylesheet. Media rules
     are pushed whole, so their conditions survive; only the layer wrappers are dropped.

     THIS IS A NO-OP ONCE THE PORTAL SHIPS THEIR OWN FLATTEN. It only rewrites when a
     layer is actually present, and any failure leaves the original sheet untouched. */
  function ggFlattenLayers(styleEl) {
    try {
      var sheet = styleEl.sheet;
      if (!sheet || !sheet.cssRules) return false;
      var out = [];
      var hadLayer = false;
      (function walk(rules) {
        for (var i = 0; i < rules.length; i++) {
          var r = rules[i];
          var n = r.constructor && r.constructor.name;
          if (n === 'CSSLayerBlockRule') { hadLayer = true; walk(r.cssRules); continue; }
          if (n === 'CSSLayerStatementRule') { hadLayer = true; continue; }
          out.push(r.cssText);
        }
      })(sheet.cssRules);
      if (!hadLayer) return false;
      styleEl.textContent = out.join('\n');
      return true;
    } catch (e) { return false; }
  }

  /* ggFillBlanks WAS HERE AND IS GONE. 24 Aug 2026.

     From 20 August it replaced every <span class="gg-blank" style="width:95ch"> in the
     portal's markup with filler words of ours sized to the same character count, because a
     page of uniform grey bars read as "still loading" rather than as a real product.

     THE REASON IT SURVIVED THE SAMPLE VARIANT WAS EARNINGS, AND EARNINGS IS FIXED. When
     ?data=sample landed this morning it left zero bars on five surfaces and FIFTY-THREE on
     earnings, so this stayed as a one-surface tool. Lane B then found the cause: their
     registry rendered <EarningsOverview /> with no props, which that component's own
     contract makes the LOCKED variant. Their PR #1344 passes the props and the surface now
     publishes 1,415 characters of its own figures.

     MEASURED ON PRODUCTION BEFORE DELETING, ALL SIX AT PAYLOAD aedc2c6228f52204: home 1654,
     earnings 1415, billing 834, referrals 834, profile 476, projects 66 -- and gg-blank
     count ZERO on every one. There is nothing left for it to fill.

     AND A NO-OP WOULD NOT HAVE BEEN HARMLESS. Kept "just in case", it would put OUR words
     back inside THEIR markup the moment a payload regressed, silently, which is the exact
     habit this whole thread has been about ending. Bars are the honest failure: they say
     the data did not arrive. Filler says the wrong thing convincingly.

     projects at 66 is NOT a case for keeping it. Lane B seeded ClientOverviewTab with three
     invented clients and the payload changed by zero bytes: the component waits for all
     three tiers of its fan-out before it draws, by design, so what we receive is its
     skeleton. Filling a skeleton with words does not make it a board. */

  function PortalPreview(props) {
    var surface = props.surface;
    var htmlState = React.useState(null); var html = htmlState[0], setHtml = htmlState[1];
    var failState = React.useState(false); var failed = failState[0], setFailed = failState[1];
    React.useEffect(function () {
      var alive = true;
      var BASE = 'https://portal.gogorilla.com/api/v1/public/preview/';
      /* ?data=sample, live since 24 Aug (their PR #1336, payload 233be9bffafcdafb). The default
         stays blank and redacted; this variant is the explicit opt-in Nicole approved, and the
         response echoes which one you got in `data`. Anything other than exactly `sample` falls
         through to blank rather than erroring, which is the right way round.
         MEASURED PER SURFACE, AND RE-MEASURED AFTER THEIR #1344 rather than trusting the first
         reading: at payload aedc2c6228f52204 the sample variant leaves ZERO gg-blank bars on all
         six. Earnings was the one exception at fifty-three, and the cause was their registry
         rendering EarningsOverview with no props, which is that component's LOCKED variant. It now
         publishes 1,415 characters of its own figures. Nothing of ours is injected into their
         markup any more; see the note above ggFillBlanks for why a harmless-looking no-op was
         deleted rather than kept. */
      fetch(BASE + surface + '?data=sample', { cache: 'no-store' })
        .then(function (r) { if (!r.ok) throw new Error('unbacked or unavailable: ' + r.status); return r.json(); })
        .then(function (j) {
          var d = (j && j.data) || {};
          if (!d.html) throw new Error('no html in payload');
          /* The stylesheet is the same file for every surface, so fetch it once. */
          if (!window.__ggPreviewCssPromise && d.css) {
            window.__ggPreviewCssPromise = fetch(d.css, { cache: 'no-store' })
              .then(function (r) { return r.ok ? r.text() : ''; })
              .then(function (txt) {
                if (!txt || document.getElementById('gg-portal-preview-css')) return;
                var st = document.createElement('style');
                st.id = 'gg-portal-preview-css';
                /* NEUTRALISE THEIR PAGE GROUND. Their sheet sets `.gg-preview { background:
                   var(--background) }` and their token block resolves --background to
                   var(--color-white). That is right on their own page and wrong inside ours: it
                   paints a white slab over the calculator's concrete ground, which is what Nicole
                   saw behind "Welcome back". THE HOST OWNS PAGE CHROME -- our own embed manifest
                   says the same thing in reverse about the six unscoped rules a host must
                   neutralise when it mounts US. Their PANEL tokens are untouched, including
                   --gg-card-concrete and the glass and metal frames, whose assets resolve on our
                   origin too (checked: identical bytes on both hosts). */
                st.textContent = txt + '\n.gg-portal-preview .gg-preview{background:transparent !important;}';
                document.head.appendChild(st);
                /* Must run AFTER append: the element has no .sheet until it is in the
                   document, so there is nothing to read the rules from before this. */
                ggFlattenLayers(st);
              })
              .catch(function () { /* styling absent is survivable, the markup still shapes */ });
          }
          var done = function () { if (alive) setHtml(d.html); };
          if (window.__ggPreviewCssPromise) window.__ggPreviewCssPromise.then(done, done); else done();
        })
        .catch(function () { if (alive) setFailed(true); });
      return function () { alive = false; };
    }, [surface]);
    /* THIS MUST SIT ABOVE THE EARLY RETURNS. Placing it after them makes the hook COUNT
       depend on fetch state, which is React error #310 - the same trap that bit
       ReferralsLive. It was two hooks until the ggFillBlanks effect went; useRef is still
       a hook, so the rule is unchanged and not merely inherited. Checked, not assumed. */
    var hostRef = React.useRef(null);
    /* FALL BACK TO THE TRACED SURFACE RATHER THAN TO NOTHING. A failed fetch, an
       unbacked id or a portal deploy in flight must never leave an empty panel. */
    if (failed) return props.fallback;
    if (!html) return e('div', { className: 'gg-sos__sk' });
    return e('div', {
      className: 'gg-portal-preview',
      ref: hostRef,
      /* MATCHES WHAT THE TRACED SURFACE ALWAYS DID. PageSkeleton sets aria-hidden on
         .sp-root; this branch did not, so under ?preview=live the decorative markup was
         exposed to assistive tech while the traced version was not. It is behind a blur
         with a lock card carrying the real actions, so it is decoration either way. */
      'aria-hidden': 'true',
      /* Their markup, their classes, injected wholesale. It is first-party, generated
         from components called with NO DATA, and the endpoint takes no identifier and
         opens no session, so there is no reader whose data could be in it. */
      dangerouslySetInnerHTML: { __html: html }
    });
  }

  /* ── THE REFERRALS SURFACE IS NOW THE PORTAL'S ARRANGEMENT, NOT A SKIN ────────
     Rewritten 24 Aug 2026. Alexander: "it has to look identical to the portal."

     WHAT CHANGED AND WHY THE ?refskin=live GATE IS GONE. That flag existed so a
     portal-shaped version could be looked at beside the hand-drawn one before
     choosing. The choice has been made, so there is one surface again rather than
     two behind a query parameter, and the `.gg-refskin` grid overrides that flag
     carried have gone with it. They were `display:contents` + `order:` hacks that
     re-sorted a DOM shaped for a different layout; the DOM below is shaped like
     the portal's, so there is nothing left to re-sort.

     TRACED FROM THE PORTAL SOURCE, ARRANGEMENT INCLUDED. Every string, every
     order, every wrapper below is read off aonslow/gogorilla-platform main:

       app/account/_components/SectionShellView.tsx   h1 + tabs + .gg-section-base
       components/tabs/GGTabs.tsx + tabs.css          the tab strip
       app/account/referrals/page.tsx                 hero + sub, and the slots
       _components/ReferralsExplorer.tsx:394          THE ARRANGEMENT (see below)
       _components/ShareCodePanel.tsx                 both branches
       _components/SubmitReferralForm.tsx             heading, sub, button, fields
       _components/EarningsCard.tsx                   the zero render
       _components/ValueRail.tsx + lib/referrals/value-rail.ts   the four cells
       _components/ReferralsTable.tsx                 columns, header, empty state
       _components/ReferralFaq.tsx                    the five entries

     THE ARRANGEMENT, WHICH IS THE PART THAT WAS WRONG BEFORE. ReferralsExplorer
     renders, in order:
       1. a three-across row of EQUAL panes — link, submit, earnings — and their
          own comment says the order is deliberate: "a tool, an action, and the
          summary of what the two of them have produced". We had the rail and the
          code pane on row one and earnings + submit on row two, which is the
          arrangement they retired on 22 Aug.
       2. ONE panel holding everything else: the "Your referrals" heading, the
          four-cell pipeline rail, the filter row, the table, and the count. Ours
          had the rail floating outside the panel, which is the thing their 19 Aug
          change fixed on their side.
       3. the FAQ, full width, underneath.

     STILL ZEROS, AND STILL NO CODE. Nicole's standing ruling, unchanged by this:
     a signed-out visitor genuinely is at zero, and minting a referral code is
     what signing up does, so the share panel keeps the portal's own no-code
     branch. This change is presentation only — the submit hand-off, the filter
     state and the empty table all behave exactly as they did.
     ───────────────────────────────────────────────────────────────────────────── */

  /* Their five FAQ entries, verbatim from
     app/account/referrals/_components/ReferralFaq.tsx. Copy carries an FCA sign-off, so it is
     transcribed rather than paraphrased. */
  var RL_FAQ = [
    ['How much do I earn?', 'You earn 10% recurring commission on what each business you refer pays us, for the length of their minimum commitment period. The business you refer gets 10% off for that period too.'],
    ['How do I refer someone?', 'Share your referral link. When a business signs up through it, the 10% discount applies automatically and the referral is credited to you.'],
    /* ⚠️ ADDED 28 AUGUST 2026, and it is the question the one-page rebuild created. With two
       tabs, "does the same link do both" answered itself by the tabs existing. On one page
       with a mode switch, a reader can reasonably wonder whether the switch changes the link.
       It does not, and this is the only place that says so out loud.
       BOTH HALVES ARE TRUE AND THEY ARE DIFFERENT MECHANISMS. A business is attributed by the
       code carried in the link. A person is attributed by referrer_account_id, which is why
       sending a name needs a session and happens in the portal. No figure appears, because
       none may be published against referring a person. */
    ['Can I use the same link for a business and a person?', 'Yes. One account, one link, and one code cover both. A business that signs up through your link is credited to you automatically. Someone you recommend for client work is matched to your account instead, which is why sending us their name happens in your portal.'],
    ['When and how am I paid?', 'Your commission builds up as a claimable balance. Once it reaches £100 you can request a payout, and we send it by bank transfer within 2 business days of approving it (business days exclude weekends and UK bank holidays).'],
    ['What if a referred business cancels or is refunded?', 'If one of their payments is refunded, the matching commission is reversed. If they cancel, you simply stop earning new commission from them.'],
    ['Do I earn again if they come back later?', 'Commission applies to the initial commitment period, so if a business reactivates later it does not restart your commission.'],
    /* ⚠️ THE SIXTH QUESTION, ADDED 28 AUGUST 2026 WITH THE ONE-PAGE REBUILD, and every
       word of it is sourced rather than invented.
       THE ROLES ARE THE REAL ONES. data.jsx lists exactly eight for dedicated resources:
       AI Implementation and Training Specialist, Full-Stack Marketer, Graphic Designer,
       Video Editor, Sales Development Representative, Lead Generation Expert, Account
       Manager, Executive Assistant. Four are named here and the sentence says "and
       similar" rather than pretending the list is closed.
       "DISCIPLINE" IS THEIR WORD, NOT OURS. The portal's candidate_referrals table is
       (id, referrer_account_id, full_name, email, discipline), so a discipline is the
       thing actually captured when a name is sent.
       ⚠️ AND IT CARRIES NO FIGURE, WHICH IS NOT A STYLE CHOICE. No rate may be published
       against referring a person until the cap exists and FCA sign-off clears. The other
       five answers all mention money because all five are about businesses. This one
       cannot, and the guard in both-referral-tabs-share-one-panel-sequence.test.js now
       checks that it does not. */
    ['What kind of people can I refer?', 'People who do the work we place. That is mostly full-stack marketers, sales development representatives, graphic designers, video editors, and similar client-facing specialists. Send us their name, their email, and what they do. We take it from there.']
  ];
  /* RL_CAND_STEPS WENT WITH THE OLD CANDIDATE LAYOUT, 27 August 2026. It fed a tinted
     three-step pane beside a blurred name form. Nicole replaced that whole shape with the
     same two panels the business tab uses, so the list had no renderer left.
     DELETED RATHER THAN LEFT DEFINED. A list that is correct, unused, and named after a
     surface that no longer exists is how the surface comes back: the next person finds it,
     assumes it is wanted, and rebuilds the pane around it. The copy it held is not lost, it
     is in the portal's own _components/candidate-copy.ts, which is where it came from.
     THE ONE THING WORTH CARRYING FORWARD: it published no rate, deliberately, and neither
     does the tab that replaced it. */

  /* The three lucide glyphs this surface uses, inlined as SVG because the calculator
     carries no icon library. Sizes and stroke widths match lucide-react's defaults so
     they sit at the same weight as the portal's. */
    /* RlSearchIcon WENT WITH THE FILTER ROW, 25 Aug 2026. Its only caller was the search
     box on a table that could never fill. */
  function RlChevron(props) {
    return e('svg', {
      className: props.className, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
      strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
      'aria-hidden': 'true', focusable: 'false'
    }, e('path', { d: 'm6 9 6 6 6-6' }));
  }
  /* ── THE FAQ IS THE HOUSE ACCORDION NOW, NOT A SECOND ONE. 26 Aug 2026, ask 5. ──
     Nicole noticed this block renders differently on the portal and here, and the portal
     lane confirmed theirs wins and named the file. Reading it settled the question in the
     opposite direction to the one expected.

     THEIR COMPONENT IS OURS. `components/accordion/GGAccordion.tsx` opens with "the
     calculator's FAQ accordion, ported exactly", and its stylesheet says the class names
     are kept "exactly as the source (.gm-ov__faq*) for 1:1 traceability against the
     calculator stylesheets". So the thing this surface was told to match is a port of
     `GMFaq` in sections.v2.jsx, which has been in this repo the whole time. **We had
     hand-rolled a second accordion beside our own.**

     THAT IS THE 25 AUGUST LESSON REPEATING: when an ask points at an existing thing, go
     and look at the existing thing. The rail tooltips shipped navy before somebody
     checked that every tooltip in the product is white; this is the same error with a
     different component. It also means the two surfaces now match BY CONSTRUCTION rather
     than by tracing, so they cannot drift apart the next time either is edited.

     `window.GMFaq` IS A REAL GLOBAL HERE, verified on the live page before relying on it,
     which matters because it carries no explicit `window.X =` export. It works because
     Babel emits a classic script and index.html loads sections.v2.jsx BEFORE this file.
     That ordering is the whole contract, so a guard asserts it rather than trusting it.

     THE INLINE STYLE IS NOT DECORATION, and it is copied from their call site for the
     same reason they needed it. `.gm-ov__faqs` carries its own top rule, top margin and
     top padding, which are right when the accordion stands alone and wrong inside a card
     that already provides the chrome. Their ReferralFaq.tsx passes exactly these three.

     NO BOUNTY FIGURE, and it is not ours to reintroduce. Their header records that the
     bounty variant of answer 01 was removed on 3 August because it was live against a
     programme with no cap and no payout mechanism. Our copy has never carried one: there
     are zero user-facing `bounty` strings in this repo and the only matches are code
     comments naming the engine type `intro_show_bounty`. */
  function ReferralFaqBlock() {
    var Faq = (typeof window !== 'undefined' && window.GMFaq) || null;
    return e('section', { className: 'gg-pane-1 rl__faq' },
      e('h2', { className: 'rl__faqt' }, 'How the referral scheme works'),
      e('div', {
        className: 'gm-ov__faqs',
        style: { marginTop: 0, borderTop: 'none', paddingTop: 0 }
      },
        RL_FAQ.map(function (row, i) {
          return Faq ? e(Faq, { key: row[0], n: i + 1, q: row[0], a: row[1] }) : null;
        })));
  }

  function ReferralsLive(props) {
    var onLeave = (props && props.onLeave) || function () {};
    /* ⚠️ THE MECHANISM ALREADY EXISTED AND THIS VIEW WAS THE ONLY PATH NOT WIRED TO IT.
       Found 27 Aug 2026 whilst reviewing why a registered referrer who reloads sees an
       empty form again. `gg.pricing-cart.v1` carries a referralResult key, it was null,
       and the reason is not that persisting was undecided:
         app.v2.jsx:1246   the SET_REFERRAL_RESULT reducer case
         app.v2.jsx:1721   writes it to storage
         app.v2.jsx:511    reads it back on restore
         sections.v2.jsx:13582  the GROW CHECKOUT dispatches it
       and signed-out-sidebar.jsx did not mention it once. Same outcome, two paths to it,
       one of them wired. So this is an inconsistency rather than a decision nobody made.
       IT DEGRADES RATHER THAN THROWS. Both props are optional and both are guarded, so an
       older app.v2.jsx that passes neither leaves this view exactly as it was. */
    var savedRR = (props && props.saved && props.saved.shareUrl) ? props.saved : null;
    var onRegistered = (props && props.onRegistered) || function () {};
    /* ══ ONE PAGE, AND THE CHOICE MOVED DOWN INTO PANEL 02 ═══════════════════════
       28 Aug 2026, Nicole, after the design pass. This was two top-level tabs and it
       is now a mode on the panel that the choice actually affects.
       WHY THE TABS WENT. A tab promises that two meaningfully different things sit
       behind it. Here they did not: one account, one link, one code, and panel 01 was
       byte for byte the same on both sides. The tab asked for a decision at the top of
       the page, before the reader had read anything, and that decision then changed
       four sentences. It also made the candidate side a 673px page with no forecast and
       no FAQ, which read as half-built, whilst a reader who landed on the business side
       never learnt we take people at all. Both failures came from the split.
       ⚠️ IT DEPARTS FROM ALEXANDER'S RULING AND NICOLE MADE THAT CALL KNOWINGLY.
       FREELANCER #9 @15:48-16:02 and #18 @01:47-02:10 both specify two tabs. Both are
       about the PORTAL's referrals surface, which is a different surface with a
       different job, and the portal is keeping its two. This is the calculator only. */
    var modeState = React.useState('business'); var mode = modeState[0], setMode = modeState[1];
    /* ⚠️ THE REGISTRATION OVERLAY. Nicole, 28 August 2026: "make the form they have to fill
       out in order to generate the referral link an overlay instead of having the form on the
       panel itself... similar to the See full breakdown overlay... where the background gets
       blurred out." Built on exactly that, see registerOverlay below. */
    var regOvState = React.useState(false); var regOv = regOvState[0], setRegOv = regOvState[1];
    /* rows STAYS EMPTY NOW AND THAT IS THE POINT. It used to be filled by the local
       submit handler, which is what made the table lie. Kept as state rather than
       deleted because the table, the counts and the empty state all read from it, and
       because a real reader may yet arrive here through the portal's preview endpoint.
       setRows is intentionally unused. */
    /* `rows` WENT WITH THE TABLE, 25 Aug 2026. It was held as state rather than a
       constant because the table, the counts and the empty state all read from it. None
       of those three exist here now. */
    var errState = React.useState(''); var err = errState[0], setErr = errState[1];
    /* ══ NAMING A BUSINESS, WHICH THIS PANEL COULD NOT DO UNTIL TODAY ═══════════════
       27 Aug 2026. This lane wrote "there is no anonymous route at all" into a permanent
       wall on the morning of the 27th, on the strength of the portal's note of the 26th.
       The route shipped the same day that note was written. We probed it ourselves from
       this origin, with a control, before building against it.
       ⚠️ THE TWO TABS STOP BEING IDENTICAL HERE AND NICOLE APPROVED THAT SPECIFICALLY.
       She asked on the 27th for the candidate tab to work the same way as the business
       one, and one shared panel sequence was built for both. It still is: the divergence
       is one branch inside panel 02, because the two tabs can genuinely do different
       things. Candidates need a session, businesses do not. */
    /* The business-submit state went with submitBusiness on 28 August. */
    /* ⚠️ THE CLIENT NAME, COMPANY AND EMAIL FIELDS WERE REMOVED ON 25 AUGUST 2026, and
       the reason is an API fact rather than a design preference. Nicole ruled option 1
       after this lane probed the portal.

       THERE IS NO ENDPOINT THAT ACCEPTS A REFERRED BUSINESS. Probed from this origin:
       create-affiliate answers 400 VALIDATION_ERROR, so it is live, whilst
       referrals/submit, referrals/create, referrals, referrals/business and
       referrals/refer all answer 404 with a Next.js HTML page. The three routes that do
       exist -- create-affiliate, validate and signin -- every one of them takes the
       REFERRERS own email. A third party's details have nowhere to go.

       THAT WAS ALREADY TRUE BEFORE THIS CHANGE. The old form handed off to the
       calculator, which wrote the values into refer_biz_ qualifier keys, where
       platform-api.js v31 strips them from the anonymous body and the qualifier emit
       whitelists eight keys that deliberately exclude all five. They were captured and
       never sent, BY DESIGN. Moving submission here would not have given them a
       destination, it would only have moved where we collected them.

       SO WE COLLECT NOTHING WE CANNOT PROCESS. The referrer gets their link, the
       referred business identifies itself when it uses that link, and no third party's
       name or email touches a public surface at all. When the portal ships an endpoint
       this is where the fields come back. */
    var svcState = React.useState({}); var svcSel = svcState[0], setSvcSel = svcState[1];
    var fEmail = React.useState('');
    /* THE FOUR REGISTRATION FIELDS, 26 August 2026. The portal's public route has
       accepted them since PR #1422 and we were sending none of them, so they were
       collected nowhere and every code minted here fell back to the email local part.
       COMPANY IS THE ONE THAT CHANGES SOMETHING VISIBLE. It decides the shape of the
       referral code, verified at both ends rather than assumed: create-affiliate.ts:237
       builds companyForCode from it and passes it to mintReferralCode, and mint-code.ts
       records that with no company the token is byte-identical to what it always was. */
    var fFirst = React.useState('');
    var fLast = React.useState('');
    var fCompany = React.useState('');
    var fWebsite = React.useState('');
    var phaseState = React.useState('idle'); var phase = phaseState[0], setPhase = phaseState[1];
    var linkState = React.useState(''); var link = linkState[0], setLink = linkState[1];
    var codeState = React.useState(''); var code = codeState[0], setCode = codeState[1];
    /* ONE ACCOUNT, ONE CODE, SO EITHER PATH SATISFIES THIS PANEL. Somebody who registered
       at the Grow checkout arrives here already holding a link, and showing them the form
       again would ask them to mint a code they have. That is the same reasoning that put
       both referral tabs on one panel sequence on the 27th.
       ⚠️ THE EMAIL IS RESTORED TOO AND IT IS NOT COSMETIC. portalSignIn posts fEmail, so
       without this the sign-in control would exchange an EMPTY email for a session and
       fall through to the login page every time.
       dashEmail IS DELIBERATELY LEFT ALONE. We do not know what the portal did about email
       on a visit we are only reading back, and its third branch is silence, which is the
       honest answer rather than a guess dressed as a fact. */
    React.useEffect(function () {
      if (!savedRR) return;
      setLink(String(savedRR.shareUrl || ''));
      setCode(String(savedRR.code || ''));
      if (savedRR.email) { fEmail[1](String(savedRR.email)); }
      setPhase('done');
    }, []);
    /* THE EMAIL OUTCOME IS THREE STATES, NOT A BOOLEAN, from 26 August 2026.
       This was `dashSent`, a boolean read from a flag the portal returned as a hardcoded
       true. This lane reproduced the defect on the live rail that morning: an address with
       an account since 30 June got 200 back with its code, the flag true, and NO EMAIL. The
       panel said "We have also emailed it to you" on the strength of a flag that could not
       be false, so there was no way to stop saying it.
       THE PORTAL FIXED IT AT SOURCE rather than us writing around it, which is the standing
       rule. dispatchPortalInvite had always returned { ok, skipped? } and the route was
       discarding it. It now returns dashboardEmail as 'sent' | 'skipped' | 'failed' and
       keeps dashboardEmailSent derived so nothing broke whilst we caught up.
       WHY THE MIDDLE STATE EARNS ITS OWN SENTENCE. Reading only the boolean, a returning
       referrer fell from a falsehood to SILENCE: the true case says something, the other two
       say nothing at all. Silence is honest and unhelpful, and "skipped" is the commonest of
       the three for anyone who has been here before. */
    var dashState = React.useState(''); var dashEmail = dashState[0], setDashEmail = dashState[1];
    var copiedState = React.useState(false); var copied = copiedState[0], setCopied = copiedState[1];
    /* THE MAGIC-LINK BRANCH. 26 Aug 2026. ggReferralSignIn can answer three ways and this
       control only ever handled one of them: a session url, a magic link already sent, or
       nothing. The middle case had no branch, so a referrer whose link was in their inbox
       was sent to a page instead of being told to go and look. */
    var chkState = React.useState(false); var checkEmail = chkState[0], setCheckEmail = chkState[1];

    /* NINE BOXES, NOT TWELVE, AND THE CUTS ARE NICOLE'S RULING OF 25 AUGUST.
       Alexander, Loom 20 at 21:47: "I think this is probably too granular... they
       literally just select services they might be interested in, and it's basically
       just nine boxes". Twelve top-level services exist in data.jsx.
       DEDICATED RESOURCES IS ONE BOX, NOT TWO. A referrer guessing on somebody else's
       behalf cannot know part-time from full-time, and asking them to is exactly the
       granularity he called too much.
       THE THREE CAPITAL SERVICES ARE OUT. Founders Portal, Fundraising Support and
       Investor Portal are founder and investor products, and refer mode sits on the
       agency path, so offering them invites a referral we cannot service from here.
       NOT SURE YET IS NOT PADDING. It is the honest answer for most referrers, and
       without it people guess to get past the screen, which produces worse data than
       no data. IT IS AN INDEPENDENT TOGGLE and stopped clearing the rest on 28 Aug
       2026; toggleSvc records why. */
    var RL_SERVICES = [
      { id: 'sales',      label: 'Sales and demand generation' },
      { id: 'paid-ads',   label: 'Paid advertising' },
      { id: 'email',      label: 'Email marketing' },
      { id: 'smm',        label: 'Social media management' },
      { id: 'content',    label: 'Content creation' },
      { id: 'motion',     label: '3D animation' },
      /* TWO SERVICES, NOT ONE MERGED OPTION. Nicole, 26 Aug 2026: "I was actually
         saying we should have Full-Time Dedicated Resources and Part-Time Dedicated
         Resources on the service dropdown, not just a consolidated Dedicated Resources
         option."
         THE MERGED `dedicated` ID IS RETIRED. It was introduced on the argument that a
         referrer cannot tell part-time from full-time, and it cost more than it saved:
         it resolved to NO service in window.SERVICES, which the portal was warned about
         in writing on 26 August, and it forced a Type pill row inside the panel to ask
         the question the picker should have asked. These ids are the REAL ones, so the
         picker now resolves cleanly against window.SERVICES and the extra row is gone. */
      { id: 'dedicated-pt', label: 'Part-time dedicated resources' },
      { id: 'dedicated-ft', label: 'Full-time dedicated resources' },
      { id: 'whitelabel', label: 'White label services' },
      { id: 'unsure',     label: 'Not sure yet' }
    ];
    /* ── NOT SURE YET CLEARS THE REST, AND ASKS FIRST. 28 Aug 2026 (Alexander).
       THIS SETTLES A RULING THAT MOVED TWICE IN ONE DAY, so the history matters more
       than usual. It was mutually exclusive by design: "not sure" and a list of
       services contradict each other. That was reported as a bug this morning and made
       an independent toggle. The report was right about the SYMPTOM and wrong about the
       CAUSE, and the correction is this: the exclusion was never the problem, the
       silence was.
       WHAT MADE IT LOOK LIKE A BUG. The accordion filters by family, so the picks it
       destroys can sit in a family the reader is not looking at. Work vanished off
       screen with nothing said. A rule you cannot see enforced is indistinguishable
       from a defect, which is why it got reported as one.
       SO THE RULE STAYS AND THE SILENCE GOES. The wipe now happens behind a confirm,
       and the ask lives on the control rather than in here: see the unsure branch in
       the row onClick, which only asks when chosenIds is non-empty. This function is
       the mechanism and is deliberately unconditional, so nothing can reach the wipe
       by another route and skip the question.
       NOTHING DOWNSTREAM COUNTS UNSURE, so no figure moves either way: chosenIds
       excludes it by name, and famChosen and activeSvc derive from chosenIds. */
    function toggleSvc(id) {
      setSvcSel(function (prev) {
        var next = {};
        Object.keys(prev).forEach(function (k) { if (prev[k]) next[k] = true; });
        if (id === 'unsure') { return next.unsure ? {} : { unsure: true }; }
        delete next.unsure;
        if (next[id]) { delete next[id]; } else { next[id] = true; }
        return next;
      });
    }
    var svcChosen = RL_SERVICES.filter(function (x) { return svcSel[x.id]; });
    /* svcTags() WAS DELETED 26 AUGUST 2026 and this note replaces it rather than the
       function being left behind unused. It capped the chosen services at three plus a
       count, which was right whilst they were a read-back and wrong the moment they
       became the switcher, because a capped switcher cannot reach service four. A dead
       helper for a replaced shape is how the replaced shape comes back. */
    /* WHO IS BEING REFERRED. The three cards and their figures come from
       window.REFER_WHO_OPTIONS in data.jsx, which is the single source as of 25 Aug.
       They used to be an inline literal in sections.v2.jsx and gained a second reader
       when FREELANCER 20 moved this function here, so they were extracted rather than
       copied. Two copies of a published money figure is how a number drifts, and the
       £50 cut from Video 4 is on this estate's record as that error happening three
       times already. */
    /* ── THE FORECAST. FREELANCER 20 at 21:47, ruled by Nicole 25 Aug 2026. ────────
       THE INPUT IS AVERAGE CLIENT VALUE, NOT A TIER, AND THE DATA DECIDED THAT.
       Nicole first asked for services plus a tier. Only four of the nine options in
       RL_SERVICES carry prices[tier] at all: sales, paid-ads, email and whitelabel.
       Content creation, 3D animation and Dedicated resources are priced per project,
       per day and per person, so a tier forecast would silently return NOTHING for
       half the list, which understates rather than errors and therefore looks like it
       worked. The service picker stays as the signal of what they might need.

       THE MODEL IS FreelancerReferralEstimator's, NOT A SECOND ONE. That component
       already takes an average client value, applies the flat 10%, and publishes
       window.__flReferAvg with a gg:refer-avg event for exactly this purpose. We read
       and write the same bridge, so the two surfaces cannot disagree.

       ⚠️ ONLY THE 10% IS IN THE TOTAL, and this is a money rule rather than a layout
       one. recey5BV1B7OxB7oT and rec1Jhythsmumz7iF: three figures are published and
       only the recurring commission is payable. The £50 on sign-up is portal gap 2 and
       the investor £100 is portal gap 1, both open since 22 July, both with NO payout
       mechanism. They stay VISIBLE, because Nicole's standing rule is that the
       calculator states the intent and the portal builds to it, and the record says do
       not quietly remove them again. But a forecast reads as what you will receive, so
       they sit BESIDE the figure rather than inside it.

       NO VAT WORDING ANYWHERE NEAR THIS. Standing rule from the 22 July VAT position:
       partners are a mix of registered and unregistered, so any single stated treatment
       is wrong for one group. */
    /* ── THE FORECAST. FREELANCER 20 at 21:47, ruled by Nicole 25 Aug 2026. ────────
       IT IS THE CALCULATOR'S OWN MODEL, NOT A SECOND ONE. sections.v2.jsx:11700 is
       Math.round(0.10 * total) against the quote's monthly total, and this is the same
       arithmetic against the same published prices. I first built it on a typed
       "average client value" and Nicole rejected it: the forecast has to work the way
       the forecast works.

       PRICES COME FROM window.priceFor, THE SAME FUNCTION THE CALCULATOR USES.
       25 August 2026: this block used to read service.prices[tier][commitMonths] by
       hand, and the comment here argued that commitMonths was the right column because
       it is the term the commission runs for. That was wrong, and it was wrong in the
       expensive direction. commitMonths is a service's MINIMUM TERM, not the price
       column any surface displays. Every other surface resolves through data.jsx
       priceFor, whose commit default is '12' (app.v2.jsx defaultCommitId, app.jsx
       TWEAK_DEFAULTS.defaultCommit, and priceFor's own fallback all agree).
       Reading commitMonths made Sales price at 1395/2295/2995 whilst the calculator's
       own AVAILABLE TIERS panel showed 995/1795/2495 for the same three tiers on the
       same page. Every forecast was overstated, and the two panels contradicted each
       other in front of the reader.
       DELEGATING RATHER THAN COPYING also inherits the cases a hand-read matrix cannot
       see: freeStarter, oneTimeTiers, the whitelabel per-client plan, Founders Pro
       going free, and custom-priced tiers, which now report as unpriced instead of
       silently scoring zero.

       THREE OPTIONS CANNOT PRICE AND EACH SAYS SO DIFFERENTLY, which is Nicole's call
       to show them rather than hide them:
         content and motion  waitlistTiers covers ALL FOUR tiers, so they are genuinely
                             waitlisted and greyed with that reason.
         dedicated           priced per person rather than per tier, so it is greyed for
                             a different reason and the label says which.
         unsure              deliberately no forecast. It exists so somebody who does not
                             know can still get their link, which is the point of it.

       THE TOTAL IS A FLOOR, NOT A CEILING, and the panel says so. A referrer who picks
       a greyed option would otherwise read a smaller number as the whole answer. */
    var RL_RATE = 0.10;
    /* THE TIER LIST BELONGS TO THE SERVICE, NOT TO THIS FILE.
       Nicole, 26 August 2026: "on the white label services on the referrals view, the
       tier names are wrong. It should be: reseller fulfillment, fulfillment + client
       management, enterprise."
       SHE FOUND THE HAND-WRITTEN SECOND REPRESENTATION. This array used to be the tier
       list, hardcoded starter/grow/scale, and it was wrong for white label in two ways
       rather than one. The names are overridden in data.jsx:824, AND the third tier is
       `enterprise` rather than `scale` — the legacy Scale tier was archived in April, so
       this surface was offering a tier that is not sold and pricing it from a column
       that still exists in the data. A wrong label is cosmetic; offering an archived
       tier is not.
       window.tiersFor (data.jsx:1628) IS THE RESOLVER and the calculator already uses it
       at sections.v2.jsx:5936-5937 for both the grid and the cards. This is the same
       failure the REUSE LAW was written about, committed by this lane, one day after
       writing it down.
       NOTE ON SPELLING: Nicole wrote "fulfillment". data.jsx says "Fulfilment", one L,
       which is the British form and the house rule. Taking the ids from the resolver
       means we render whatever data.jsx says and neither spelling is retyped here. */
    var RL_TIERS_FALLBACK = [
      { id: 'starter', name: 'Starter' },
      { id: 'grow',    name: 'Grow' },
      { id: 'scale',   name: 'Scale' }
    ];
    /* ── DEDICATED RESOURCES ─────────────────────────────────────────────────
       THIRD VERSION, 26 August 2026. The first used a native select; the second used
       one office for the whole selection and a Type pill row. Both were wrong and
       Nicole named why each time.

       WHAT SHE ASKED FOR HERE, in her words: "there should be three checkboxes with
       flags next to them, really small flags, kind of like emojis... It should be per
       role, not just three checkboxes, and it applies to all of the roles they picked.
       It should be per role, so there will be a lot of checkboxes on the dropdown."
       So a role is not chosen and then given an office. A role is chosen IN an office,
       and the same role can be wanted in two, which is two people.

       THE FLAGS ARE THE EMOJI THE DATA ALREADY CARRIES. FT_LOCATIONS has flag: '🇬🇧'
       on every row and has since June. The previous version fetched SVGs from flagcdn
       for a 14px slot, which is what Nicole was looking at when she said it "looks so
       bad". The emoji was there the whole time.

       THE TYPE ROW IS GONE because part-time and full-time are now two entries in the
       service picker, which is where the question belonged. */
    function isDed(id) { return id === 'dedicated-pt' || id === 'dedicated-ft'; }
    function dedTierOf(id) { return id === 'dedicated-ft' ? 'fulltime' : 'parttime'; }
    function dedServiceFor(id) {
      var list = (typeof window !== 'undefined' && window.SERVICES) || [];
      return list.filter(function (x) { return x.id === id; })[0] || null;
    }
    function dedRolesFor(id) {
      try {
        var svc = dedServiceFor(id);
        if (!svc || typeof window.dfRolesForTier !== 'function') return [];
        return (window.dfRolesForTier(svc, dedTierOf(id)) || []).filter(function (r) {
          /* Custom roles are excluded on Nicole's instruction. They price from a rate
             the buyer types, which a referrer guessing on somebody else's behalf has no
             way to know. */
          return r && !r.isCustom && r.id !== 'pt-custom' && r.id !== 'ft-custom';
        });
      } catch (e) { return []; }
    }
    function dedPlans() {
      var all = (typeof window !== 'undefined' && window.DF_DAY_PLANS) || [];
      return all.filter(function (p) { return p && p.id !== 'custom'; });
    }
    /* BdCommitPill reads a value key and a save percentage. DAY_PLANS carries the
       discount as a fraction, so it is converted here rather than a second list of
       percentages being written down anywhere. */
    function dedPlanOpts() {
      return dedPlans().map(function (p) {
        return { id: p.id, days: p.days, save: Math.round((p.discount || 0) * 100) };
      });
    }
    /* HOW MANY OF EACH. Nicole put this on the header line beside the days. It is a
       count PER CHECKED ROLE AND OFFICE, matching the cart, whose FT line carries its
       own qty per role. Two designers in the Philippines and one in the UK is two
       checkboxes, and the count then says how many of each. */
    function dedQtyOpts() {
      return [1, 2, 3, 4, 5].map(function (n) { return { id: String(n), n: n, save: 0 }; });
    }
    function dedCfg(id) {
      var c = (svcCfg && svcCfg[id]) || {};
      var picks = (c.picks && typeof c.picks === 'object') ? c.picks : {};
      var plans = dedPlans();
      var planId = c.planId && plans.some(function (p) { return p.id === c.planId; }) ? c.planId : 'd5';
      var qty = Math.max(1, Math.min(5, parseInt(c.qty, 10) || 1));
      return { picks: picks, planId: planId, qty: qty };
    }
    function dedPatch(id, patch) {
      setSvcCfg(function (prev) {
        var next = {}; for (var k in prev) { if (Object.prototype.hasOwnProperty.call(prev, k)) next[k] = prev[k]; }
        next[id] = Object.assign({}, prev && prev[id] ? prev[id] : {}, patch);
        return next;
      });
    }
    /* One checkbox is one (role, office) pair. Toggling copies rather than mutating,
       because React compares identity and a mutated object re-renders nothing. */
    function dedToggle(id, roleId, locId) {
      var cur = dedCfg(id).picks;
      var list = (cur[roleId] || []).slice();
      var at = list.indexOf(locId);
      if (at === -1) list.push(locId); else list.splice(at, 1);
      var picks = {};
      Object.keys(cur).forEach(function (k) { if (cur[k] && cur[k].length) picks[k] = cur[k]; });
      if (list.length) picks[roleId] = list; else delete picks[roleId];
      dedPatch(id, { picks: picks });
    }
    function dedLocs() { return (typeof window !== 'undefined' && window.DEDICATED_FT_LOCATIONS) || []; }
    /* The offices a role actually offers. A role carrying locations ['uk'] must not
       show a Philippines checkbox at all, rather than showing one that prices wrongly. */
    function dedLocsFor(role) {
      try {
        if (role && typeof window.dfLocationsFor === 'function') return window.dfLocationsFor(role) || [];
      } catch (e) {}
      return dedLocs();
    }
    function dedCount(id) {
      var picks = dedCfg(id).picks, n = 0;
      Object.keys(picks).forEach(function (k) { n += (picks[k] || []).length; });
      return n;
    }
    /* ── THE ARITHMETIC IS THE CART'S, BOTH MODELS ────────────────────────────
       PART-TIME, traced from ptMonthly: the plan's day rate, rounded, times the office
       multiplier, rounded AGAIN, then days and the flat 20% recurring saving. BOTH
       ROUNDINGS MATTER. Rounding once at the end gives a different figure, and a
       forecast that disagrees with the cart by a pound is worse than none because it is
       checkable.
       FULL-TIME, traced from ftMonthly: the hourly rate for the office at the JUNIOR
       floor, times 173 hours, rounded, then times the headcount. The 173 is the flow's
       own number and covers annual leave and local hiring and compliance. The rounding
       sits before the headcount multiply exactly as the cart has it.
       SENIORITY IS THE INPUT WE DO NOT ASK FOR. The cart returns nothing without it, and
       a referrer can no more know seniority than tier, so this takes junior, which is the
       floor FT_SENIORITY anchors to the office pill. The note says floor, so the figure
       is not passed off as a quote. This is the flow's own behaviour: its estimate
       tooltip reads "Until we shortlist, it shows the floor rate for each role."
       OMITTED DELIBERATELY from both: the agency partner multiplier the cart applies. A
       referrer is not buying, so folding in a partner discount would forecast commission
       against a price the referred business will never be shown. */
    function dedMonthly(id) {
      try {
        var c = dedCfg(id);
        var roles = dedRolesFor(id);
        var plans = dedPlans();
        var plan = plans.filter(function (p) { return p.id === c.planId; })[0] || plans[0];
        var ft = id === 'dedicated-ft';
        var total = 0;
        Object.keys(c.picks).forEach(function (roleId) {
          var role = roles.filter(function (r) { return r.id === roleId; })[0];
          if (!role || role.waitlist) return;
          (c.picks[roleId] || []).forEach(function (locId) {
            var loc = dedLocs().filter(function (l) { return l.id === locId; })[0];
            if (!loc) return;
            if (ft) {
              if (typeof window.dfSeniorityRate !== 'function') return;
              var hourly = window.dfSeniorityRate(role, locId, 'junior');
              if (typeof hourly !== 'number' || !(hourly > 0)) return;
              total += Math.round(Math.max(6, hourly) * 173) * c.qty;
              return;
            }
            if (!plan) return;
            var base = role.price || 0;
            var rate = (role.dayRates && typeof role.dayRates[plan.id] === 'number')
              ? role.dayRates[plan.id]
              : Math.round(base * (1 - (plan.discount || 0)));
            var mult = (typeof window.dfLocMult === 'function') ? window.dfLocMult(role, loc) : 1;
            rate = Math.round(Math.round(rate) * mult);
            total += Math.round(rate * plan.days * 0.8) * c.qty;
          });
        });
        return total;
      } catch (e) { return 0; }
    }
    /* THE WORDS FOR A PRICE BELONG TO window.priceFor, NOT TO gbp().
       26 August 2026, found live whilst checking Nicole's white label report. priceFor
       returns { value, label, free, custom }, and this surface was throwing everything
       but `value` away and running it through gbp(). Two consequences, both visible on
       the white label card:
         - Reseller Fulfilment is a FREE tier. priceFor says label 'Free' and free true.
           gbp(0) said "£0/month", which is a price where the product has none.
         - Enterprise carries label 'Bespoke Pricing'. This lane had hardcoded
           'Contact for Pricing' from the calculator's fallback branch, which only fires
           when the label is literally 'Custom'. So the referrals card and the calculator
           said different things about the same tier.
       WHAT THIS DELIBERATELY DOES NOT COPY: the calculator's whitelabel-specific display
       tree at sections.v2.jsx:6030-6090, which hardcodes £95 for grow, 'Free' for every
       other WL tier, and "+ Custom wholesale discount" for the setup line. That is the
       BUYING surface and Nicole ruled this one a simplified forecast. Reusing the shared
       DATA is reuse; transplanting the other surface's branch tree is duplication that
       would drift on the next pricing change. */
    function priceWords(id, t, c) {
      var m = svcMeta(id);
      if (!m.priceable || !m.svc) return null;
      try {
        if (typeof window !== 'undefined' && typeof window.priceFor === 'function') {
          var p = window.priceFor(m.svc, t, c || cfgFor(id).commit) || {};
          /* THE FREE SIGNAL IS THE LABEL, NOT A FLAG. Verified against the running
             app on 26 Aug 2026: window.priceFor returns exactly { value, label,
             custom } and there is NO `free` key. This branch tested p.free alone,
             which is undefined on every call, so white label's Reseller Fulfilment
             fell through and printed "£0/month" for a tier the data calls Free —
             the very thing the comment above says this function exists to prevent.
             THE GUARD ENCODED THE SAME WRONG ASSUMPTION and passed alongside it.
             THE SAME DEAD TEST EXISTS ON THE CALCULATOR, sections.v2.jsx, where a
             white-label special case two lines below happens to mask it. Flagged to
             Nicole rather than changed here: that is the buying surface. */
          if (p.free || p.label === 'Free') return 'Free';
          if (p.custom) return p.label || 'Bespoke Pricing';
        }
      } catch (e) {}
      return null;
    }
    function rlTiers(id) {
      try {
        var m = svcMeta(id);
        var svc = (m && m.svc) ? m.svc : null;
        /* ENTERPRISE IS NOT OFFERED ON THIS SURFACE, 26 Aug 2026 (Nicole): "Why are we
           including Enterprise in the available tiers? We did not have to do that. We
           just included Enterprise for white label services because I thought there's a
           price for that. We need to remove it since there's no price for it."
           SHE IS RIGHT AND IT IS TRUE OF EVERY SERVICE, not only white label. Read from
           the running app rather than assumed: no service in the catalogue carries a
           price for `enterprise`. White label's Enterprise resolves to label "Bespoke
           Pricing", custom true, value 0, and the other priceable services have price
           columns for starter, grow and scale only.
           A TIER THAT CAN NEVER SHOW A NUMBER IS DEAD WEIGHT ON A FORECAST. It is a real
           tier on the buying surface, where a conversation follows the click. Here the
           whole point is the figure, so a card that can only ever say "Bespoke Pricing"
           takes a third of the row and gives nothing back. */
        var drop = function (list) {
          return (list || []).filter(function (t) { return t && !t.isEnterprise; });
        };
        if (typeof window !== 'undefined' && typeof window.tiersFor === 'function') {
          var out = drop(window.tiersFor(svc));
          if (out.length) return out;
        }
        if (typeof window !== 'undefined' && Array.isArray(window.TIERS) && window.TIERS.length) {
          var g = drop(window.TIERS);
          if (g.length) return g;
        }
      } catch (e) {}
      /* Load-order fallback only. Same three ids the global list opens with, so it
         cannot disagree with data.jsx about what a tier IS, only about how many. */
      return RL_TIERS_FALLBACK;
    }
    /* 26 Aug 2026: TIER AND COMMITMENT ARE PER SERVICE, NOT PER BASKET.
       Nicole: "you can basically select any tier or any commitment option for each
       service, not the whole basket, so that's why I want it to be like this."
       There is no `tier` state and no `commitId` state any more. Both are derived from
       whichever service the pills below have made active, so the panels always describe
       one service and there is no third value that can disagree with the two of them. */
    var cfgState = React.useState({}); var svcCfg = cfgState[0], setSvcCfg = cfgState[1];
    var activeState = React.useState(''); var activeRaw = activeState[0], setActiveSvc = activeState[1];

    /* ── THE FAMILY STEP BAR, Nicole 25 August 2026 ────────────────────────────
       "we kinda need the step bar but only so they can switch to creative services
       and talent solutions as well". So the steps are SERVICE FAMILIES, not the
       calculator's Commitment/Tier/Channels/Add-ons stepper, which configures ONE
       service and is not what this is for.

       THE FAMILY NAMES COME FROM data.jsx `category`, WHICH IS NOT WHAT ANYONE CALLS
       THEM. The stored values are Growth, Creative, Talent, Agency and Capital;
       "Creative Services" and "Talent Solutions" exist only as prose in tooltips.
       Labels here are the spoken names, ids are the stored ones, so the mapping is
       explicit rather than a rename that silently stops matching.

       CAPITAL IS ABSENT ON PURPOSE, per the standing ruling: Founders Portal,
       Fundraising Support and the Investor Portal are founder and investor products
       and refer mode sits on the agency path.

       WHITE LABEL IS PRESENT even though Nicole named only Creative and Talent. It
       was already in the nine live options, and dropping a service whilst adding a
       switcher would be a removal disguised as a layout change. */
    var RL_FAMS = [
      { id: 'Growth',   label: 'Growth' },
      { id: 'Creative', label: 'Creative services' },
      { id: 'Talent',   label: 'Talent solutions' },
      { id: 'Agency',   label: 'White label' }
    ];
    /* THE BRIDGE BETWEEN THE CALCULATOR'S STEP IDS AND OUR STORED CATEGORY VALUES.
       data.jsx stores category as Growth, Creative, Talent, Agency and Capital;
       the flow's step ids are growth, creative, talent and whitelabel. Capital has
       no step in the agency flow and no family here, which is correct: its three
       services are founder and investor products and refer mode sits on the agency
       path, so offering them would invite a referral this path cannot service.
       ONE MAP, BOTH DIRECTIONS USED. Written out rather than derived, because a
       derivation would have to guess at the pairing and the pairing is a fact. */
    var FAM_OF_STEP = { growth: 'Growth', creative: 'Creative', talent: 'Talent', whitelabel: 'Agency' };
    /* ⚠️ ONE LABEL IS OVERRIDDEN AND IT IS THE ONLY STRING THIS BAR WRITES.
       Nicole, 26 August: "I thought we don't need the white label plan." She was right
       about the words and it turned out to be a label problem rather than a step problem.
       WHY THE STEP STAYS: `whitelabel` is a REFERRABLE service. It is the only service in
       the Agency category, it is one of the nine options ruled on 25 August, and it is one
       of the nine ids sent to the portal this morning. Dropping the tab would have made it
       unreachable in the picker without removing it from the list, which is the quiet kind
       of wrong. Nicole confirmed agencies are referrable, and "An agency" is one of the
       three refer-who cards, so the service belongs here.
       WHY THE LABEL CHANGES: on the calculator's flow this step is where a RESELLER picks
       THEIR OWN plan, Reseller Fulfilment or Fulfilment plus Client Management, so
       "White-label plan" is exactly right there. On this bar it names a family of services
       a referred business might buy, and a plan-chooser label is wrong for that.
       NO HOUSE STRING EXISTS TO REUSE, checked rather than assumed: window.getStepLabel
       returns "White-label plan" for every clientTypeId and intentId combination tried,
       including agency-referral. The service's own name in data.jsx is "White Label
       Services", which is title case whilst its three siblings on this bar are sentence
       case, so the label below is that name in the siblings' casing.
       THE OTHER THREE LABELS ARE NOT TOUCHED and still come from stepsForClient, so a
       rename in data.jsx still reaches them without anybody remembering this bar exists. */
    var FAM_LABEL_OVERRIDE = { whitelabel: 'White label services' };
    function FAM_FLOW() {
      try {
        if (typeof window.stepsForClient !== 'function') return [];
        var f = window.stepsForClient('agency', 'agency-whitelabel') || [];
        return f.slice(1, -1)
          .filter(function (x) { return !!FAM_OF_STEP[x.id]; })
          .map(function (x) {
            return FAM_LABEL_OVERRIDE[x.id]
              ? Object.assign({}, x, { label: FAM_LABEL_OVERRIDE[x.id] })
              : x;
          });
      } catch (e) { return []; }
    }
    function FAM_OF_SERVICE(id) {
      var svc = (window.SERVICES || []).filter(function (x) { return x.id === id; })[0];
      return svc ? svc.category : null;
    }
    var famState = React.useState('Growth'); var fam = famState[0], setFam = famState[1];

    /* COMMITMENT IS NOW THE READER'S CHOICE. It was hardcoded to '12' when the prices
       began resolving through window.priceFor. The calculator lets you pick, the price
       column follows, and a forecast that cannot follow it would drift from the panel
       beside it the moment anyone moved the pills.
       SUPERSEDED 26 AUGUST, ONE DAY LATER, and the text above is kept because the
       reasoning still holds, only the scope changed: it is now one commitment PER
       SERVICE rather than one for the basket. The state lives in svcCfg above. */
    var RL_COMMITS = (typeof window !== 'undefined' && window.COMMITMENTS) || [
      { id: '3', months: 3, save: 0 }, { id: '6', months: 6, save: 20 }, { id: '12', months: 12, save: 40 }
    ];

    /* Which family a coarse option belongs to. `dedicated` is our merged option and has
       no single service behind it, so it is mapped here rather than looked up. */
    var RL_FAM_OF = { sales: 'Growth', 'paid-ads': 'Growth', email: 'Growth',
      smm: 'Creative', content: 'Creative', motion: 'Creative',
      'dedicated-pt': 'Talent', 'dedicated-ft': 'Talent', whitelabel: 'Agency' };
    /* What this family offers, excluding the unsure escape hatch, which belongs to no
       family. The picker's resting label counts THIS rather than every service, so
       switching family changes something visible without the menu having to open. */
    var famServices = RL_SERVICES.filter(function (x) {
      return x.id !== 'unsure' && RL_FAM_OF[x.id] === fam;
    });

    /* PRICEABLE IS COMPUTED FROM THE DATA, NOT LISTED HERE. A hand-written list of which
       services can price is a second representation of data.jsx and would drift the day
       a waitlist lifts or a price lands. */
    function svcMeta(id) {
      var list = (typeof window !== 'undefined' && window.SERVICES) || [];
      /* Both dedicated services are priced per person rather than by tier, so they are
         not tier-priceable. They ARE forecastable, from their own models, which is a
         different question and the reason the picker no longer mutes them. */
      if (isDed(id)) {
        return { priceable: false, why: 'priced per person', svc: dedServiceFor(id) };
      }
      var svc = list.filter(function (x) { return x.id === id; })[0];
      /* THE SERVICE TRAVELS EVEN WHEN IT CANNOT PRICE. Added 26 Aug 2026 with the
         tiersFor change: content and 3D animation are waitlisted at every tier, and
         returning without `svc` meant tiersFor got null and fell back to the GLOBAL tier
         list rather than theirs. Unpriceable is a statement about money, not about which
         tiers a service has. */
      if (!svc || !svc.prices) return { priceable: false, why: 'quoted individually', svc: svc || null };
      var wl = svc.waitlistTiers || [];
      var tiersWithPrice = Object.keys(svc.prices || {});
      var open = tiersWithPrice.filter(function (t) { return wl.indexOf(t) === -1; });
      if (!open.length) return { priceable: false, why: 'waiting list', svc: svc };
      return { priceable: true, svc: svc, openTiers: open };
    }
    /* DOES COMMITMENT ACTUALLY CHANGE THIS SERVICE'S PRICE?
       Nicole, 26 Aug 2026: "I don't think white label plans have a commitment period."
       She is right, and it is provable rather than remembered: data.jsx gives white
       label the SAME figure at 3, 6 and 12 months on every tier.
       THIS IS DERIVED, NOT A HARDCODED `whitelabel` CHECK. A control that cannot change
       the number should not be offered by ANY service priced flat across terms, and
       naming one service here would be the same hand-written second representation that
       got the tier list wrong this morning. If a future service goes flat, this hides
       itself; if white label ever gains real terms, it comes back on its own. */
    function commitMatters(id) {
      try {
        var m = svcMeta(id);
        if (!m.priceable || !m.svc) return false;
        var seen = null;
        for (var i = 0; i < m.openTiers.length; i++) {
          var t = m.openTiers[i];
          var vals = RL_COMMITS.map(function (c) { return priceFor(id, t, c.id); });
          for (var j = 1; j < vals.length; j++) { if (vals[j] !== vals[0]) return true; }
          seen = true;
        }
        return seen ? false : true;
      } catch (e) { return true; }
    }
    /* THE DEFAULTS ARE NAMED ONCE. '12' is what every other surface defaults to
       (app.v2.jsx defaultCommitId, app.jsx TWEAK_DEFAULTS and priceFor's own fallback
       all agree) and 'grow' is the recommended tier. cfgFor is the ONLY reader of
       svcCfg, so a service that has never been touched prices exactly like one that was
       touched and left alone, rather than falling through to openTiers[0]. */
    var RL_DEFAULT_CFG = { tier: 'grow', commit: '12' };
    function cfgFor(id) {
      var c = (svcCfg && svcCfg[id]) || {};
      return { tier: c.tier || RL_DEFAULT_CFG.tier, commit: c.commit || RL_DEFAULT_CFG.commit };
    }
    /* EVERY PRICING HELPER TAKES THE COMMITMENT rather than closing over one shared
       value. The shared RL_COMMIT that used to sit here was the thing that made the
       basket share a commitment, so removing it is the change, not a tidy-up. */
    function priceFor(id, t, c) {
      var m = svcMeta(id);
      if (!m.priceable) return 0;
      var useTier = m.openTiers.indexOf(t) === -1 ? m.openTiers[0] : t;
      if (typeof window !== 'undefined' && typeof window.priceFor === 'function') {
        var p = window.priceFor(m.svc, useTier, c || cfgFor(id).commit) || {};
        /* A custom or bespoke tier has no number to add. Returning 0 would score it as
           free; returning 0 here is only reached alongside unpriced() below, which
           tells the reader the figure is a floor. */
        if (p.custom) return 0;
        return typeof p.value === 'number' ? p.value : 0;
      }
      /* Load-order fallback only. Same column, so it cannot disagree with the above. */
      var row = m.svc.prices[useTier] || {};
      var _c = c || cfgFor(id).commit;
      return typeof row[_c] === 'number' ? row[_c]
        : (typeof row === 'number' ? row : 0);
    }
    /* A tier priced Custom is not priceable even though the service is, so the floor
       note has to count it. */
    function isCustomAt(id, t, c) {
      var m = svcMeta(id);
      if (!m.priceable) return true;
      var useTier = m.openTiers.indexOf(t) === -1 ? m.openTiers[0] : t;
      if (typeof window !== 'undefined' && typeof window.priceFor === 'function') {
        return !!(window.priceFor(m.svc, useTier, c || cfgFor(id).commit) || {}).custom;
      }
      return false;
    }
    function setupFor(id, t) {
      var m = svcMeta(id);
      if (!m.priceable) return 0;
      var useTier = m.openTiers.indexOf(t) === -1 ? m.openTiers[0] : t;
      var sf = m.svc.setupFees || {};
      return typeof sf[useTier] === 'number' ? sf[useTier] : 0;
    }
    var chosenIds = Object.keys(svcSel).filter(function (k) { return svcSel[k] && k !== 'unsure'; });

    /* WHICH SERVICE THE PANELS ARE DESCRIBING. Derived rather than stored, so
       deselecting the active service cannot leave the tier cards editing something that
       is no longer in the list. That is a real failure and not a theoretical one: the
       active id is set by a click and cleared by a different control entirely, so the
       two WILL disagree unless one of them is computed from the other. */
    /* THE ACTIVE SERVICE FOLLOWS THE FAMILY IN THE STEP BAR. Nicole, 26 Aug 2026:
       "if we are on White Label Services from this step bar picker, the pill buttons need
       to switch to White Label as well. If we're talking about the other categories, for
       example, Talent Solutions, then it needs to switch to Dedicated Resources."
       SO THE FAMILY IS THE OUTER SELECTION AND THE PILL IS THE INNER ONE. Before this,
       the step bar changed which services the DROPDOWN offered whilst the tier panel
       carried on describing a service from another family, so the reader could be reading
       Growth prices under a White Label heading.
       DERIVED, NEVER STORED. activeRaw is only honoured whilst it belongs to the family
       currently shown; otherwise the first chosen service of THIS family wins. A stored
       active id would survive a family change and reintroduce exactly the mismatch. */
    var famChosen = chosenIds.filter(function (id) { return RL_FAM_OF[id] === fam; });
    var activeSvc = (famChosen.indexOf(activeRaw) !== -1) ? activeRaw : (famChosen[0] || '');
    /* The panels read these two, so every existing call site below still says `tier` and
       `commitId` and means the active service's. */
    var tier = cfgFor(activeSvc).tier;
    var commitId = cfgFor(activeSvc).commit;
    /* THE ONLY WRITER. Copying prev rather than mutating it, because React compares the
       object identity and a mutated object re-renders nothing. */
    function patchActive(patch) {
      if (!activeSvc) return;
      setSvcCfg(function (prev) {
        var next = {}; for (var k in prev) { if (Object.prototype.hasOwnProperty.call(prev, k)) next[k] = prev[k]; }
        var cur = (prev && prev[activeSvc]) || {};
        next[activeSvc] = {
          tier: (patch && patch.tier) || cur.tier || RL_DEFAULT_CFG.tier,
          commit: (patch && patch.commit) || cur.commit || RL_DEFAULT_CFG.commit
        };
        return next;
      });
    }

    /* THE BASKET IS THE SUM OF NINE SEPARATE DECISIONS NOW, so it has to be walked with
       each service's own tier and commitment rather than one pair applied to all of
       them. This is the function the sidebar reads. */
    function basketTotals() {
      return chosenIds.reduce(function (acc, id) {
        /* DEDICATED IS PRICED PER PERSON PER DAY, so it has no tier column to read
           and priceFor returns 0 for it by design. Until 26 August it therefore
           contributed NOTHING to the forecast, and a referrer who said "they need
           two designers" was shown a commission of zero against a real opportunity.
           Its own arithmetic is dedMonthly. */
        if (isDed(id)) { acc.monthly += dedMonthly(id); return acc; }
        var c = cfgFor(id);
        acc.monthly += priceFor(id, c.tier, c.commit);
        acc.setup += setupFor(id, c.tier);
        return acc;
      }, { monthly: 0, setup: 0 });
    }
    var monthlyTotal = basketTotals().monthly;
    /* dedicated is no longer in the floor note: it prices now, from its own model,
       so listing it as an exclusion would understate the forecast it contributes to. */
    var unpriced = chosenIds.filter(function (id) {
      return !isDed(id) && isCustomAt(id, cfgFor(id).tier, cfgFor(id).commit);
    });

    /* SUPERSEDED 26 AUGUST 2026. The text below described the old behaviour and is kept
       so the reversal is visible rather than silent:

         "EACH TIER CARD SHOWS THE SUM ACROSS WHAT THEY PICKED, not one service's price.
          The calculator's own AVAILABLE TIERS panel is per service because you are
          buying one. Here the reader has picked a basket on somebody else's behalf, so
          the honest card is what that basket costs at each tier."

       Nicole reversed it: "you can basically select any tier or any commitment option
       for each service, not the whole basket". So the cards are per service again, which
       is what the calculator does, and the basket total moved to basketTotals above.
       The argument that killed the old shape is hers and it is a good one: with one
       tier for the basket there was no way to say that a referral wants Scale on paid
       ads and Start on email, which is the ordinary case rather than the exotic one. */
    function tierTotals(t) {
      if (!activeSvc) return { monthly: 0, setup: 0 };
      return { monthly: priceFor(activeSvc, t, cfgFor(activeSvc).commit), setup: setupFor(activeSvc, t) };
    }
    var monthly = Math.round(monthlyTotal * RL_RATE);

    /* THE COUNT-UP uses requestAnimationFrame rather than a timer, so it stops when the
       tab is backgrounded instead of queueing frames nobody sees, and it lands EXACTLY
       on the target rather than on whatever the last tick produced. A forecast that
       settles a pound out because an animation was interrupted is worse than one that
       does not animate. */
    var shownState = React.useState(0); var shownFig = shownState[0], setShownFig = shownState[1];
    React.useEffect(function () {
      var from = shownFig, to = monthly;
      if (from === to) return undefined;
      var t0 = null, raf = null, DUR = 420;
      var step = function (t) {
        if (t0 === null) t0 = t;
        var k = Math.min(1, (t - t0) / DUR);
        var eased = 1 - Math.pow(1 - k, 3);
        setShownFig(k === 1 ? to : Math.round(from + (to - from) * eased));
        if (k < 1) raf = window.requestAnimationFrame(step);
      };
      raf = window.requestAnimationFrame(step);
      return function () { if (raf) window.cancelAnimationFrame(raf); };
    }, [monthly]);
    var gbp = function (n) { return '\u00a3' + Math.round(n).toLocaleString('en-GB'); };

    var whoState = React.useState('direct'); var referWho = whoState[0], setReferWho = whoState[1];
    /* "How do I refer?" HAD NO DESTINATION ON THIS SURFACE. 26 Aug 2026, Loom 20 at
       20:53 to 21:29. Alexander, on this exact control: "How do I submit a referral?
       how do I know, you see people just aren't going to do that... that looks so
       affiliate-y", and he asks for "three ways to refer, step one, two, three",
       naming an intro email to his own address and creating a WhatsApp group.
       THE ANSWER ALREADY EXISTED AND WAS UNREACHABLE. window.getReferHelpOverlay()
       carries five routes, not three, and two of them are the two he named:
       Submit it here, WhatsApp intro, Email intro, Share your link, Share your
       discount code. It even holds the drafted intro email addressed to
       alexander.onslow@gogorilla.com and the WhatsApp message, both written on
       1 July. The control rendered and did nothing, because this call site passed
       onHelp: null.
       So this is a wiring change rather than a build, and that is the FOURTH time
       today the answer has been that the thing already exists. */
    var helpOvState = React.useState(false); var helpOv = helpOvState[0], setHelpOv = helpOvState[1];
    /* The commissions overlay is the SAME overlay as How do I refer, opened on its
       "Your commission" tab. One payload, two entry points, so the rate table cannot
       differ between the two places a reader can reach it from. */
    var commOvState = React.useState(false); var commOv = commOvState[0], setCommOv = commOvState[1];

    /* ── THE CONFIRM THAT GUARDS THE WIPE, 28 Aug 2026.
       IT ONLY EXISTS WHEN THERE IS SOMETHING TO LOSE. The opener checks chosenIds
       before setting this, so ticking Not sure yet on an empty picker, and unticking it
       afterwards, both go straight through. A prompt that appears when nothing is at
       stake is how people learn to dismiss the one that matters.
       IT IS THE HOUSE CONFIRM, NOT A NEW ONE. .cs-modal is the shell ClientSwitchModal
       and the q1 path-change confirm both use in sections.v2.jsx, down to Cancel on the
       left and a primary that NAMES THE DESTRUCTION rather than saying Continue. Its
       CSS already exists at app.v2.css:5014, so this adds no stylesheet of its own.
       IT IS PORTALLED, WHICH cs-modal ITSELF IS NOT. Both existing call sites render it
       inline, and they can: they are on the calculator. This glimpse is position fixed
       with its own stacking context, so an inline modal paints inside it and under its
       own scroll. That was the 19 August defect on the step-1 offer and it is recorded
       twice already in this file. Same ggPortalHost the other two overlays use.
       THE ESCAPE HANDLER IS A HOOK AND SO IT LIVES OUT HERE, unconditionally. Putting
       it inside wipeConfirm would make the hook COUNT depend on whether the dialog is
       open, which is React error #310 and the same trap the count-up comment records. */
    var wipeAskState = React.useState(false); var wipeAsk = wipeAskState[0], setWipeAsk = wipeAskState[1];
    React.useEffect(function () {
      if (!wipeAsk) return undefined;
      var onKey = function (ev) { if (ev.key === 'Escape') setWipeAsk(false); };
      document.addEventListener('keydown', onKey);
      return function () { document.removeEventListener('keydown', onKey); };
    }, [wipeAsk]);
    function wipeConfirm() {
      if (!wipeAsk) return null;
      var host = (typeof window !== 'undefined' && typeof window.ggPortalHost === 'function') ? window.ggPortalHost() : null;
      if (!host || typeof ReactDOM === 'undefined' || !ReactDOM.createPortal) return null;
      var n = chosenIds.length;
      var close = function () { setWipeAsk(false); };
      return ReactDOM.createPortal(
        e('div', {
          className: 'cs-modal rl__wipeask', role: 'dialog',
          'aria-modal': 'true', 'aria-labelledby': 'rl-wipe-title'
        },
          e('div', { className: 'cs-modal__backdrop', onClick: close }),
          e('div', { className: 'cs-modal__panel' },
            e('h2', { id: 'rl-wipe-title', className: 'cs-modal__title' }, 'Clear your chosen services?'),
            e('p', { className: 'cs-modal__body' },
              'Not sure yet is the answer for when you do not know what they need, so it replaces the rest. Your current selections (',
              e('strong', null, String(n) + (n === 1 ? ' service' : ' services')),
              ') will be cleared.'),
            e('div', { className: 'cs-modal__actions' },
              e('button', { type: 'button', className: 'btn btn--ghost btn--sm', onClick: close }, 'Cancel'),
              e('button', {
                type: 'button', className: 'btn btn--primary btn--sm',
                onClick: function () { setWipeAsk(false); toggleSvc('unsure'); }
              }, 'Clear and tick Not sure yet')))),
        host);
    }
    var WHO_OPTS = (typeof window !== 'undefined' && window.REFER_WHO_OPTIONS) || [];
    /* ⚠️ THIS LINE WAS ABOVE WHO_OPTS FOR ONE DEPLOY AND IT BROKE THE SURFACE.
       `var` hoists the DECLARATION but not the assignment, so WHO_OPTS was undefined
       when this ran and the render threw "Cannot read properties of undefined
       (reading 'filter')". The babel parse passed, because it is valid JavaScript, and
       the suite passed, because a static guard reads text. Only opening the page
       caught it, which is exactly what the guard file written this morning says a
       static guard cannot do. Keep derived values BELOW what they derive from. */
    /* whoOpt WAS DELETED HERE, 26 August 2026, with the grey bonus line that was its only
       reader. Kept as a note rather than left as an unused binding: a variable nobody reads
       is the same rot as a class nobody emits, and the next person to want the bonus figure
       should reach for GGReferForecastRows rather than rebuild the line this fed. */
    var ddState = React.useState(false); var ddOpen = ddState[0], setDdOpen = ddState[1];
    var ddRef = React.useRef(null);
    /* THE DEDICATED DROPDOWN GETS ITS OWN STATE AND ITS OWN OUTSIDE-CLICK, mirroring
       the service picker's above rather than sharing it. Sharing one `open` flag
       between two dropdowns on the same pane means opening either closes the other,
       which is correct, and clicking inside one closes it because the other's ref
       does not contain the target, which is not. Two refs, two flags. */
    var dedOpenState = React.useState(false); var dedOpen = dedOpenState[0], setDedOpen = dedOpenState[1];
    var dedRef = React.useRef(null);
    React.useEffect(function () {
      if (!dedOpen) return undefined;
      var onDoc = function (ev) { if (dedRef.current && !dedRef.current.contains(ev.target)) setDedOpen(false); };
      var onKey = function (ev) { if (ev.key === 'Escape') setDedOpen(false); };
      document.addEventListener('mousedown', onDoc);
      document.addEventListener('keydown', onKey);
      return function () {
        document.removeEventListener('mousedown', onDoc);
        document.removeEventListener('keydown', onKey);
      };
    }, [dedOpen]);
    /* OUTSIDE CLICK AND ESCAPE, both, because a dropdown that only closes on its own
       trigger traps a reader who has moved on. Bound only whilst open, so the page
       carries no listener the rest of the time. */
    React.useEffect(function () {
      if (!ddOpen) return undefined;
      var onDoc = function (ev) { if (ddRef.current && !ddRef.current.contains(ev.target)) setDdOpen(false); };
      var onKey = function (ev) { if (ev.key === 'Escape') setDdOpen(false); };
      document.addEventListener('mousedown', onDoc);
      document.addEventListener('keydown', onKey);
      return function () {
        document.removeEventListener('mousedown', onDoc);
        document.removeEventListener('keydown', onKey);
      };
    }, [ddOpen]);

    /* `rows` AND ITS FILTER WENT WITH THE TABLE, 25 Aug 2026. rows stayed as state
       rather than a constant because the table, the counts and the empty state all read
       from it and because a real reader might yet arrive through the portal's preview
       endpoint. None of those three exist here any more, so the filter had nothing left
       to filter and is gone with them. */

    /* 2026-08-19 (Nicole). THIS USED TO ADD A ROW TO THE LOCAL TABLE AND NOTHING ELSE.
       No call to ggCreateAffiliate, no request, nothing left the browser - so a visitor
       typed a real client's name and email, watched a row appear, and believed they had
       referred somebody. The interface confirmed a success that had not happened, and
       what it collected was a third party's contact details.

       It was built that way on purpose, mirroring the portal, where that row IS backed
       by a real record. The mirror was right and the context was not.

       NOW IT HANDS OFF. The details go into the calculator's refer-mode qualifier and we
       jump to its LAST STEP, which is the one real submit path. That is the standing
       ruling on recv7C6MfaXyJxfZY - route and preselect rather than open a second submit
       path - applied to this form. Validation stays exactly as it was, in the portal's
       order and with the portal's two messages, because that part was never the problem.

       UNTOUCHED BY THE 24 Aug UI REBUILD. That change moved markup only; every line of
       this function is the one that shipped on 19 August. */
    /* THE SUBMIT PATH NOW COMPLETES HERE INSTEAD OF HANDING OFF. 25 Aug 2026,
       FREELANCER 20 at 21:47, ruled by Nicole.

       WHAT IT USED TO DO, kept because the reasoning is still sound for what it was:
       it collected a client's name, company and email and called
       ggEnterReferWithBusiness, which set refer mode, wrote the refer_biz_ qualifiers
       and jumped the visitor to the calculator's last step. That was itself a fix, for
       an earlier version that added a local table row and sent nothing at all, so a
       visitor typed a real client's details, watched a row appear and believed they had
       referred somebody. The interface confirmed a success that had not happened.

       WHY IT MOVED: Alexander asked for the whole thing to finish on this surface.
       WHY IT LOOKS DIFFERENT FROM THE ASK: the only endpoint that returns a link and a
       code is create-affiliate, and it takes the REFERRERS email. See the note on the
       state above for the probe. So the action is "get your referral link", which is
       what actually happens, rather than "submit a referral", which is what we cannot
       yet do.

       WE NEVER FABRICATE A LINK. platform-api.js returns { ok:false, kind } on every
       error shape and this surfaces a real message for each, so a failed call can never
       read as a success. That rule predates this change and is the reason the wrapper
       exists at all. */
    function submit(ev) {
      if (ev && ev.preventDefault) ev.preventDefault();
      var em = fEmail[0].trim();
      if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(em)) { setErr('Enter a valid email address.'); return; }
      /* THE COMPANY IS WHAT THE CODE IS MINTED FROM, so an empty one is not a missing
         nicety, it is a different code. Checked before the request rather than after,
         because the portal accepts the field as optional and would mint FIRSTNAME10
         without ever telling us the company was missing. */
      if (!fCompany[0].trim()) { setErr('Enter your company name. Your referral code is made from it.'); return; }
      setErr(''); setPhase('busy');
      var done = function (res) {
        if (res && res.ok) {
          setLink(String(res.shareUrl || ''));
          setCode(String(res.code || ''));
          /* The tri-state when the portal sends one, and the derived boolean as the
             fallback, so an older deploy of the route still lands on 'sent' or ''. */
          setDashEmail(res.dashboardEmail || (res.dashboardEmailSent ? 'sent' : ''));
          setPhase('done');
          /* THE SAME SHAPE THE GROW CHECKOUT WRITES, field for field, because three readers
             in sections.v2.jsx destructure it and a fourth shape would break them quietly.
             sections.v2.jsx:13581 writes { shareUrl, code, email, isNew, quoteId }. */
          try {
            onRegistered({
              shareUrl: String(res.shareUrl || ''),
              code: String(res.code || ''),
              email: fEmail[0].trim(),
              isNew: !!res.isNew,
              quoteId: res.quoteId || null
            });
          } catch (e) {}
          return;
        }
        var kind = (res && res.kind) || 'error';
        var msg = 'Something went wrong. Please try again.';
        if (kind === 'validation') { msg = 'Please enter a valid email address.'; }
        else if (kind === 'rate_limit') { msg = 'Too many attempts. Please try again in an hour.'; }
        else if (kind === 'not_enabled') { msg = 'Referral links are not available right now. Please try again later.'; }
        setErr(msg); setPhase('idle');
      };
      /* ⚠️ MINT THE CONTINUITY NONCE FIRST, AND THIS LINE IS THE WHOLE REASON THE SIGN-IN
         BUTTON BELOW WAS A DEAD END FOR SEVEN DAYS. 26 Aug 2026.
         ggReferralSignIn reads gg.refcontinuity.v1 and returns null WITHOUT MAKING A
         REQUEST if it is absent. That key was minted in exactly one place, the calculator's
         refer-mode flow at sections.v2.jsx:13457, and this file had zero calls to
         ggMintContinuity. So the rail minted a link, showed a Sign in to your portal button,
         and that button could never do an instant sign-in no matter what the portal did.
         Measured before the fix rather than reasoned: nonce null, helper returned null, and
         performance.getEntriesByType('resource') showed ZERO requests to referrals/signin.
         ONLY THE HASH TRAVELS. ggMintContinuity keeps the RAW nonce in sessionStorage and
         returns the SHA-256 of it; the raw value is POSTed only on the sign-in click. Same
         shape as the booking path, deliberately.
         BEST EFFORT, NEVER BLOCKING. If SubtleCrypto is missing, which is any non-secure
         context, the mint returns '' and everything below still works. It fails closed to
         the magic link, which is the behaviour the portal already supports. */
      try {
        if (typeof window.ggCreateAffiliate !== 'function') { done({ ok: false, kind: 'error' }); return; }
        var _mint;
        try {
          _mint = (typeof window.ggMintContinuity === 'function')
            ? Promise.resolve(window.ggMintContinuity('gg.refcontinuity.v1')).catch(function () { return ''; })
            : Promise.resolve('');
        } catch (e0) { _mint = Promise.resolve(''); }
        _mint.then(function (hash) {
          return window.ggCreateAffiliate(em, typeof hash === 'string' ? hash : '', {
            firstName: fFirst[0], lastName: fLast[0],
            company: fCompany[0], website: fWebsite[0]
          });
        }).then(done, function () { done({ ok: false, kind: 'error' }); });
      } catch (e) { done({ ok: false, kind: 'error' }); }
    }

    function copyLink() {
      try {
        navigator.clipboard.writeText(link);
        setCopied(true);
        setTimeout(function () { setCopied(false); }, 1800);
      } catch (e) {}
    }

    /* ⚠️ submitBusiness AND ggSubmitReferralIntent WERE REMOVED HERE ON 28 AUGUST, and this
       comment is the whole record of them. They posted a business name to the portal's
       anonymous intent route from this panel. The route is live and correct and we proved it
       end to end; the panel was the wrong home for it. See the note on the panel 02 section
       below for why, and `git log -S submitBusiness` for the code.
       THIS FILE'S OWN RULE APPLIES TO WHAT WAS DELETED WITH THEM. createStripeCheckout sat
       defined, exported and called from nowhere for weeks and convinced two lanes a feature
       existed. ggSubmitReferralIntent went out of platform-api.js in the same commit rather
       than being left as a global nobody calls. */
    /* Their field shape, SubmitReferralForm.tsx: a 13px semibold label over a
       .gg-field input, with "(optional)" carried as a lighter span inside the label
       rather than as separate text beside it. */
    /* 27 Aug 2026: `required` added, and it is the fifth argument because `optional`
       already occupied the fourth slot's meaning. The two are not opposites here:
       first and last name are neither required NOR marked optional, exactly as the
       Grow checkout form has them. */
    function field(label, st, type, ph, optional, required) {
      return e('label', { className: 'rl__field' },
        e('span', { className: 'rl__flabel' }, label,
          optional ? e('span', { className: 'rl__fopt' }, ' (optional)') : null),
        e('input', {
          className: 'rl__input', type: type || 'text',
          value: st[0], placeholder: ph || '',
          autoComplete: 'off', required: !!required,
          onChange: function (ev) { st[1](ev.target.value); }
        }));
    }

    /* THE FOUR RAIL CELLS. deriveRailStages + deriveCommissionCell in
       lib/referrals/value-rail.ts, evaluated at zero: three clickable stages and one
       metal-framed money cell that is deliberately NOT a filter, because there is no
       "earned" column on the table to filter by. */
    /* moneyCell WAS DELETED HERE, 25 Aug 2026. It was the static zero cell from the
       old pipeline rail. The forecast panel now builds its own cell with the live
       figure, so this was a defined and uncalled helper, which this file's note about
       PanelIcon calls the next reader's false lead. */

    /* THIS COMMENT LIVES ABOVE THE RETURN ON PURPOSE. It sat BETWEEN `return` and the
       expression for one deploy, and a block comment containing newlines counts as a line
       terminator for ASI, so `return;` fired and the whole surface rendered as nothing.
       The suite compiled it happily, because it is valid JavaScript that returns undefined,
       and there was no console error either. Only reading the DOM caught it. */
    /* ── ONE PANEL SEQUENCE, RENDERED BY BOTH TABS ──────────────────────────
       Nicole, 27 August 2026: "we should just have the same form or your referral
       link panel in both these tabs. Then, once they've already filled out one of
       them, when they go to the other tab, it should already display the link and
       code that's been generated, and step 2 needs to be unlocked."

       IT IS ONE ACCOUNT, WHICH IS WHY THIS WORKS AT ALL. `accounts.referral_code`
       is UNIQUE per account and is not typed by business or candidate, so
       registering on either tab registers for both. The registered state carries
       across for free because phase, link and code are component state rather than
       per-tab state, so nothing had to be lifted.

       ⚠️ THE LINK DOES NOT REFER A CANDIDATE, AND THE COPY MUST NOT SAY IT DOES.
       Checked at the portal rather than assumed: the candidates route inserts into
       candidate_referrals (id, referrer_account_id, full_name, email, discipline)
       and NO code appears anywhere in it. A candidate is attributed by ACCOUNT ID,
       which needs a signed-in partner. The ?ref= link is a business mechanism, it
       applies the 10% discount to a quote and attributes a sale, and a candidate is
       not buying anything. So the panels are identical in STRUCTURE and only the
       sentences describing what the link does differ by tab.

       NO RATE ON THE CANDIDATE TAB until the cap exists and FCA sign-off clears.
       That blocker is recorded in the portal's own candidates route header. */
    /* ══ THE PORTAL SIGN-IN CONTROL, AND IT MOVED RATHER THAN MULTIPLIED ══════
       2026-08-27, Nicole: panel 02 must not unlock before sign-in, because there is
       nothing behind it that a signed-out reader can do. So the wall is permanent and
       the way through the wall belongs ON the wall.
       IT IS THE SAME CONTROL, MOVED OUT OF PANEL 01, not a second copy. This file's
       own rule three lines below the panel 02 heading is that two controls doing one
       job is the same fault as two padlocks on one panel, and a Sign in button under
       the link AND on the cover beside it would be exactly that.
       PUTTING IT BACK IN PANEL 01 IS ONE CALL SITE. Nothing else changed. */
    /* ══ THE REGISTRATION OVERLAY ═══════════════════════════════════════════════════════
       ⚠️ NOTHING HERE IS NEW. Nicole's instruction on 28 August was "not to invent anything.
       If there's any component that we can reuse, please go with that. Check the repo first."
       So, in order:
         THE SHELL is bdwn-modal, the same classes "See full breakdown" uses. Backdrop with
         backdrop-filter blur(6px), white panel, close button, title, subtitle, actions. It is
         defined at app.v2.css:8382 and it already has its own mobile stacking rule at 8712.
         No new CSS was written for this overlay.
         THE FORM is the same field() output that stood in panel 01, moved rather than rebuilt.
         Same fields, same validation, same submit handler.
         THE SUCCESS STATE is window.ReferralShareLinks, the estate's own block for "your link
         is ready": the link, a copy button, and WhatsApp, LinkedIn, X, email and native share.
         It was already used by the Grow checkout at sections.v2.jsx and is now published on
         window for the same reason GMOverlayModal and tiersFor are, because this file is a
         separate script and cannot import.
       ⚠️ IT PORTALS OUT, and that is not optional. bdwn-modal is position: fixed with
       z-index 9999, and this whole surface lives inside .gg-sos__glimpse, which is itself
       position: fixed with z-index 39 and its own stacking context. Rendered inline the modal
       would paint INSIDE the glimpse and under its scroll. window.ggPortalHost() is the escape
       the estate already uses for exactly this, and it carries .gg-scope so the styles apply.
       IT DEGRADES RATHER THAN THROWS. If ReactDOM or the portal host is missing the overlay
       simply does not open, which is the state the panel was in before it existed. */
    function registerOverlay() {
      if (!regOv) return null;
      var host = null;
      try { host = (typeof window.ggPortalHost === 'function') ? window.ggPortalHost() : null; } catch (e) { host = null; }
      if (!host || typeof ReactDOM === 'undefined' || !ReactDOM.createPortal) return null;
      var close = function () { setRegOv(false); };
      var done = (phase === 'done');
      return ReactDOM.createPortal(
        e('div', { className: 'bdwn-modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'rl-reg-title' },
          e('div', { className: 'bdwn-modal__backdrop', onClick: close }),
          e('div', { className: 'bdwn-modal__panel', onClick: function (ev) { ev.stopPropagation(); } },
            e('button', { type: 'button', className: 'bdwn-modal__close', onClick: close, 'aria-label': 'Close' },
              e('svg', { viewBox: '0 0 16 16', width: 14, height: 14, fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' },
                e('line', { x1: 3, y1: 3, x2: 13, y2: 13 }),
                e('line', { x1: 13, y1: 3, x2: 3, y2: 13 }))),
            e('h2', { id: 'rl-reg-title', className: 'bdwn-modal__title' },
              done ? 'Your referral link is ready' : 'Get your referral link'),
            e('p', { className: 'bdwn-modal__subtitle' },
              done
                ? 'It carries the 10% discount and tracks every referral for you. Share it however you like.'
                : 'One step. It carries the 10% discount, tracks every referral for you, and covers businesses and people alike.'),
            e('div', { className: 'bdwn-modal__body' },
              done
                ? (window.ReferralShareLinks
                    ? e(React.Fragment, null,
                        e(window.ReferralShareLinks, { link: link, email: fEmail[0].trim(), code: code, isNew: false }),
                        /* ⚠️ THE CODE IS STATED SEPARATELY BECAUSE THE SHARED BLOCK DOES NOT
                           SHOW IT. Nicole, 28 August: "the discount code should also be clear
                           on the confirmation message. It could be below the link."
                           ReferralShareLinks takes a `code` prop and its "existing link"
                           branch renders the LINK and the share targets without ever printing
                           the code, which we saw on the live page rather than assumed.
                           IT IS ADDED HERE RATHER THAN THERE ON PURPOSE. That component is
                           shared with the Grow checkout, so changing it to print a code would
                           change a surface this lane was not asked to touch. One line beside
                           it costs nothing and keeps the blast radius at this overlay.
                           THE CODE MATTERS ON ITS OWN. Somebody reads it down a phone or types
                           it into the discount box, which is a different act from clicking a
                           link, and it is the whole reason the format is BUSINESSNAME10. */
                        code ? e('p', { className: 'rl__fine rl__ovcode' },
                          'Your discount code is ', e('strong', null, code),
                          '. They can type it at checkout instead of using the link.') : null)
                    /* If the shared block has not loaded, the link is still the thing the reader
                       came for, so it is shown plainly rather than not at all. */
                    : e('p', { className: 'rl__fine' }, 'Your code is ' + code + '. Your link is ' + link + '.'))
                : e(React.Fragment, null,
    /* ⚠️ THE FORM'S OWN LEAD SENTENCE WAS DROPPED HERE. It said the same thing as the
       overlay subtitle three lines above, word for word, because it was written when this
       paragraph WAS the only introduction and the form sat in a panel with no title of its
       own. Two sentences saying one thing is the fault this file names about controls, and
       it applies to copy: the mode note above panel 02 went for the same reason today. */
    e('form', { className: 'rl__form', onSubmit: submit },
      /* NAME AND EMAIL FIRST, THEN THE BUSINESS. Email stays the only
         required field, because it is the only one the portal requires
         and because a referrer who will not give a company should still
         get a link rather than being stopped by a form.
         THE TWO NAME FIELDS SHARE A ROW so the form gains two lines
         rather than four. Nicole's standing complaint about this pane is
         vertical space. */
      /* ⚠️ NO PLACEHOLDER ON A NAME OR A COMPANY. Nicole, 27 Aug 2026:
         "I don't think these placeholders are good because they are real
         people." They were Alexander, Onslow and Telfer Digital, which is
         a real person and a real company used as filler on a public form.
         MATCHED TO THE GROW CHECKOUT FORM, which she named as the pattern
         and which this lane should have copied in the first place. Read
         from sections.v2.jsx: first name, last name and company are all
         `required` with NO placeholder, email carries you@company.com and
         website carries https://. Only the two fields whose FORMAT is
         ambiguous get an example. A name needs no example. */
      e('div', { className: 'rl__fpair' },
        field('First name', fFirst, 'text', '', true),
        field('Last name', fLast, 'text', '', true)),
      field('Your email', fEmail, 'email', 'you@company.com', false, true),
      /* COMPANY IS REQUIRED, and Nicole gave the reason: "that's where
         we're gonna generate the code and link". Without it the code falls
         back to the email local part and the whole business-name format
         Alexander asked for at Loom 20 @22:21-22:40 silently does not
         happen. The Grow checkout form requires it too. */
      field('Company', fCompany, 'text', '', false, true),
      field('Website', fWebsite, 'text', 'https://', true),
      err ? e('p', { className: 'rl__err', role: 'alert' }, err) : null,
      e('div', { className: 'rl__frow' },
        e('button', { type: 'submit', className: 'btn btn--primary btn--sm', disabled: phase === 'busy' },
          phase === 'busy' ? 'Getting your link...' : 'Get your referral link'))))))),
        host);
    }

    function portalSignIn() {
      return (
        /* SIGN IN TO YOUR PORTAL, THE CANONICAL CONTROL. Nicole, 25 Aug: it
           was an underlined anchor and the real one already exists at
           sections.v2.jsx:4530. btn--primary so it is not underlined, and
           btn__arrow--diag, whose own CSS comment says it nudges on hover,
           because it opens a new tab. ggReferralSignIn exchanges the email
           and code for a session URL, so this is an instant sign-in rather
           than a link to a login page. */
        /* ⚠️ THE FALLBACK WAS portal.gogorilla.com/referrals AND THAT IS A 404.
           26 Aug 2026. It sat in THREE places here, the href and both branches,
           and it was the destination for every referrer whose instant sign-in did
           not resolve, which until today was all of them. Confirmed 404 by curl
           AND in a real browser, so it was not a curl artefact.
           /partners/referrals IS THE RIGHT PATH, not /login, and the difference
           matters for exactly one person: somebody who already has a session lands
           on their referrals page instead of a login screen. Everyone else is
           redirected to /login by the portal's own middleware, which is correct,
           because the create-affiliate email carries the link AND the code, so
           there is something waiting for them there. The portal confirmed that on
           the 26th; before that answer this fix could not be made safely.
           DO NOT SUBSTITUTE buildPortalSignInUrl. It returns '' without BOTH an
           email and a quote id, and this rail has no quote id. Verified live. */
        /* rl__push was margin-top:auto, which pushed this to the bottom of a flex
           COLUMN. On the cover it sits in a block beside a lock, so auto resolves to
           zero and only the 20px padding survived as a gap under two lines of copy.
           rl__coverbtn is the same control with the spacing the new home needs. */
        e('div', { className: 'rl__coverbtn' },
          checkEmail
            ? e('p', { className: 'rl__fine', role: 'status' },
                'Check your email. We have sent you a link that signs you straight in.')
            : e('a', {
            /* ⚠️ btn--amber IS THE HOUSE CONTROL FOR THIS EXACT JOB, not a colour picked
               to sit on the metal. sections.v2.jsx:14900 uses it for the one other button
               on this estate that sends somebody to their portal, "Go to your portal", and
               it is defined at app.v2.css:9753 rather than here. Reused, not redrawn. */
            className: 'btn btn--amber btn--sm rl__signin',
            href: 'https://portal.gogorilla.com/partners/referrals',
            target: '_blank', rel: 'noopener noreferrer',
            onClick: function (ev) {
              if (typeof window.ggReferralSignIn !== 'function') return;
              if (ev && ev.preventDefault) ev.preventDefault();
              var FALLBACK = 'https://portal.gogorilla.com/partners/referrals';
              var w = null;
              try { w = window.open('', '_blank'); if (w) { try { w.opener = null; } catch (e) {} } } catch (e) { w = null; }
              window.ggReferralSignIn(fEmail[0].trim(), code).then(function (r) {
                /* THREE ANSWERS, NOT TWO. A session url, a magic link already
                   sent, or nothing. The middle branch is new: the portal has
                   emailed them, so closing the blank tab and saying so beats
                   opening a login page they do not need. Mirrors the canonical
                   control at sections.v2.jsx:4498 rather than inventing a
                   second behaviour for the same three outcomes. */
                if (r && r.url) {
                  if (w) { try { w.location = r.url; return; } catch (e) {} }
                  try { window.open(r.url, '_blank', 'noopener'); } catch (e) {}
                  return;
                }
                if (r && r.emailSent) {
                  if (w) { try { w.close(); } catch (e) {} }
                  try { setCheckEmail(true); } catch (e) {}
                  return;
                }
                if (w) { try { w.location = FALLBACK; return; } catch (e) {} }
                try { window.open(FALLBACK, '_blank', 'noopener'); } catch (e) {}
              }, function () {
                if (w) { try { w.location = FALLBACK; } catch (e) {} }
              });
            }
          }, 'Sign in to your portal',
            e('span', { className: 'btn__arrow btn__arrow--diag', 'aria-hidden': 'true' }, '\u2197'))));
    }

    function referPanes(kind) {
      var isCand = kind === 'candidate';
      return (
          e('div', { className: 'rl__panes' },

            /* ── LEFT: YOUR REFERRAL LINK. Registration lives here now. ───────────── */
            e('div', { className: 'gg-pane-1 rl__p6 rl__col' },
              /* NUMBERED, because the two panels are a sequence and Nicole asked for the
                 same 01 / 02 chips the "What happens on the call" list already uses.
                 rl__stepn is that component, reused rather than redrawn. It does the work
                 the lock alone could not: the padlock says you cannot, the number says
                 what to do first. */
              e('div', { className: 'rl__shead' },
                e('p', { className: 'rl__eyebrow rl__eyebrow--step' },
                  e('span', { className: 'rl__stepn rl__stepn--sm' }, '01'), 'Your referral link'),
                /* SHARE VIA ONLY EXISTS ONCE THERE IS SOMETHING TO SHARE, Nicole 25 Aug.
                   Beside an empty state it offered an action that cannot be taken. */
                phase === 'done'
                  ? e('span', { className: 'rl__sharebtn', 'aria-hidden': 'true' },
                      'Share via…', e(RlChevron, { className: 'rl__sharechev' }))
                  : null),

              phase === 'done'
                ? e(React.Fragment, null,
                    /* The link is the SAME object on both tabs, one account and one code.
                       What it DOES differs, so only the sentence does. */
                    e('p', { className: 'rl__note' }, isCand
                      ? 'Your account is ready. Candidates you send us are matched to it automatically.'
                      : 'Your link is ready and the 10% discount is built in.'),
                    code ? e('p', { className: 'rl__fine' }, 'Your code is ' + code + '.') : null,
                    e('div', { className: 'rl__frow' },
                      e('input', { className: 'rl__input', type: 'text', readOnly: true, value: link,
                        onFocus: function (ev) { ev.target.select(); } }),
                      e('button', { type: 'button', className: 'btn btn--primary btn--sm', onClick: copyLink },
                        copied ? 'Copied' : 'Copy link')),
                    /* THREE OUTCOMES, THREE SENTENCES, and none of them claims an email
                       that did not go.
                         sent     an email went out just now.
                         skipped  they were already invited, so the link is in an older
                                  email rather than a new one. Saying "we have emailed it"
                                  would be the original defect wearing the true flag.
                         failed   the send was recorded and alerted on their side. We do not
                                  mention email at all, because the link on screen is what
                                  the reader needs and an apology for a background job is
                                  noise they cannot act on.
                       ANYTHING ELSE STAYS SILENT, including an older route that returns no
                       tri-state at all. */
                    /* ⚠️ THE PANEL LOST ITS BOTTOM CAP WHEN THE BUTTON MOVED OUT, and the hole
                       was 246px of an 453px panel. Measured live at v113. The two panels are an
                       equal-height pair, so panel 01 is as tall as the skeleton beside it whether
                       or not it has the content to fill that. The sign-in button used to sit at the
                       very bottom on rl__push and the air collected ABOVE it, which is a card. With
                       the button gone the air collected BELOW everything, which is an unfinished
                       panel, and it is the same fault Nicole named on panel 02 on the 27th.
                       THE NOTE IS THE CAP NOW. The wrapper always renders so the anchor does not
                       come and go with the tri-state.
                       ⚠️ UNVERIFIED EDGE: when the route returns no tri-state at all the wrapper is
                       empty and the air is back at the bottom. That is the state this panel was in
                       before today rather than a new fault, and no live account reproduces it. */
                    e('div', { className: 'rl__push' },
                      dashEmail === 'sent'
                        ? e('p', { className: 'rl__fine' }, 'We have also emailed it to you, so it is there if you close this page.')
                        : dashEmail === 'skipped'
                        ? e('p', { className: 'rl__fine' }, 'Your link is the same one as before, so it is already in your inbox from when you first signed up.')
                        : null))


                : e(React.Fragment, null,
                    /* ⚠️ THE FORM MOVED INTO AN OVERLAY ON 28 AUGUST 2026, Nicole, and this
                       panel is a prompt and a button now.
                       WHY. The five-field form met a visitor before they had seen what they
                       would earn, and it made panel 01 a tall form in one state and four short
                       lines in the other. Behind a button the page leads with the proposition
                       and the forecast and asks for details once somebody is convinced, and the
                       panel is the same short shape either way.
                       ⚠️ IT IS ONE CLICK MORE AND THAT IS THE REAL COST. The copy no longer
                       claims "one step", because it is now two. That is the trade she took.
                       NOTHING WAS REDRAWN. The overlay is the bdwn-modal shell that "See full
                       breakdown" already uses, the form inside it is this same field() output
                       moved rather than rebuilt, and the success state is the estate's own
                       ReferralShareLinks. */
                    e('p', { className: 'rl__fine rl__lead' },
                      'It carries the 10% discount, tracks every referral for you, and covers businesses and people alike.'),
                    /* ⚠️ THE GLYPH IS Icon('referrals'), THE PORTAL'S OWN Share2, ALREADY IN
                       THIS FILE. Nicole, 28 August: "maybe we add the icon thing again that we
                       used before, and then we add the Get your referral link button below it
                       and also centered."
                       IT IS THE SAME MARK THE RAIL DRAWS FOR REFERRALS, four items up the left
                       edge of this very screen, so the panel and the rail item it belongs to
                       speak the same language. Nothing was drawn for this.
                       WHY IT IS HERE AT ALL. The pair is stretched so both panels are as tall
                       as panel 02, and panel 01 had 118px of nothing under its button. This is
                       the honest way to close that: give the short panel something true to
                       hold, rather than shrink the skeleton past the point where it still
                       reads as a list. */
                    e('div', { className: 'rl__cta' },
                      e('span', { className: 'rl__ctaicon', 'aria-hidden': 'true' }, Icon('referrals')),
                      e('button', {
                        type: 'button', className: 'btn btn--primary btn--sm',
                        'aria-haspopup': 'dialog',
                        onClick: function () { setRegOv(true); }
                      }, 'Get your referral link')))),

            /* ── RIGHT: SUBMIT A REFERRAL. Locked until a link exists. ────────────── */
            /* ⚠️ ALWAYS LOCKED, AND THE CLASS NO LONGER ASKS. Nicole, 27 Aug 2026:
               "Are they gonna be able to submit referrals in the pre-login state? If
               not, then it shouldn't unlock in the first place, and we should be clear
               about that on the copy." They are not, on either tab and for two
               different reasons recorded on the cover below, so the wall stays up. */
            /* ⚠️ ALWAYS LOCKED, ON BOTH TABS. Nicole ruled this on 27 August, it was reversed
               the same afternoon on the strength of the portal's anonymous business route, and
               she restored it on 28 August. The route is real and we verified it; the reason to
               stop using it is a product one rather than a technical one.
               THE PANEL WAS WRITE-ONLY. It took a business name and could then show the reader
               nothing: no list, no status, no history, and a confirmation line that vanished on
               reload. They still had to open the portal to learn whether anything had happened,
               so it was a second front door to a room they had to enter anyway.
               IT ALSO SPLIT THE TWO TABS, which is the thing she had asked us to avoid on the
               27th, and it made panel 01 the short one in a pair, which is where its 371px of
               dead space came from. Reverting removed that defect rather than fixing it. */
            e('section', { className: 'gg-pane-1 rl__p6 rl__col rl__locked-pane' },
              e('div', { className: 'rl__shead' },
                e('p', { className: 'rl__eyebrow rl__eyebrow--step' },
                  e('span', { className: 'rl__stepn rl__stepn--sm' }, '02'),
                  'Submit a referral'),
                /* ⚠️ THE MODE SWITCH SITS IN THE HEADER ROW, top right, from 28 August 2026.
                   Nicole: "maybe the Business and Person tabs should be in the upper right
                   corner instead of below the panel name."
                   IT COSTS THE PANEL NOTHING THERE. Below the eyebrow it added its own line
                   plus a margin to the panel's height, which is the height we were trying to
                   bring down. Beside the eyebrow it fits in a row that already existed and was
                   already carrying a control on panel 01, the Share via chip, so the two
                   panels now have the same header shape as each other.
                   rl__shead ALREADY WRAPS, added earlier today when the eyebrow was being
                   crushed at 390px, so this needed no new layout rule. */
                e('div', { className: 'rl__modes', role: 'group', 'aria-label': 'What kind of referral' },
                  [['business', 'A business'], ['candidate', 'A person']].map(function (m) {
                    var on = (mode === m[0]);
                    return e('button', {
                      key: m[0], type: 'button', 'aria-pressed': on ? 'true' : 'false',
                      className: 'rl__mode' + (on ? ' rl__mode--on' : ''),
                      onClick: function () { setMode(m[0]); }
                    }, m[1]);
                  })),
                /* ⚠️ THERE WAS A GREY SENTENCE HERE AND IT SAID THE SAME THING TWICE.
                   Nicole, 28 Aug: "we also have the metal frame thing on top of the gray
                   blocks, so why don't we just add the copy there? Why do we need to have a
                   gray text below the tabs?"
                   She is right, and it is this file's own rule about two controls doing one
                   job applied to copy. The cover states the condition for the live mode, in
                   the place this surface uses for stating conditions. A second sentence above
                   it, in the same grey, restated it and pushed the wall further down. */
                /* NO LOCK IN THE CORNER. Nicole, 25 Aug: the cover already carries one,
                   and two padlocks on one panel is the same fault as two controls doing
                   one job. The 19 August lesson on this surface was that removing a wall
                   means removing every sign that advertises it; the same counting applies
                   when the wall stays, because a second sign does not make it more locked,
                   it just makes the panel noisier. */
                null),

              /* The mode switch moved up into the header row on 28 August, see rl__shead above. */
              /* THE PANEL HAS ONE STATE NOW AND THAT IS THE POINT. It used to unlock the
                 moment a link existed, and what appeared was a paragraph explaining that
                 the actual submitting happens somewhere else. That is a door that opens
                 onto a wall. The cover carries the sentence instead, so the reader is
                 told the condition once, in the place that states conditions. */
              e('div', { className: 'rl__lockwrap2' },
                    /* ⚠️ IT IS A LIST NOW, NOT A FORM, AND THAT IS THE POINT. Flagged in the
                       design pass of 28 August and it is the sharpest thing in it: a skeleton
                       of a FORM implies the form will unlock in place, and it will not. It
                       unlocks in the portal, somewhere else entirely. The old shape was two
                       label bars, two fields, a wide field and a SUBMIT BUTTON, which is a
                       picture of something that can never appear on this surface.
                       WHAT IT SHOWS INSTEAD IS THE LIST A REFERRAL LANDS IN, which is what
                       the reader will see once they sign in, so the wall previews the room
                       behind it rather than a door that is not there.
                       NICOLE'S 27 AUGUST NOTE STILL HOLDS: it has to fill the panel, because
                       a cover floating in white space reads as unfinished rather than locked.
                       These are the same rows the old skeleton already carried at its foot.
                       Only the form half above them has gone. */
                    e('div', { className: 'rl__skel', 'aria-hidden': 'true' },
                      e('span', { className: 'rl__skelbar rl__skelbar--sm' }),
                      e('div', { className: 'rl__skelrow' },
                        e('span', { className: 'rl__skelfield' }),
                        e('span', { className: 'rl__skelfield' })),
                      e('span', { className: 'rl__skelfield rl__skelfield--wide' }),
                      /* ⚠️ THE SAME BLOCKS IN BOTH STATES, from 28 August 2026. It used to
                         shed a row once a link existed, back when panel 02 was the tall one
                         and shedding was what kept the pair from stretching panel 01 full of
                         air. Nicole: "on the submitted state, I think we should still have the
                         gray blocks on the second panel behind the metal frame."
                         IT IS AFFORDABLE NOW BECAUSE THE OTHER PANEL GREW. Panel 01 carries a
                         link, a code and a share chip once registered, so it is no longer the
                         short half and there is nothing for panel 02 to shrink towards.
                         AND THE COVER NEEDS SOMETHING BEHIND IT. A metal frame floating on
                         white reads as an unfinished panel; the same cover over a list reads
                         as a wall in front of something. That was the whole reason the blocks
                         exist and it is as true after registering as before. */
                      e('div', { className: 'rl__skelrow' },
                        e('span', { className: 'rl__skelfield' }),
                        e('span', { className: 'rl__skelfield' }))),
                    /* gg-pane-2 IS THE METAL FRAME. PROMPT_CARD_STYLE only supplies the
                       concrete wash; the frame itself is gg-pane-2's ::after, eight
                       metal-frame webps on a 18px slice. Nicole spotted it missing, and the
                       tell is the Commission cell directly below, which is a gg-pane-2 and
                       has the border this card did not. So the class does the work and the
                       inline style goes: two ways of dressing one card is how they drift. */
                    /* ⚠️ THE CONTROL IS A SIBLING OF THE COPY, NOT A CHILD OF IT, and that is
                       the whole layout. Nicole, 27 Aug 2026: "we can move it on the same line as
                       the text so we have less vertical space. Move it to the right-hand side."
                       Stacked under two lines it added 42px to the cover and dragged the lock
                       down with it, which needed a top-alignment modifier to correct. On the
                       right it adds nothing: the cover is as tall as its tallest child and the
                       copy was already taller than a button. So the modifier is GONE rather than
                       kept, because a rule that exists to fix a layout we no longer have is the
                       kind of thing the next reader spends ten minutes on. */
                    e('div', { className: 'rl__cover gg-pane-2' },
                      e('span', { className: 'rl__coverlock', 'aria-hidden': 'true' },
                        e('svg', { viewBox: '0 0 24 24', width: 15, height: 15, fill: 'none', stroke: 'currentColor',
                                   strokeWidth: 2.1, strokeLinecap: 'round', strokeLinejoin: 'round' },
                          e('rect', { x: 5, y: 11, width: 14, height: 9, rx: 2 }),
                          e('path', { d: 'M8 11V8a4 4 0 0 1 8 0v3' }))),
                      /* ⚠️ TWO CONDITIONS, TWO SENTENCES, AND NEITHER OF THEM IS A PROMISE.
                         BEFORE THE LINK EXISTS the condition is step one, immediately to the
                         left, and the cover carries NO control. Nicole's call on 25 Aug and it
                         still holds: a button here would either duplicate the one they can
                         already see or scroll them sideways to reach it.
                         AFTER THE LINK EXISTS the condition is a session, so the control that
                         gets them one moves onto the cover. That is the only place the reader
                         is actually stopped.
                         THE TWO TABS ARE STOPPED FOR DIFFERENT REASONS AND THE COPY SAYS SO.
                         CANDIDATE: the route exists, POST /api/v1/me/referrals/candidates, but
                         it attributes by referrer_account_id, so it needs a signed-in partner.
                         BUSINESS: naming one has no anonymous route at all. The portal's note
                         of 26 August says it is next up, so this does NOT say the portal can
                         do it today, because we have not seen that and it would be a promise
                         made on their behalf. It says the link is already doing the work,
                         which is true and is the thing the reader has in hand. */
                      e('div', { className: 'rl__covercopy' },
                        e('p', { className: 'rl__covertitle' }, phase === 'done'
                          ? (isCand ? 'Sign in to send a name' : 'Sign in to see your referrals')
                          : (isCand ? 'Account required' : 'Referral link required')),
                        e('p', { className: 'rl__covertext' }, phase === 'done'
                          ? (isCand
                              ? 'Candidates are matched to your account, so this happens in your portal.'
                              : 'Your link is already doing this. Anyone who uses it is matched to you, and naming a business directly is coming.')
                          : (isCand
                              ? 'Candidates are matched to your account, so set it up first.'
                              : 'Submissions attach to your link, so create it first.')),
                        null),
                      phase === 'done' ? portalSignIn() : null))))
      );
    }

    return e('div', { className: 'rl' },
      registerOverlay(),
      wipeConfirm(),
      /* THE OVERLAY IS THE CALCULATOR'S OWN GMOverlayModal, not a second one.
         window.GMOverlayModal is published at sections.v2.jsx:4936 and its own comment
         records that dedicated-flow.jsx, another separate script, already reuses it, so
         this is the third caller rather than the first. Verified as a real global on the
         live page before relying on it, because it carries no explicit export.
         IT PORTALS TO THE .gg-scope ROOT, which is what lets it escape this glimpse.
         The glimpse is position: fixed with its own stacking context, so an inline modal
         would paint inside it and under its own scroll. That was the exact defect on the
         step-1 offer overlay on 19 August, and the fix there was this same component.
         IT DEGRADES RATHER THAN THROWS. If either global is missing the control simply
         does nothing again, which is the state it was already in, rather than taking the
         surface down. */
      (helpOv && window.GMOverlayModal && typeof window.getReferHelpOverlay === 'function')
        ? e(window.GMOverlayModal, {
            data: window.getReferHelpOverlay(),
            onClose: function () { setHelpOv(false); }
          })
        : null,
      (commOv && window.GMOverlayModal && typeof window.getReferHelpOverlay === 'function')
        ? e(window.GMOverlayModal, {
            data: window.getReferHelpOverlay(),
            initialTabLabel: 'Your commission',
            onClose: function () { setCommOv(false); }
          })
        : null,
      /* ── BACK TO GROW, ruled by Nicole 25 August 2026 ────────────────────────
         The two Switch-back-to-my-plan toggles are retired in the same change, so
         this is now the ONLY way out of refer mode. It has to do both halves:
         clear refer mode AND close the view. Closing alone would leave the reader
         on the calculator with the referring banner up and no control to dismiss
         it, which is worse than the toggle it replaces.
         BOTH HALVES ARE GUARDED, because doing one is the natural half-fix.
         It sits above the h1 rather than inside .rl__hrow so it reads as a trail
         out of the view rather than as a subtitle of it. */
      /* ONE ROW, per CrumbBar: a nav labelled Breadcrumb holding the crumbs and their
         separator. Without the wrapper the three siblings stack vertically, because the
         parent is a plain column. */
      e('nav', { className: 'rl__crumbs', 'aria-label': 'Breadcrumb' },
        e('button', {
          type: 'button', className: 'rl__backtrail rl__crumblink',
          onClick: function () {
            try { if (typeof window.ggExitReferMode === 'function') window.ggExitReferMode(); } catch (err) {}
            onLeave();
          }
        },
          'Grow'),
        /* ⚠️ THIS IS A TRAIL NOW, NOT A BACK BUTTON. Nicole, 26 August: "please can you look
           at the portal because we have a specific design for breadcrumb trails? Look at the
           portal source code, not just guessing or doing it by hand."
           READ RATHER THAN GUESSED: components/nav/CrumbBar.tsx, whose own header calls it
           "ONE crumb trail for the whole estate". Its shape is a nav[aria-label=Breadcrumb]
           holding an ol, links at text-slate-500 with `hover:text-slate-900` and NO underline
           in any state, a lucide ChevronRight separator at size-4 in slate-400, and a last
           crumb that is not a link and carries aria-current=page in semibold brand blue.
           WHAT CHANGED AND WHAT DID NOT. The markup and the semantics changed; the CLICK DID
           NOT. "Grow" still runs ggExitReferMode AND onLeave, both halves, which is the thing
           refer-mode-has-exactly-one-way-in-and-one-way-out.test.js exists to protect. A trail
           that closed the view without clearing refer mode would leave the reader on the
           calculator being told they are referring, with no control to stop.
           THE CHEVRON POINTS RIGHT AND SITS BETWEEN THE CRUMBS, where the old one pointed left
           and sat before the label. That is the difference between "go back" and "you are
           here", and the portal means the second. */
        e('span', { className: 'rl__crumbsep', 'aria-hidden': 'true' },
          e('svg', { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'none', stroke: 'currentColor',
                     strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
            e('path', { d: 'm9 18 6-6-6-6' }))),
        e('span', { className: 'rl__crumbnow', 'aria-current': 'page' }, 'Referrals')),
      // ── SectionShellView: the h1, the tab strip, then ONE .gg-section-base pane ──
      e('div', { className: 'rl__hrow' }, e('h1', { className: 'rl__h1' }, 'Referrals')),
      /* THE TAB STRIP WAS HERE. Nothing takes its place: the page starts at the hero. */

      e('div', { className: 'gg-section-base' },

      /* ⚠️ THE HERO NAMES BOTH AND ATTACHES THE 10% TO BUSINESSES ONLY. The candidate
         rate cannot be published until the cap exists and FCA sign-off clears, which the
         portal's own candidates route records, so no figure of any kind appears against
         referring a person anywhere on this page. */
      e('h2', { className: 'rl__hero' }, 'Refer a business, or refer a person'),
      e('p', { className: 'rl__sub' }, 'One account, one link, one code, and it covers both. Businesses you refer get 10% off, and you earn 10% recurring commission on what they pay us for the length of their minimum commitment. You can also send us someone you would recommend for client work, using the same link.'),

          referPanes(mode),


          /* ── EVERYTHING ELSE LIVES IN ONE PANEL. Nicole's call on their side,
             7 Aug 2026, extended on 19 Aug to pull the pipeline rail inside it:
             the rail is the table's stage filter, so it belongs in the box with
             the table rather than floating above it. */
          /* ══ THE TABLE AND THE PIPELINE RAIL WERE REMOVED HERE, 25 August 2026 ═════
             Nicole's ruling, and the reason is the same one that removed the copy
             promising a link nothing was generating.

             THEY COULD NEVER FILL. There is no reader on this side and no endpoint
             behind them, `rows` was permanently empty by design, and the three stage
             cells derived their figures from it, so INTRODUCED, IN CONVERSATION and
             PAYING YOU were structurally zero rather than merely zero today. The
             search box, the four selects and Export CSV all filtered and exported
             nothing. A table that can never fill is furniture that describes a
             product the reader is not using.

             THEY STAY ON THE PORTAL, WHERE THEY ARE REAL. This surface mirrors
             ReferralsExplorer.tsx and the mirror is right on their side, which is the
             whole reason the arrangement was copied on 25 August at 00:08.

             ⚠️ THIS IS A DELIBERATE DIVERGENCE AND IT WAS TOLD, NOT DISCOVERED.
             Earlier the same day this lane put a local tint on .gg-pane-1 here, which
             diverged from their glass.css:41 without anyone deciding to, and Nicole
             caught it and it was reverted. The difference between that and this is
             only that this one is announced: it is item 4 of
             Calculator_Outbound_2026-08-25_To_Portal_Referrals_Six_Asks.md.

             THE COMMISSION CELL SURVIVES, and it is the point of keeping a panel at
             all. FREELANCER 20 puts the forecasting here, and Nicole's instruction is
             that this cell is where the number lands. It is the one cell on the old
             rail that was never a count of rows we do not have. */
          e('section', { className: 'gg-pane-1 rl__panel rl__panel--forecast' },
            e('div', { className: 'rl__phead' },
              e('h2', { className: 'rl__h2' }, 'What you would earn'),
              /* ⚠️ THE SCOPE IS STATED OUT LOUD RATHER THAN LEFT TO BE INFERRED. Every
                 figure in this panel is a business-referral figure. With one page and no
                 tabs, a reader in person mode would otherwise read the 10% as theirs.
                 ⚠️ AND IT IMPLIES SOMETHING BY OMISSION, WHICH NICOLE HAS SEEN AND
                 ACCEPTED. Labelling this "business referrals" tells a reader the person
                 rate is something else or not yet set. That is true, and it is the least
                 bad of the options: the alternative is moving the whole forecast behind
                 the link, which makes the page materially worse for everybody in order to
                 avoid an implication. If FCA sign-off ever makes the implication itself
                 unacceptable, that is the change to make and it is a big one. */
              e('span', { className: 'rl__scope' }, 'Business referrals')),
            e('p', { className: 'rl__fine rl__forecastnote' },
              'This forecast covers what you earn when you refer a business. You earn 10% recurring on what they pay us, for the length of their minimum commitment.'),

            /* ── PICK WHO FIRST, THEN WHAT, THEN THE TIER. Nicole's order, 25 Aug, and
               it is the order the money depends on: who decides the bonus, what and
               which tier decide the monthly total the 10% runs on. */
            /* ── TWO COLUMNS. Nicole, 25 Aug, layout A. Controls on the left, readout
               on the right, which is the only arrangement in which "still like the
               sidebar calculator" is true: a sidebar is a summary, not an input.

               THE WHO PANEL IS THE REAL COMPONENT, not a second set that looks like it.
               window.GGReferWhoCards is ClientTypeSection's own block, extracted today
               and published, so the badge, the amber selected state, the tooltip and the
               figures are the same ones the Grow step draws. The figures were already
               shared through REFER_WHO_OPTIONS; this shares the markup too, which was
               the half that could still have drifted.

               IT DEGRADES RATHER THAN BLANKS. sections.v2.jsx loads before this file in
               index.html and always has, but a missing component here would take out the
               whole forecast, so the simple cards remain as the fallback. */
            e('div', { className: 'rl__fcgrid' },

              e('div', { className: 'rl__fccol' },
                window.GGReferWhoCards
                  ? e(window.GGReferWhoCards, {
                      value: referWho,
                      onChange: function (id) { setReferWho(id); },
                      onHelp: function () { setHelpOv(true); }
                    })
                  : e('div', { className: 'rl__who', role: 'radiogroup', 'aria-label': 'Who are you referring' },
                      WHO_OPTS.map(function (o) {
                        var on = referWho === o.id;
                        return e('button', {
                          key: o.id, type: 'button', role: 'radio', 'aria-checked': on ? 'true' : 'false',
                          className: 'rl__whocard' + (on ? ' is-on' : ''),
                          onClick: function () { setReferWho(o.id); }
                        },
                          e('span', { className: 'rl__wholabel' }, o.label),
                          e('span', { className: 'rl__whonum' },
                            e('b', { className: 'rl__whobig' }, o.big),
                            e('span', { className: 'rl__whounit' }, o.unit)));
                      })),

                /* Its own panel below, per Nicole: what they might need, and on which plan. */
                e('div', { className: 'refer-who-select glass-frame rl__pickpanel' },
                  e('div', { className: 'refer-who-select__head' },
                    /* THE HOUSE TOOLTIP, NOT A LOCAL ONE. window.HoverPortalTip with
                       tipClassName dis-tip dis-tip--above and window.InfoIcon is the
                       pattern used about sixty times in sections.v2.jsx. Both are real
                       globals here because Babel emits a classic script, verified live
                       rather than assumed, and sections.v2.jsx loads before this file.
                       WHY THIS REPLACED A LOCAL BUBBLE. The local one had to solve
                       placement, stacking and hit-testing for itself, and got all three
                       wrong in turn: it hung off the panel edge, it painted under the
                       picker because a ported glass-frame rule made its parent a
                       stacking context, and a pointer-events none of my own made it read
                       as covered. HoverPortalTip portals to window.ggPortalHost() at
                       z-index 2147483647, so none of those three can occur.
                       THE COPY IS MERGED with the referring banner's, per Nicole, so the
                       one explanation lives in one place rather than two that drift. */
                    e('span', { className: 'refer-who-select__title' }, 'What might they need?',
                      e('span', { className: 'rl__opt' }, '(Optional)'),
                      window.HoverPortalTip ? e(window.HoverPortalTip, {
                        wrapClassName: 'rl__tipwrap',
                        tipClassName: 'dis-tip dis-tip--above',
                        placement: 'above',
                        tip: e('span', { className: 'dis-tip__body' },
                          'Use this to forecast your potential referral commission. Pick the services '
                          + 'you would refer and see it on the right. You do not need to know exactly '
                          + 'what they will buy, so submit the client details from your admin portal '
                          + 'once you are ready.',
                          e('span', { style: { display: 'block', height: '8px' } }),
                          'Waitlisted services and bespoke tiers are left out of the figure, because '
                          + 'they are priced individually rather than from a plan.')
                      }, e(window.InfoIcon, { title: 'About this forecast' })) : null)),
                  /* ── UNNUMBERED STEP BAR. Real .sdg-stepper markup so it is the
                     calculator's control, with .sdg-stepper__num simply not rendered
                     rather than hidden in CSS: Nicole asked for no numbering, and a
                     number that exists but is invisible still reaches a screen reader
                     and still leaves the circle's width in the layout.
                     is-current is the only state used. is-done would claim a family had
                     been completed, and there is nothing here to complete. */
                  /* ── THE REAL STEP BAR, NOT ITS CLASSES. 26 August 2026, Nicole's ruling.
                     She asked for the calculator's own numbered step indicator, the one with
                     the 3D icons, "obv no client type and last step".
                     THIS USED TO BE sdg-stepper MARKUP HAND-ASSEMBLED HERE, which was the
                     third lookalike on this surface in one day. window.StepIndicator is
                     published at sections.v2.jsx:488 and already reused by dedicated-flow.jsx,
                     so this is its third caller rather than a new control.
                     THE FLOW IS THE CALCULATOR'S OWN, SLICED. stepsForClient('agency',
                     'agency-whitelabel') returns Client type, White-label plan, Growth
                     services, Creative services, Talent solutions, Next steps. slice(1, -1)
                     drops the first and the last, which is exactly the two Nicole named, and
                     leaves the four families with their real ids, labels and icons. Nothing
                     here names an icon or a label, so a rename in data.jsx reaches this bar
                     without anybody remembering it exists.
                     THE ORDER CHANGED AS A RESULT and that is the point: White-label plan now
                     leads because that is the order the calculator itself walks a partner
                     through, and two orders for one set of services is how the two surfaces
                     stopped agreeing in the first place.
                     NOTHING IS LOCKED AND EVERYTHING IS REACHABLE. This is a filter, not a
                     flow, so canJumpTo is always true and isStepLocked always false. Passing
                     neither would default to "only steps up to the current one", which would
                     silently make three of the four families unclickable. */
                  (function () {
                    var flow = FAM_FLOW();
                    if (!window.StepIndicator || !flow.length) {
                      /* Fallback keeps the surface usable if the calculator has not mounted,
                         the same shape as the GGReferWhoCards fallback beside it. */
                      return e('div', { className: 'rl__fams-fallback', role: 'list', 'aria-label': 'Service families' },
                        flow.map(function (f) {
                          return e('button', {
                            key: f.id, type: 'button', role: 'listitem',
                            className: 'rl__fam' + (FAM_OF_STEP[f.id] === fam ? ' is-current' : ''),
                            onClick: function () { setFam(FAM_OF_STEP[f.id]); }
                          }, f.label);
                        }));
                    }
                    var idx = 0;
                    for (var k = 0; k < flow.length; k++) { if (FAM_OF_STEP[flow[k].id] === fam) { idx = k; break; } }
                    return e('div', { className: 'rl__stepbar' },
                      e(window.StepIndicator, {
                        step: idx,
                        flow: flow,
                        clientTypeId: 'agency',
                        intentId: 'agency-whitelabel',
                        /* NO UTILITY LINKS ON A FILTER. One of them is Portal login,
                           pointing at /login, which is the dead end removed from six locked
                           surfaces on 25 August. Here it would walk a reader out of the
                           glimpse mid-task to a page that cannot help them. */
                        utilities: false,
                        canJumpTo: function () { return true; },
                        isStepLocked: function () { return false; },
                        countFor: function (sObj) {
                          var f2 = FAM_OF_STEP[sObj && sObj.id];
                          if (!f2) return null;
                          var n = svcChosen.filter(function (x) { return FAM_OF_SERVICE(x.id) === f2; }).length;
                          return n || null;
                        },
                        onJump: function (i2) { var f3 = FAM_OF_STEP[flow[i2] && flow[i2].id]; if (f3) setFam(f3); }
                      }));
                  })(),

                  /* ── THE SERVICES ACCORDION, 28 Aug 2026 (Alexander).
                     WHAT IT REPLACED. The picker was a dropdown: one trigger, and a flat
                     list of checkboxes behind it. Choosing a service then sent you back
                     out to the pill row to make it active, and that service's tiers
                     appeared in a third place further down the column. Three controls and
                     two jumps for one decision, and the menu covered the pane whilst it
                     was open.
                     IT IS ONE CONTROL NOW. Every service in the family is a row. A row
                     carries its own checkbox, its own tier chip and its own chevron, and
                     opening one shows that service's tiers and commitment inside it.
                     ONE ROW IS OPEN AT A TIME AND NOTHING NEW TRACKS THAT. The open row IS
                     activeSvc, which is already derived from the family and the basket. So
                     "open another and the last one closes" is a property of the data rather
                     than a second piece of state that can drift out of step with it, which
                     is the same reasoning that removed the separate tier and commit state
                     on 26 August.
                     THE TIERS AND COMMITMENT BLOCK BELOW IS THE SAME BLOCK, MOVED. It is
                     still gated on activeSvc and still reads rlTiers(activeSvc), so every
                     ruling recorded against it still holds. Only its position changed, and
                     it was moved rather than retyped so that it could not quietly drift. */
                  e('div', { className: 'rl__svclist' },
                    /* THE HEAD IS THE OLD TRIGGER'S FOUR PARTS, STILL. Same plus square,
                       same bold count, same blue chip, so the picker did not change
                       vocabulary on the way to changing shape. It no longer opens
                       anything, because there is nothing left to open. */
                    e('div', { className: 'rl__svchead' },
                      e('span', { className: 'rl__ddplus', 'aria-hidden': 'true' }, '+'),
                      e('span', { className: 'rl__ddlbl' },
                        String(famServices.length) + (famServices.length === 1 ? ' service available' : ' services available')),
                      e('span', { className: 'rl__ddchip' },
                        svcChosen.length === 0 ? 'Pick any that fit' : String(svcChosen.length) + ' selected')),
                    /* The portal's popover heads its unchecked list with this eyebrow. */
                    e('p', { className: 'rl__ddeyebrow' }, 'Available'),
                    /* Filtered to the chosen family. "Not sure yet" is never filtered out:
                       it is the honest answer regardless of family and it is what lets
                       somebody who does not know still get their link. */
                    RL_SERVICES.filter(function (x) {
                      return x.id === 'unsure' || RL_FAM_OF[x.id] === fam;
                    }).map(function (x) {
                      var on = !!svcSel[x.id];
                      var m = x.id === 'unsure' ? { priceable: true } : svcMeta(x.id);
                      /* MUTED MEANS "THIS CANNOT FORECAST", NOT "THIS HAS NO TIER", and a
                         muted row is unselectable rather than grey and clickable. Both
                         rulings are Nicole's, 26 Aug 2026, and they carry over unchanged. */
                      var muted = x.id !== 'unsure' && !isDed(x.id) && !m.priceable;
                      /* NOT SURE YET NEVER OPENS. It is an answer, not a service, so there
                         is nothing to price inside it and a chevron would promise one. */
                      var open = !muted && x.id !== 'unsure' && x.id === activeSvc;
                      /* The chip reports the tier this service is set to, so a closed row
                         still says what was chosen inside it. Dedicated is priced per
                         person rather than by tier, so it has nothing to report here. */
                      var chip = null;
                      if (on && !muted && x.id !== 'unsure' && !isDed(x.id)) {
                        var tNow = rlTiers(x.id).filter(function (t) { return t.id === cfgFor(x.id).tier; })[0];
                        chip = tNow ? (tNow.name || tNow.label) : null;
                      }
                      return e('div', {
                        key: x.id,
                        className: 'rl__svcrow' + (on ? ' is-on' : '') + (open ? ' is-open' : '') + (muted ? ' is-muted' : '')
                      },
                        e('button', {
                          type: 'button',
                          /* THE ROW IS AN OPTION, so it carries the option class the
                             dropdown used, not just a new one of its own. rl__ddbox is
                             already its checkbox and rl__ddwhy already carries the reason a
                             muted one cannot be picked; rl__ddopt is the third part of that
                             same vocabulary and there is no reason for the row to drop it
                             on the way from a menu to a list.
                             IT IS ALSO WHAT dedicated-picker-reuses-the-flow.test.js READS,
                             and that test was right to complain: the class had left the
                             file entirely when the menu did. */
                          className: 'rl__ddopt rl__svcrowbtn',
                          'aria-expanded': open ? 'true' : 'false',
                          disabled: muted,
                          'aria-disabled': muted ? 'true' : 'false',
                          /* ONE CLICK IS THE WHOLE DECISION. Picking a service selects it,
                             moves the family to the one it belongs to, makes it the service
                             being priced, and opens it. Clicking the row that is already
                             open puts it back, which is the only way a row closes without
                             another one opening, and it is the same gesture as unticking. */
                          onClick: function () {
                            if (muted) return;
                            /* NOT SURE YET IS A PLAIN TICK, and it needs its own line here
                               because it is the one row that never opens. Everything below
                               this is the open/close gesture, and it reads "already chosen
                               but not the open one, so open it" — which for unsure resolves
                               to setActiveSvc('unsure'), an id the activeSvc derivation
                               discards by design. The click did nothing, so a ticked Not
                               sure yet could not be unticked. Caught on the preview on
                               28 Aug 2026 rather than by the suite, because every guard on
                               this control reads the source and none of them click it. */
                            if (x.id === 'unsure') {
                              /* ASK ONLY WHEN A PICK WOULD BE DESTROYED. Ticking it with
                                 nothing chosen, and unticking it at any time, take nothing
                                 away and so take no question with them. */
                              if (!on && chosenIds.length) { setWipeAsk(true); return; }
                              toggleSvc(x.id);
                              return;
                            }
                            if (RL_FAM_OF[x.id]) setFam(RL_FAM_OF[x.id]);
                            if (on && x.id === activeSvc) { toggleSvc(x.id); return; }
                            if (!on) toggleSvc(x.id);
                            setActiveSvc(x.id);
                          }
                        },
                          e('span', { className: 'rl__ddbox' + (on ? ' is-on' : ''), 'aria-hidden': 'true' },
                            on && window.Check ? e(window.Check, { size: 12 }) : null),
                          e('span', { className: 'rl__svcname' }, x.label),
                          muted ? e('span', { className: 'rl__ddwhy' }, m.why) : null,
                          chip ? e('span', { className: 'rl__svcchip' }, chip) : null,
                          (muted || x.id === 'unsure') ? null : e(RlChevron, { className: 'rl__ddchev' })),
                        /* NO overflow:hidden ON THIS BODY, deliberately. The commitment
                           menu is absolutely positioned out of the tier label row, and a
                           clipping ancestor would cut it off. The row opens by mounting
                           rather than by animating a height, which is the house pattern
                           and the reason there is no measured scrollHeight anywhere. */
                        !open ? null : e('div', { className: 'rl__svcbody' },
                        /* THE EXACT LABEL ROW FROM ServiceBlock, per Nicole: svc__tier-label-row
                           wrapping svc__tier-label. The string is Available tiers rather than
                           Choose a tier, because the calculator says Choose a tier only once a
                           service is in YOUR quote, and nothing here is being bought.
                           THE COMMITMENT DROPDOWN SITS IN THIS ROW, which is the slot the
                           calculator uses for the pay-upfront toggle. Nicole was unsure where to
                           put it. Here it is beside the label it changes, it costs no vertical
                           space, and the old pill row was wide enough to leave the row ragged. */
                        /* ⚠️ TIERS AND COMMITMENT DO NOT EXIST UNTIL A SERVICE IS PICKED.
                           Nicole, 26 Aug: "maybe we only show the available tiers and commitment
                           once they have picked a service from the drop-down, because it looks so
                           bad with the tier panels not showing any price."
                           She is right and the empty state was arguing with itself. Three tier
                           cards reading a dash over "Pick a service" is a priced control with no
                           price in it, and a Commitment pill that changes a number nobody can see
                           is a control with no effect. THE HOUSE RULE IS THAT AN EMPTY STATE IS A
                           FEATURE WITH A NEXT ACTION, not a gap. The next action here is the
                           picker directly above, so the honest empty state is to show the picker
                           and nothing else.
                           NOT RENDERED RATHER THAN HIDDEN, per the same reasoning as .tier__blurb
                           a few lines below: a display:none would reach classes the calculator
                           draws elsewhere. */
                        /* HIDDEN UNTIL THIS FAMILY HAS A PICK. Nicole: "whenever I switch to a
                           different service category, for example, I'm in White Label Services
                           and then I click on Growth Services, I think we need to hide the
                           Available Tiers and Commitment section because it's quite confusing.
                           Once I have picked a service on Growth Services, that's the only time
                           we show the Available Tiers and Commitment section."
                           THE TEST IS THE FAMILY, NOT THE BASKET. It used to be svcChosen.length,
                           which stayed true across a family switch, so moving to an untouched
                           category left a full tier panel on screen describing a service from
                           somewhere else. */
                        !activeSvc ? null : e(React.Fragment, null,
                        e('div', { className: 'svc__tier-label-row rl__tierrow' },
                          /* Dedicated has no tiers to be available. It is priced per person per
                             day, so the label names what the control actually does. */
                          e('div', { className: 'svc__tier-label' },
                            isDed(activeSvc) ? 'Who they need' : 'Available tiers'),
                          /* window.BdCommitPill IS THE OVERLAY'S OWN CONTROL, reused rather than
                             traced. Same trigger, same menu, same minus sign on the savings.
                             THE DEDICATED CONTROLS SIT IN THIS SAME SLOT, 26 Aug 2026. Nicole:
                             "the days a month and how many specialists should be the commitment,
                             which sits on the same line as the available tier, so this should sit
                             on the same line as who they need... what we're trying to do here is
                             basically lessening the vertical space."
                             So there is ONE slot on this header row and three things can occupy
                             it: a commitment for a tiered service, a day count for part-time, a
                             headcount for full-time. Each is the same component with different
                             props, which is why generalising BdCommitPill this afternoon was
                             worth doing rather than drawing a second small dropdown. */
                          (window.BdCommitPill && (isDed(activeSvc) || commitMatters(activeSvc))) ? e('span', { className: 'rl__commitslot' },
                            e('span', { className: 'rl__commitlbl' },
                              activeSvc === 'dedicated-ft' ? 'How many specialists'
                                : activeSvc === 'dedicated-pt' ? 'Days a month'
                                : 'Commitment'),
                            activeSvc === 'dedicated-ft'
                              ? e(window.BdCommitPill, {
                                  opts: dedQtyOpts(), cid: String(dedCfg(activeSvc).qty),
                                  onPick: function (next) { dedPatch(activeSvc, { qty: parseInt(next, 10) || 1 }); },
                                  unit: 'each', valueKey: 'n', unitLong: 'each',
                                  menuLabel: 'How many specialists', triggerNoun: 'how many specialists', nudge: false
                                })
                              : activeSvc === 'dedicated-pt'
                              ? e(window.BdCommitPill, {
                                  opts: dedPlanOpts(), cid: dedCfg(activeSvc).planId,
                                  onPick: function (next) { dedPatch(activeSvc, { planId: String(next) }); },
                                  unit: 'days', valueKey: 'days', unitLong: 'days',
                                  menuLabel: 'Days a month', triggerNoun: 'days a month', nudge: false
                                })
                              : e(window.BdCommitPill, {
                                  opts: RL_COMMITS, cid: commitId,
                                  onPick: function (next) { patchActive({ commit: String(next) }); }
                                })) : null),
                        /* SIMPLIFIED AVAILABLE TIERS, per Nicole 25 Aug. The calculator's own
                           panel carries a grey blurb and a tick list per tier; both are removed
                           here. Neither helps somebody guessing on another business's behalf,
                           and three tick lists is most of the column's height.
                           WHAT IS KEPT is what makes the choice: the panel, the price, the setup
                           fee, the RECOMMENDED badge and the amber Grow tint.
                           THE PRICE IS THE BASKET, NOT ONE SERVICE. The calculator prices one
                           service at three tiers because you are buying it. Here the reader has
                           picked several on somebody else's behalf, so each card sums what THAT
                           basket costs at THAT tier. Nothing picked shows a dash, not a zero,
                           because zero is a price and a dash is an absence. */
                        /* ── THE REAL AVAILABLE TIERS PANEL, SIMPLIFIED ──────────────────
                           Nicole, 25 August 2026: the real panels, "still not ticks and tier
                           description". So this uses the calculator's own .tiers / .tier
                           classes and simply DOES NOT RENDER .tier__blurb or .tier__badges.

                           NOT RENDERED RATHER THAN HIDDEN IN CSS, deliberately. display:none
                           on .tier__blurb would reach into a class the calculator draws
                           everywhere else, so the next person to add a tier list anywhere on
                           this page would lose their ticks without touching anything.

                           THE PRICE IS THE BASKET AT THAT TIER, not one service. The
                           calculator prices one service across three tiers because you are
                           buying it. Here the reader has picked on somebody else's behalf and
                           may pick several, so each card sums what THAT basket costs at THAT
                           tier and commitment. Nothing picked shows a dash, because zero is a
                           price and a dash is an absence. */
                        /* THE COLUMN COUNT FOLLOWS THE LIST. sections.v2.jsx:5936 sets it
                           inline from window.tiersFor(service).length; we cannot, because
                           app.v2.css declares .gg-scope .tiers with repeat(4) !important and a
                           normal inline declaration does NOT beat a stylesheet !important.
                           Only inline-with-important would, which React's style object cannot
                           express. So the count travels as a class and the CSS carries one
                           rule per count, each at 0,3,0 specificity so it wins on its own
                           merits. This was got wrong twice on this grid already, both times by
                           assuming specificity beats !important. */
                        /* ── THE DEDICATED PANEL ─────────────────────────────────────────
                           THIRD VERSION. One dropdown, and inside it one row per role with a
                           small emoji flag checkbox for each office that role is offered in.
                           Nicole: "It should be per role, not just three checkboxes, and it
                           applies to all of the roles they picked... so there will be a lot of
                           checkboxes on the dropdown." A role wanted in two offices is two
                           people, which the forecast counts as two.
                           THE FLAGS ARE FT_LOCATIONS' OWN EMOJI. They have been on that data
                           since June and the previous version ignored them in favour of fetched
                           SVGs, which is what looked wrong.
                           THE DAYS AND THE HEADCOUNT ARE NOT HERE. They sit on the "Who they
                           need" header row above, in the slot the commitment pill uses, because
                           the whole point of this revision is less vertical space. */
                        isDed(activeSvc) ? (function () {
                          var svcId = activeSvc;
                          var roles = dedRolesFor(svcId);
                          if (!roles.length) return null;
                          var cfg = dedCfg(svcId);
                          var n = dedCount(svcId);
                          return e('div', { className: 'rl__ded' },
                            e('div', { className: 'rl__dd', ref: dedRef },
                              /* THE SAME TRIGGER AS THE SERVICE PICKER, 26 Aug 2026 (Nicole):
                                 "make the Choose roles and offices dropdown similar to the service
                                 selector dropdown so it's more consistent in terms of design."
                                 It was a bare text button whilst the picker six inches above it
                                 carried a plus square, a bold label, a blue count chip and a
                                 chevron. Two dropdown idioms on one pane, which is the mistake this
                                 surface keeps repeating. Same four parts, same classes, same order,
                                 and the label counts the same way: what is available before a pick,
                                 what is chosen after one. */
                              e('button', {
                                type: 'button', className: 'rl__ddbtn' + (dedOpen ? ' is-open' : ''),
                                'aria-haspopup': 'listbox',
                                'aria-expanded': dedOpen ? 'true' : 'false',
                                onClick: function () { setDedOpen(!dedOpen); }
                              },
                                e('span', { className: 'rl__ddplus', 'aria-hidden': 'true' }, '+'),
                                e('span', { className: 'rl__ddlbl' },
                                  n === 0 ? String(roles.length) + (roles.length === 1 ? ' role available' : ' roles available')
                                    : n === 1 ? '1 specialist selected'
                                    : String(n) + ' specialists selected'),
                                n === 0
                                  ? e('span', { className: 'rl__ddchip' }, 'Pick any that fit')
                                  : e('span', { className: 'rl__ddchip' }, String(n) + ' selected'),
                                e(RlChevron, { className: 'rl__ddchev' })),
                              dedOpen ? e('div', {
                                className: 'rl__ddmenu rl__ddroles', role: 'listbox',
                                'aria-multiselectable': 'true',
                                'aria-label': 'Roles and offices they might need'
                              },
                                /* ONE EYEBROW ROW CARRYING THE OFFICE NAMES, which is the column
                                   title Nicole asked for and which .rl__ddeyebrow already is.
                                   The names sit above the flags so the emoji never has to carry
                                   the meaning on its own, which a flag is bad at. */
                                e('div', { className: 'rl__ddrolehead' },
                                  e('p', { className: 'rl__ddeyebrow' }, 'Role'),
                                  e('div', { className: 'rl__ddflags' }, dedLocs().map(function (l) {
                                    return e('p', { key: l.id, className: 'rl__ddeyebrow rl__ddflaghead' }, l.label);
                                  }))),
                                roles.map(function (r) {
                                  var allowed = dedLocsFor(r);
                                  var mine = cfg.picks[r.id] || [];
                                  return e('div', { key: r.id, className: 'rl__ddrolerow' },
                                    e('span', { className: 'rl__ddrolename' }, r.name),
                                    e('div', { className: 'rl__ddflags' }, dedLocs().map(function (l) {
                                      var offered = allowed.some(function (a) { return a.id === l.id; });
                                      if (!offered) {
                                        /* A blank cell rather than a disabled control: this role
                                           is not offered from that office at all, and a greyed
                                           checkbox invites a click that can never do anything. */
                                        return e('span', { key: l.id, className: 'rl__ddflagcell', 'aria-hidden': 'true' });
                                      }
                                      var on = mine.indexOf(l.id) !== -1;
                                      return e('button', {
                                        key: l.id, type: 'button', role: 'checkbox',
                                        'aria-checked': on ? 'true' : 'false',
                                        'aria-label': r.name + ', ' + l.label,
                                        className: 'rl__ddflagcell rl__ddflagbtn',
                                        onClick: function (ev) { ev.stopPropagation(); dedToggle(svcId, r.id, l.id); }
                                      },
                                        e('span', { className: 'rl__ddbox' + (on ? ' is-on' : '') },
                                          on && window.Check ? e(window.Check, { size: 12 }) : null),
                                        /* AN IMAGE, NOT THE EMOJI. Corrected 26 Aug 2026 after
                                           looking at the rendered page rather than the DOM.
                                           FT_LOCATIONS carries flag: '🇬🇧' and I used it, but a
                                           flag emoji is a REGIONAL INDICATOR PAIR and Windows
                                           ships no glyph for it, so Chrome falls back to drawing
                                           the two letters. Nicole's screenshot showed GB, PH and
                                           ZA as text beside the checkboxes, which is why it
                                           looked wrong to her and looked fine to me: I had only
                                           ever queried the DOM, where the emoji is present and
                                           correct.
                                           THE EARLIER IMAGE FLAGS WERE NOT THE MISTAKE. Their
                                           size and their setting were: 20x14 inside big pill
                                           buttons. Here they are 16x11 beside a checkbox, which
                                           is the "really small flags" she asked for, and they
                                           render on every platform. */
                                        window.GGFlag
                                          ? e(window.GGFlag, { loc: l, className: 'rl__ddflagimg', width: 16, height: 11 })
                                          : e('span', { className: 'rl__ddflag', 'aria-hidden': 'true' }, l.flag));
                                    })));
                                })) : null),
                            n === 0 ? null : e('p', { className: 'rl__fine rl__dednote' },
                              'About ', e('strong', null, gbp(dedMonthly(svcId))), ' a month, ',
                              svcId === 'dedicated-pt'
                                ? 'at the recurring rate with its 20% saving.'
                                : 'at the junior floor rate until they shortlist.',
                              ' Confirmed on their scoping call.'));
                        })() : null,
                        isDed(activeSvc) ? null : e('div', { className: 'tiers rl__tiers2 rl__tiers2--n' + rlTiers(activeSvc).length },
                          rlTiers(activeSvc).map(function (t) {
                            var on = tier === t.id;
                            var tot = tierTotals(t.id);
                            var rec = t.id === 'grow';
                            /* A TIER THE ACTIVE SERVICE DOES NOT OPEN IS WASHED OUT, NOT HIDDEN.
                               26 Aug 2026, and it only became possible today: whilst the cards
                               priced the whole basket, every tier was offered by something, so
                               there was nothing to wash. Per service, content and 3D animation
                               are waitlisted at all four tiers and dedicated resources is priced
                               per person, so a card can now be genuinely unavailable.
                               .tier--waitlist IS THE HOUSE CLASS for exactly this, app.v2.css:4271,
                               and sections.v2.jsx:5947 applies it from the same waitlistTiers list
                               that svcMeta reads. HIDING instead of washing would drop the grid to
                               one or two tracks, and .tiers.rl__tiers2 is repeat(3, minmax(0,1fr)),
                               so the survivors would stretch and the panel would jump height every
                               time the pills were used. */
                            var _m = svcMeta(activeSvc);
                            var off = !!activeSvc && (!_m.priceable || _m.openTiers.indexOf(t.id) === -1);
                            return e('button', {
                              key: t.id, type: 'button', role: 'radio', 'aria-checked': on ? 'true' : 'false',
                              'aria-disabled': off ? 'true' : 'false',
                              className: 'tier' + (on && !off ? ' tier--active' : '') + (rec ? ' tier--recommended' : '') + (off ? ' tier--waitlist' : ''),
                              onClick: function () { if (!off) patchActive({ tier: t.id }); }
                            },
                              rec ? e('img', { src: 'assets/badges/recommended.webp', alt: 'Recommended', className: 'tier__rec-banner' }) : null,
                              e('div', { className: 'tier__head' },
                                e('div', { className: 'tier__head-main' },
                                  e('div', { className: 'tier__name-row' },
                                    /* `name` is what data.jsx and window.TIERS both use. The
                                       `label` this line used to read existed only on the
                                       deleted local array, so reading it against a real tier
                                       object rendered an empty span. */
                                    e('span', { className: 'tier__name' }, t.name || t.label))),
                                /* THE TICK IS THE HOUSE PATTERN, NOT A NEW ONE. Nicole, 26 Aug:
                                   "why is there not a check mark on the boxes when you select the
                                   tiers?" .agency-intent-card__radio.is-on renders
                                   {on && <window.Check size={18} />} inside the box, and its own CSS
                                   comment says it "matches the canonical .client-card__radio /
                                   .tier__radio style". So the box was already right and the GLYPH
                                   was the missing half. Same component, same size.
                                   ⚠️ THE CALCULATOR'S OWN TIER CARDS AT sections.v2.jsx:5902 STILL
                                   HAVE NO TICK. That is a divergence this change creates and it is
                                   flagged to Nicole rather than fixed here, because changing the
                                   primary buying surface is not a side effect of polishing this one. */
                                e('span', { className: 'tier__radio' + (on ? ' is-on' : ''), 'aria-hidden': 'true' },
                                  /* ⚠️ SIZE 16, NOT 18, AND THE 18 WAS MINE. The calculator's own
                                     tier card at sections.v2.jsx renders <window.Check size={16} />
                                     and ALWAYS HAS. On 26 August this lane reported that the
                                     calculator's tiers had no tick at all and offered to add one.
                                     That was inferred from this surface lacking one, without opening
                                     the other file. Nicole said add it, and adding it produced a
                                     duplicate tick which is how the mistake surfaced. The calculator
                                     was right the whole time; only this copy was missing the glyph,
                                     and only this copy had the wrong size. */
                                  on && window.Check ? e(window.Check, { size: 16 }) : null)),
                              /* NO DASH BRANCH ANY MORE. The whole block is unrendered until a
                                 service exists, so "nothing picked" is unreachable here and a
                                 branch for it would be a state that cannot occur. */
                              /* A WASHED CARD SHOWS A DASH, NOT ZERO. Caught live on 26 Aug
                                 2026, in the browser rather than by the suite, which is the
                                 second time this week the tests passed on something the page
                                 got wrong. The wash rendered correctly and every card then read
                                 "£0/month + £0 setup fee", because an unpriceable service sums
                                 to nothing. ZERO IS A PRICE. It says content creation is free,
                                 which is a worse claim than saying nothing at all, and it is
                                 the same rule already applied two panels down: "Nothing picked
                                 shows a dash, because zero is a price and a dash is an
                                 absence." The rule existed, the new state broke a surface it
                                 had not been applied to yet. */
                              (function () {
                                var words = off ? null : priceWords(activeSvc, t.id);
                                return e('div', { className: 'tier__price' },
                                  off ? '\u2014' : (words || gbp(tot.monthly)),
                                  (off || words) ? null : e('span', { className: 'tier__price-sub' }, '/month'));
                              })(),
                              /* THE SETUP LINE IS OMITTED WHEN THERE IS NO SETUP FEE, rather than
                                 printed as "+ £0 setup fee". Same rule as the dash: zero is a
                                 price and an absence is not. White label carries setupFees {},
                                 so every one of its cards was claiming a nil fee it does not
                                 charge. The element is dropped entirely rather than emptied, so
                                 it cannot leave a gap where a line used to be. */
                              (off || tot.setup > 0)
                                ? e('div', { className: 'tier__setup-fee' },
                                    /* The isEnterprise branch that used to sit here is gone with
                                       Enterprise itself, 26 Aug 2026. A branch for a tier this
                                       surface no longer renders is a road to a place that does
                                       not exist, and the next reader has to prove it is dead
                                       before touching anything near it. */
                                    off ? (svcMeta(activeSvc).why || 'not priced here')
                                        : '+ ' + gbp(tot.setup) + ' setup fee')
                                : null);
                          })))
                        ));
                    })),
                  /* 26 Aug 2026: THESE ARE THE SWITCHER NOW, not a read-back. Nicole:
                     "the blue badges thing at the bottom of the picker, which shows the
                     names of the services, should be clickable so users can switch to
                     different services and different tiers and commitments."
                     THE COMPONENT IS .qual-pill, WHICH SHE NAMED: "we can take
                     inspiration, or basically reuse the exact components we use on the
                     paid ads qualifying questions at the last step". It is defined at
                     app.v2.css:11771 and already used at five places in sections.v2.jsx,
                     so this is the sixth rather than a seventh shape.
                     THE CAP HAD TO GO, and that is a deliberate reversal of yesterday's
                     three-plus-a-count rule. A cap on a read-back hides nothing you can
                     act on. A cap on a SWITCHER makes services four and up unreachable,
                     so the pane growing by a row is the price of the control working.
                     role=radio because exactly one is active, which is what a radio group
                     means and what a screen reader will announce. */
                  /* THE ROW IS LABELLED NOW, 28 Aug 2026 (Alexander). It sat under the list
                     with nothing naming it, so a row of blue pills read as buttons that do
                     something rather than as the picks themselves. AVAILABLE heads what you
                     can choose; SELECTED heads what you have. Same eyebrow, so the pair reads
                     as one list in two halves rather than as two unrelated controls. */
                  svcChosen.length ? e('p', { className: 'rl__ddeyebrow rl__svceyebrow' }, 'Selected') : null,
                  svcChosen.length ? e('div', {
                    className: 'qualifier-q__opts qualifier-q__opts--pills rl__svcpills',
                    role: 'radiogroup', 'aria-label': 'Choose which service to price'
                  }, svcChosen.map(function (x) {
                    var on = x.id === activeSvc;
                    return e('button', {
                      key: x.id, type: 'button', role: 'radio', 'aria-checked': on ? 'true' : 'false',
                      className: 'qual-pill' + (on ? ' qual-pill--on' : ''),
                      /* CLICKING A PILL ALSO MOVES THE STEP BAR. The pills list every
                         chosen service across all four families, and the active one is now
                         derived from the family. Without this line, clicking a Growth pill
                         whilst the step bar sat on White Label would set an id the
                         derivation immediately discards, and the pill would refuse to
                         select with no explanation. The family is the outer selection, so
                         choosing a service means choosing its family too. */
                      onClick: function (ev) {
                        ev.stopPropagation();
                        try { if (RL_FAM_OF[x.id]) setFam(RL_FAM_OF[x.id]); } catch (err) {}
                        setActiveSvc(x.id);
                      }
                    }, x.label);
                  })) : null
                  /* THE TIERS AND COMMITMENT USED TO SIT HERE, and on 28 Aug 2026 they
                     moved inside the open service row above. They are the same block, gated
                     on the same activeSvc; only the place changed. Nothing renders in this
                     position now, which is why the column ends on the pills. */
                  )),

              /* ── COLUMN 2: THE SIDEBAR, VERTICAL. Nicole's screenshot: the referral card
                 above the forecast card. It reports rather than asks, which is why the
                 picker stays on the left. */
              /* ── COLUMN 2: THE CALCULATOR'S OWN SIDEBAR, CLASS FOR CLASS ────────────
                 Nicole, 25 August 2026: the metal framed sidebar panel INCLUDING the
                 glass panels inside it, exact.

                 SO THIS IS .summary, NOT gg-pane-1. Two things were wrong with the
                 previous version and only one of them was visible. gg-pane-1 and
                 gg-pane-2 do not exist anywhere in sections.v2.jsx at all — they are
                 ours, defined only in sos-previews.css — so the sidebar was never
                 wearing the calculator's clothes even when it was a single card.

                 THE METAL FRAME IS THE ::before ON .summary, a 22px nine-slice in
                 app.css:764. It is NOT <Frame variant="metal" />: that component emits a
                 25px slice set and app.v2.css:6573 hides it on .summary precisely because
                 the two sets do not line up. Rendering Frame here would have produced a
                 frame that looked almost right, which is worse than none.

                 THE GLASS PANELS INSIDE ARE .summary__list-card and .summary__total, each
                 carrying its own 14px and 22px bevel from CSS. Nothing is split: this is
                 still ONE card, exactly as ruled earlier today. The panels are its
                 interior, which is what the calculator does too. */
              /* 26 Aug 2026: the aside now carries .summary-wrap, the calculator's own
                 sidebar class, rather than a copy of its values. That is what makes
                 it follow the scroll inside the panel, and it inherits every rule the
                 calculator has: sticky at top 1rem, max-height calc(100vh - 2rem),
                 the flex column that lets .summary__list scroll internally, and the
                 degrade-to-static under 1100px. One divergence, handled in CSS: the
                 calculator hides its sidebar under 880px because a mobile totals bar
                 repeats it, and the referrals view has no such bar, so .rl__side
                 restores display there. */
              e('aside', { className: 'summary-wrap rl__side', 'aria-label': 'Your referral forecast' },
                e('div', { className: 'summary rl__side1' },
                  e('div', { className: 'summary__scroll' },
                    e('div', { className: 'summary__list-card' },
                      e('div', { className: 'summary__list' },
                        e('div', { className: 'summary__plan summary__plan--inline summary__plan--set' },
                          e('div', { style: { flex: 1, minWidth: 0 } },
                            e('div', { className: 'summary__kicker summary__kicker--inline' }, 'Your referral'),
                            e('div', { className: 'summary__plan-name' },
                              'GoGorilla', e('em', null, 'Referrals'), e('sup', null, '\u00ae')),
                            e('div', { className: 'summary__plan-meta' },
                              e('span', { className: 'summary__plan-chip' }, '10% recurring')))),

                        /* ⚠️ THIS PARAGRAPH WAS A FLAT COPY OF THE CALCULATOR'S, AND IT WAS
                           WRONG FOR TWO OF THE THREE READERS. Nicole, 26 Aug: "make sure the
                           text style is also consistent."
                           WHAT WAS HERE: one unbranched sentence, no bold, no inline type
                           style, always saying "business". An investor referring a portfolio
                           company was told about a business, and an agency referring an
                           agency was told about a business too.
                           WHAT IS HERE NOW: window.GGReferNote, extracted from
                           sections.v2.jsx the same day, which branches on referWho, bolds the
                           three figures and carries the size, colour and leading the
                           calculator sets. One paragraph, one place. */
                        window.GGReferNote
                          ? e(window.GGReferNote, { referWho: referWho })
                          : null,

                        e('div', { className: 'summary__list-title' },
                          e('span', null, 'Services & Add-ons'),
                          e('span', null, String(chosenIds.length))),

                        chosenIds.length === 0
                          ? e('div', { className: 'summary__empty' }, e('em', null, 'Nothing selected yet'))
                          : chosenIds.map(function (id) {
                              var meta = RL_SERVICES.filter(function (x) { return x.id === id; })[0];
                              return e('div', { key: id, className: 'summary__line' },
                                e('span', { className: 'summary__line-label' }, meta ? meta.label : id),
                                e('span', { className: 'summary__line-val' },
                                  /* NOTHING TICKED IS AN ABSENCE, NOT A PRICE. Caught in
                                     the QA pass of 26 Aug: a dedicated service in the
                                     basket with no role chosen read "£0", which says the
                                     specialists are free. Same rule as the tier cards. */
                                  isDed(id) ? (dedCount(id) === 0 ? '\u2014' : gbp(dedMonthly(id)))
                                    : isCustomAt(id, cfgFor(id).tier, cfgFor(id).commit) ? 'Bespoke' : gbp(priceFor(id, cfgFor(id).tier, cfgFor(id).commit))));
                            }))),

                    /* ⚠️ THIS BLOCK USED TO BE A SECOND FORECAST. Nicole, 26 Aug: "You are
                       still not using the exact vertical calculator sidebar thing. This is on
                       the calculator repo, so I don't understand why you can't just copy it
                       exactly as it is." She was right and the answer was not to copy it more
                       carefully, it was to stop having two.
                       WHAT WAS HERE: hand-rolled rl__sideline / rl__sidebig / rl__sidebiglbl
                       rows drawing a commission figure the calculator already draws, with
                       different type, different colour and no info tooltips.
                       WHAT IS HERE NOW: window.GGReferForecastRows, EXTRACTED from
                       sections.v2.jsx on the same day so both surfaces render one component.
                       The calculator passes its animated total, this passes the basket total
                       at the chosen tier, and both are the same thing: the monthly total the
                       referred business would pay, which is the only base the 10% is ever
                       computed from. Neither surface can quietly use a different one now.
                       THE MONTHLY TOTAL AND SETUP ROWS USE THE REAL CLASSES rather than the
                       real JSX, and that is the law's first exception stated out loud: the
                       calculator's own versions of those two rows are entangled with
                       onlyOneTime, bundleDiscount, agencyDiscountPct and _msPctFinal, none of
                       which exist on this surface. Same presentation contract, different data.
                       IT DEGRADES RATHER THAN BLANKS, like GGReferWhoCards beside it. */
                    /* ⚠️ THE INLINE FLEX COLUMN IS LOAD-BEARING AND I SHIPPED WITHOUT IT ONCE.
                       GGReferForecastRows puts `order: -1` on the commission row so it leads
                       the panel, which is what the calculator does in refer mode. `order` only
                       applies inside a flex container, and the calculator sets exactly this
                       inline at its own call site. Without it the property is inert, the rows
                       fall back to source order, and the monthly total leads instead of the
                       commission. Measured on the live page, not reasoned: MONTHLY TOTAL came
                       first when Nicole's reference has RECURRING COMMISSION first. */
                    e('div', { className: 'summary__total rl__sidetotal', style: { display: 'flex', flexDirection: 'column' } },
                      /* THE EYEBROW AND THE COMMISSIONS LINK, the same pair the calculator
                         draws above its refer-mode totals. The link opens the overlay that
                         already exists rather than a new one: getReferHelpOverlay carries a
                         "Your commission" tab with the full rate table, so this is the same
                         content the Grow step shows, reached the same way. */
                      e('div', { className: 'rl__forecasthead' },
                        e('span', { className: 'rl__forecasteyebrow' }, 'Your forecast'),
                        (window.GMOverlayModal && typeof window.getReferHelpOverlay === 'function')
                          ? e('button', {
                              type: 'button', className: 'summary__savings-viewall svc__talent-wl-link',
                              onClick: function () { setCommOv(true); }
                            },
                              e('span', { className: 'svc__talent-wl-link__text' }, 'View available commissions'),
                              ' ',
                              e('span', { className: 'svc__talent-wl-link__arrow', 'aria-hidden': 'true' }, '\u203a'))
                          : null),
                      e('div', { className: 'summary__total-row' },
                        e('div', { className: 'summary__total-label' }, e('span', null, 'Monthly total')),
                        e('div', { className: 'summary__total-val' },
                          chosenIds.length === 0 ? '\u2014' : gbp(monthlyTotal))),
                      window.GGReferForecastRows
                        ? e(window.GGReferForecastRows, { total: monthlyTotal, referWho: referWho })
                        : null,
                      e('div', { className: 'summary__total-row summary__total-row--setup-fees' },
                        e('div', { className: 'summary__total-label summary__total-label--setup-fees' },
                          e('span', null, 'Setup & one-off fees')),
                        e('div', { className: 'summary__total-val summary__total-val--setup-fees' },
                          chosenIds.length === 0 ? '\u2014' : gbp(basketTotals().setup))),
                      /* ⚠️ THE GREY "Plus, on top" LINE WAS DELETED HERE, 26 August 2026.
                         Nicole: "we have a separate green line for referral bonus, do not
                         invent a grey text line at the bottom."
                         IT WAS AN INVENTION AND THE GREEN ROW ALREADY EXISTS.
                         GGReferForecastRows draws REFERRAL BONUS as its own green
                         summary__total-row for the investor case, at the same weight as the
                         commission beside it. This surface had instead appended a grey
                         label-and-sentence row at the bottom carrying whoOpt.desc, which said
                         the same thing in the wrong place, in the wrong colour, and after the
                         setup fees rather than beside the money.
                         THE £50 SIGN-UP BONUS IS NOT LOST. It is in the commission tooltip on
                         the row above, which is where the calculator carries it too. */
                      unpriced.length ? e('p', { className: 'rl__fcfloor' },
                        'This is a floor. ' + unpriced.length + (unpriced.length === 1 ? ' service you picked is' : ' services you picked are')
                        + ' quoted individually, so they are not in the number yet.') : null)))))),

          e(ReferralFaqBlock, { key: 'faq' }))
    );
  }

  function PageSkeleton(item, clientType) {
    var spec = clientType === 'agency' ? SOS_PAGES[item.id] : null;
    if (!spec) return Skeleton(item.kind);
    return e('div', { className: 'sp-root', 'aria-hidden': 'true' }, sosNode(spec));
  }

  // THE INNER TAB BAR. Every page in the portal has one and this preview had none,
  // which was the largest structural difference between the glimpse and the real thing.
  // It renders INSIDE gg-sos__blur so it reads as part of the page being glimpsed rather
  // than as chrome we bolted on, which is also where it sits in the portal. Shape follows
  // AccountTabNav, a pill row with the first tab active. No page title above it on purpose:
  // the real h1 differs from the rail label on two pages and is personalised on Home, so
  // writing one here would be inventing copy for a surface we are only mirroring.
  function TabBar(item) {
    var tabs = item && item.tabs;
    if (!tabs || !tabs.length) return null;
    return e('div', { className: 'gg-sos__sk-tabs' },
      tabs.map(function (label, i) {
        return e('span', {
          key: label,
          className: 'gg-sos__sk-tab' + (i === 0 ? ' is-on' : '')
        }, label);
      }));
  }

  // ── THE GLIMPSE CARD IS A METAL-FRAMED CARD. ONE FRAME, NOT TWO. ───────
  // 11 Aug 2026. This card carried .glass-frame; it now carries the metal
  // frame component instead, so the locked preview belongs to the same metal
  // family as the pricing sidebar (.summary) and the white-label banner
  // (.alert--metal). NOTE this is the CARD only. The rail's selected pill still
  // takes the thin glass frame and must keep it, because that mirrors the active
  // link in FreelancerRail.tsx and is a different component in a different place.
  //
  // THE FRAME SITS ON THE CARD ITSELF, there is no wrapper element. That is how
  // every other metal surface in the app is built: the frame is the card's own
  // edge treatment, not a second box around a first one. A short-lived earlier
  // version of this change nested a glass card inside a metal wrapper, which put
  // two 9-slice bevels on one object and forced a second, larger radius on the
  // outer box. Both are gone.
  //
  // Slice is 18px, matching the two existing metal Frame usages in
  // sections.v2.jsx. The metal-frame/ WebPs are byte-identical to the legacy
  // 22px set (same blob SHAs, different filenames), so this is the SAME metal,
  // just driven through the component instead of a bespoke ::before.
  //
  // RADIUS IS THE GLOBAL CARD TOKEN, 18px, with the literal as a fallback so it
  // is correct whether or not the token branch has landed. .gg-sos__prompt in
  // app.v2.css still says 22px, which is what this overrides; that 22px was set
  // against 22px glass slices and is exactly the mismatch the token exists to
  // fix. Fold this into the CSS rule and delete the override when convenient
  // — it is inline only because app.v2.css could not be edited through the
  // tooling used for this change.
  //
  // CONCRETE FILL, the same one every other metal-framed card gets. app.v2.css
  // gives .summary, .svc-margin--gold.svc-margin--metal and .alert--metal a warm
  // white wash over UI_Card_Concrete_02_Seamless.webp, and this card was the one
  // metal surface still filled flat white. Values copied from that rule, tile size
  // included: it is pinned at 480px so the grain reads at the SAME scale on every
  // card whatever its dimensions, so do not switch it to cover/contain.
  //
  // The path differs from the CSS one by design. In app.v2.css the url resolves
  // against the stylesheet, so it is bare; in an inline style it resolves against
  // the document, which carries <base href="/">, so it needs the assets/ prefix.
  var PROMPT_CARD_STYLE = {
    borderRadius: 'var(--global-card-component-border-radius, 18px)',
    background:
      'linear-gradient(rgba(255, 251, 244, 0.50), rgba(255, 251, 244, 0.50)), ' +
      "url('assets/UI_Card_Concrete_02_Seamless.webp') 0 0 / 480px 480px repeat"
  };

  // ---------------------------------------------------------------- live labels
// THE PARTNER RAIL IS NO LONGER HAND-COPIED. 13 Aug 2026.
// It used to be, and it went stale exactly as predicted: the portal renamed
// Opportunities to Get Work and My Projects to Projects, and this file sat a
// rename behind on a PUBLIC page until somebody happened to look.
//
// KEY ON portalId. 13 Aug 2026: each agency rail item now carries an EXPLICIT
// portalId holding the portal's own id, so the join no longer depends on a
// coincidence. It previously keyed on our icon field, which happened to equal
// their id one for one, and nothing enforced that.
// DO NOT GO BACK TO OUR OWN id. Ours were invented here (opps, projects, refer,
// earn); theirs are explore, clients, referrals, earnings. Only three of seven
// match, so keying on our id silently drops four items and never errors.
// Founder and investor rails carry NO portalId, which is correct: the endpoint is
// partners-only, so they fall through to their baked-in labels.
//
// LABELS ONLY, AND PARTNER ONLY. Order, icons, kind and tabs stay ours because
// they drive the blurred skeleton. The endpoint is partners-only, so the founder
// and investor rails are deliberately left alone.
//
// FAILURE IS SILENT AND SAFE. Any error, non-200, or empty list and we keep the
// baked-in labels. This must never be able to empty the rail.
var GG_NAV_URL = 'https://portal.gogorilla.com/api/v1/public/nav/partners';
var __ggNavLabels = null;
var __ggNavOrder = null;   // the portal's item order, so the rail follows theirs
var __ggNavPromise = null;

/* THE PORTAL NOW PUBLISHES ITS RAIL TOKENS. 13 Aug 2026, same day as the label wiring.
   theme.navHover on GET /api/v1/public/nav/partners. CAMELCASE, not nav-hover: every other
   key in that payload is camelCase and the portal flagged a hyphenated one as a trap.

   VALIDATED BEFORE INJECTION, DELIBERATELY. This is a remote string going into a style
   property, so it is checked against a strict hex pattern first. Anything else is ignored
   and the CSS fallback stands. Never pass a remote value into setProperty unchecked.

   CACHING NOTE: that endpoint sends cache-control public, max-age=300, and its version
   field did NOT change when the theme block was added, so version cannot be used to detect
   staleness. A returning visitor may see up to five minutes of old theme. Harmless here,
   because the fallback and the previous value are both valid colours. */
var GG_NAV_HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;

/* THE SEATBELT. Row rec5XPJjRt9wGg2t4. The coupling itself is now FIXED, because each
   item carries an explicit portalId rather than relying on icon happening to match. This
   assertion stays anyway, because portalId alone cannot catch everything: it does not
   notice the portal ADDING an item we do not render, or REMOVING one we do. Both change
   what a visitor sees and neither throws.
   Add a rail item without a portalId and it will show here as unmatched, which is the
   intended nudge rather than a fault. */
function ggAssertNavIds(items) {
  try {
    if (typeof console === 'undefined' || !console.warn) return;
    var rail = (typeof RAILS !== 'undefined' && RAILS && RAILS.agency) ? RAILS.agency : null;
    if (!rail || !Array.isArray(items)) return;
    /* An item with NO portalId is reported SEPARATELY rather than filtered away. An
       earlier version quietly dropped it, which silenced the single most likely mistake:
       adding a rail item and forgetting its key. */
    var noKey = rail.filter(function (it) { return !it.portalId; })
                    .map(function (it) { return it.id; });
    var ours = rail.map(function (it) { return it.portalId; }).filter(Boolean);
    var theirs = items.map(function (it) { return it && it.id; });
    var missing = ours.filter(function (i) { return theirs.indexOf(i) === -1; });
    var extra = theirs.filter(function (i) { return ours.indexOf(i) === -1; });
    if (missing.length || extra.length || noKey.length) {
      console.warn('[gg-sos] partner rail is out of step with the portal nav endpoint. '
        + 'Rail items with no portalId: [' + noKey.join(', ') + ']. '
        + 'Ours with no portal match: [' + missing.join(', ') + ']. '
        + 'Portal items we do not render: [' + extra.join(', ') + ']. '
        + 'These items will keep their baked-in labels. See tracker row rec5XPJjRt9wGg2t4.');
    }
  } catch (e) {}
}

function applyNavTheme(theme) {
  if (!theme || typeof theme !== 'object') return;
  var v = theme.navHover;
  if (typeof v !== 'string') return;
  v = v.trim();
  if (!GG_NAV_HEX.test(v)) return;
  try { document.documentElement.style.setProperty('--gg-nav-hover', v); } catch (e) {}
}

function ggFetchNavLabels() {
  if (__ggNavPromise) return __ggNavPromise;
  /* PLAIN FETCH, AND THIS TIME THE EXIT CONDITION WAS ACTUALLY MET AND MEASURED.
     17 Aug 2026. The five minute cache-bust bucket is GONE. It was removed once before,
     on 13 Aug, restored the same day when the bare URL failed twice, and this file then
     carried TWO STACKED COMMENT BLOCKS THAT CONTRADICTED EACH OTHER for four days, one
     saying the bucket was removed and one explaining why it existed. That is why this is
     now a single block. If you remove or restore the bucket, rewrite this, do not stack.

     WHY IT EXISTED. On 13 Aug the bare URL failed twice in two different ways whilst a
     query string worked: first it served a payload with no theme block, then it failed
     CORS outright. The root cause was never ours and never really CORS either. It is a
     CACHING rule producing a CORS symptom: the route is public max-age 60, the edge keeps
     ONE copy, Vary Origin is not honoured on arbitrary request headers, so whichever
     request populated the cache decided the header for everybody else. A curl, a crawler
     or an uptime probe sends no Origin, the old middleware echoed nothing, and that
     origin-less copy was served to every browser for the next five minutes.

     WHY IT IS SAFE TO REMOVE, MEASURED 17 Aug ON A BROWSER THAT HAD NEVER SEEN THE
     ENDPOINT, not inferred and not read off a curl:
       Access-Control-Allow-Origin is the WILDCARD on both cache states. MISS with age 0,
       and HIT with age 33, both carried it. THAT is the fix. A wildcard copy is valid for
       every origin no matter who populated it, so the whole class of failure is gone by
       construction rather than by care.
       Access-Control-Allow-Credentials is absent. A wildcard plus credentials is rejected
       outright by browsers, so its removal was load bearing, not tidying.
       There is NO ETag and NO Last-Modified. A 304 carries no ACAO, which is what made the
       13 Aug failure unrecoverable: a client revalidated, was refused, and never healed.
       With nothing to revalidate against there is no conditional request and no 304 path.
       The rail rendered the LIVE labels on a cold load, Get Work and Projects rather than
       our baked-in Opportunities and My Projects, and theme.navHover resolved to #cce2ff.

     THE ASSERTION THAT ACTUALLY MATTERS IF THIS EVER BREAKS AGAIN. Read
     Access-Control-Allow-Origin. If it is the wildcard, this class of bug is not what you
     are looking at. If it ever echoes a specific origin, the bug is back regardless of
     whether the page rendered. A COLD LOAD ALONE IS NOT SUFFICIENT: a fresh profile proves
     your client holds no copy, it proves nothing about which copy the edge node you hit is
     holding, so a cold load can pass on luck. Assert the header, then confirm with the load.
     And do not assert on labels alone. Five of our seven fallback labels match live, so a
     total failure of this fetch renders almost identically to a total success. Assert on
     theme.navHover or on data.version, which our fallback cannot coincidentally match.

     NOTE ON stale-while-revalidate. Their route source sets swr 3600 but production serves
     "public, max-age=60" with no swr at all, confirmed from three independent measurements
     on 17 Aug. An earlier version of this comment blamed Vercel's edge for consuming it.
     That was a guess and it was wrong: it is not reaching the edge either. Theirs to fix,
     logged with them, deliberately untouched before the board demo. So the worst case here
     is a 60 second max-age with a full round trip on expiry, not an hour long stale window
     and not a guaranteed instant serve. Do not reason about a guarantee we do not have. */
  __ggNavPromise = fetch(GG_NAV_URL, { credentials: 'omit' })
    .then(function (r) { return r.ok ? r.json() : null; })
    .then(function (j) {
      var items = j && j.data && (j.data.items || j.data);
      if (!Array.isArray(items) || !items.length) return null;
      var map = {};
      items.forEach(function (it) {
        if (it && it.id && typeof it.label === 'string' && it.label) map[it.id] = it.label;
      });
      applyNavTheme(j && j.data && j.data.theme);
      ggAssertNavIds(items);
      __ggNavOrder = items.map(function (it) { return it && it.id; }).filter(Boolean);
      __ggNavLabels = map;
      return map;
    })
    .catch(function () { return null; });
  return __ggNavPromise;
}

/* ORDER FOLLOWS THE PORTAL TOO, from 13 Aug evening. Their 20:30 deploy moved Grow from
   fourth to second on Nicole's ruling, and our rail did NOT follow, because until then we
   only consumed labels and theme. That left her own ruling live in the portal and
   contradicted on the public page.

   RULES, and they matter more than the sort itself:
   - Items we can match sort by THEIR index.
   - Items with no portalId, or whose portalId the portal does not list, KEEP THEIR RELATIVE
     ORDER AND GO TO THE END. Nothing is ever dropped. A rail that silently loses an item is
     worse than one in the wrong order.
   - No order, empty order, or a failed fetch returns the rail untouched, so our baked-in
     order is the fallback exactly as our baked-in labels are.
   Order is applied AFTER relabelling so the two cannot disagree about which item is which. */
function reorderByPortal(rail, order) {
  if (!order || !order.length) return rail;
  var known = [], unknown = [];
  rail.forEach(function (it, i) {
    var pos = it.portalId ? order.indexOf(it.portalId) : -1;
    if (pos === -1) unknown.push({ it: it, i: i });
    else known.push({ it: it, pos: pos, i: i });
  });
  known.sort(function (a, b) { return (a.pos - b.pos) || (a.i - b.i); });
  return known.map(function (x) { return x.it; })
              .concat(unknown.map(function (x) { return x.it; }));
}

function useLiveRailLabels(rail, apply) {
  var st = React.useState(__ggNavLabels);
  var labels = st[0], setLabels = st[1];
  React.useEffect(function () {
    if (labels) return undefined;
    var alive = true;
    ggFetchNavLabels().then(function (m) { if (alive && m) setLabels(m); });
    return function () { alive = false; };
  }, [labels]);
  if (!apply || !labels) return rail;
  var relabelled = rail.map(function (it) {
    var live = labels[it.portalId];
    return (live && live !== it.label) ? Object.assign({}, it, { label: live }) : it;
  });
  return reorderByPortal(relabelled, __ggNavOrder);
}

function SignedOutSidebar(props) {
    var state = props && props.state ? props.state : {};
    var clientTypeId = state.clientTypeId || null;
    var openId = React.useState(null);
    var open = openId[0], setOpen = openId[1];

    var on = enabled() && !!clientTypeId;

    // COLLAPSIBLE SINCE 7 Aug 2026, mirroring FreelancerRail.tsx which gained this the
    // same day. It hides FULLY rather than narrowing to icons, which is the portal's
    // choice and its reason applies here too: the calculator wants the whole width.
    // No collapse state: the rail is always expanded. See the note above.
    var railRef = React.useRef(null);

    // Shift the calculator right whilst the rail is showing. Done as a class on the
    // mount element so no JSX restructuring is needed and the rail can be removed
    // cleanly. gg-scope IS the mount element, see the embed contract.
    React.useEffect(function () {
      var host = document.querySelector('.gg-scope');
      if (!host) return;
      if (on) host.classList.add('gg-sos-on'); else host.classList.remove('gg-sos-on');
      host.classList.remove('gg-sos-collapsed');
      return function () {
        try { host.classList.remove('gg-sos-on'); host.classList.remove('gg-sos-collapsed'); } catch (err) {}
      };
    }, [on]);

    // The inert / aria-hidden effect that hid the rail whilst collapsed is removed with the
    // collapse itself, 19 Aug 2026. It existed only so a folded rail was not a set of
    // invisible tab stops; an always-expanded rail needs neither.

    // A client type change closes any open glimpse, so the overlay never survives
    // into a pathway whose rail does not contain that page.
    React.useEffect(function () { setOpen(null); }, [clientTypeId]);

    // HOOK ORDER. useLiveRailLabels contains a useState and a useEffect of its own,
    // so calling it BELOW the early return made this component run 6 hooks when no
    // client type was chosen and 8 once one was. That is React error 310 and it blanked
    // the entire page on every client type, not just Resellers. Moved above the gate on
    // 14 August 2026. Every hook must run on every render, so do not move it back.
    var rail = RAILS[clientTypeId] || RAILS.agency;
    rail = useLiveRailLabels(rail, rail === RAILS.agency);

    /* ── THE ONE WAY IN FROM THE CALCULATOR. 25 Aug 2026, FREELANCER 20 at 21:47.
       Nicole: clicking Refer a business should take you to the Referrals view rather
       than leaving you in Grow. setOpen lives in this component, so the calculator
       cannot reach it without a handle, and this is the handle.
       REGISTERED IN AN EFFECT AND REMOVED ON UNMOUNT, so a stale closure can never
       outlive the component and drive a setOpen belonging to a tree that is gone.
       IT RETURNS A BOOLEAN because the caller has to know whether it worked: the rail
       does not render at all below 820px or before a client type is picked, and a
       caller that assumed success would silently do nothing on both. */
    React.useEffect(function () {
      window.ggOpenReferralsView = function ggOpenReferralsView() {
        try { setOpen('refer'); return true; } catch (e) { return false; }
      };
      return function () { try { delete window.ggOpenReferralsView; } catch (e) { window.ggOpenReferralsView = undefined; } };
    }, []);

    /* PAGE SCROLL LOCK WHILST A GLIMPSE IS OPEN. 26 Aug 2026. Nicole raised the two
       scrollbars on 25 Aug and this is the fix.

       TWO SCROLLBARS IS THE SYMPTOM, ONE UNLOCKED PAGE IS THE CAUSE. The glimpse is
       position: fixed with overflow-y: auto and its content is taller than its box,
       measured 1919px in a 900px box on Referrals, whilst the document underneath is
       still scrollable at 1549px against 900px. Both bars are real and both work, which
       is why it reads as a fault rather than as a rendering artefact.

       IT COVERS EVERY RAIL SURFACE, NOT ONLY REFERRALS, because it keys on open, which
       is the single piece of state deciding whether any glimpse is on screen.

       THE GUTTER LINE IS NOT DECORATION, AND IT IS WHY THIS IS NOT A ONE LINER. Hiding
       the root overflow removes the scrollbar, which WIDENS the initial containing block,
       and every position fixed element moves right with it. Measured on the live page
       BEFORE this was written: the glimpse right edge went 1425 to 1440 at a 1440
       viewport, a 15px jump on open. scrollbar-gutter stable keeps the gutter reserved
       whilst the overflow is hidden and puts the edge back to 1425 exactly.
       PADDING ON THE ROOT DOES NOT FIX THIS, which is the trap. A fixed element resolves
       against the viewport rather than against the root padding box, so the obvious
       compensation is the one that does nothing.
       Set BEFORE the overflow, so the gutter is reserved before the bar is taken away.
       On a browser without scrollbar-gutter the lock still works and the 15px shift comes
       back. That is graceful degradation and is deliberately not worked around, because a
       second untested mechanism is worse than a known small shift on old Safari.

       TOUCHING documentElement IS SAFE HERE, and it is worth saying so because the embed
       contract forbids it in general. This file is NOT in the manifest. Ten scripts are
       served to the portal and signed-out-sidebar.jsx is not one of them, checked against
       assets/embed-manifest.json rather than assumed, so this can never lock a host page.

       IT RESTORES THE SCROLL POSITION. Without the scrollTo the page sits at the top when
       the glimpse closes, which is the half of this fix a reader notices only when it is
       missing.

       ABOVE THE EARLY RETURN, DELIBERATELY. This component returns null when no client
       type is picked. A hook below that gate changes the hook count between renders,
       which is React error 310, and it has already blanked this page once. */
    React.useEffect(function () {
      if (!open) return;
      var de = document.documentElement;
      var y = window.scrollY || de.scrollTop || 0;
      var prevOverflow = de.style.overflow;
      var prevGutter = de.style.scrollbarGutter;
      de.style.scrollbarGutter = 'stable';
      de.style.overflow = 'hidden';
      return function () {
        de.style.overflow = prevOverflow;
        de.style.scrollbarGutter = prevGutter;
        try { window.scrollTo(0, y); } catch (e) {}
      };
    }, [open]);

    if (!on) return null;

    var isInvestor = clientTypeId === 'investor';
    var item = open ? rail.filter(function (x) { return x.id === open; })[0] : null;
    /* ONE PREDICATE FOR ALL THREE REFER BRANCHES, and it has to be a STATEMENT here rather than
       inline below: referrals is special-cased in three separate places -- the unblurred wrapper,
       the ReferralsLive body and the suppressed sign-up card -- and they would drift apart the
       moment one was edited. When the portal's own referrals panel is being served, referrals is an
       ordinary blurred glimpse like every other surface, so all three follow this one switch. */
    var _referStaysLive = !!(item && item.id === 'refer' && !portalPreviewFor(item.id));

    return e('div', { className: 'gg-sos' },
      // THE RAIL IS A PLAIN PANEL AND THE GLASS PILL IS THE SELECTED ITEM. Read off
      // FreelancerRail.tsx: the aside is "bg-white/70 backdrop-blur-[8px] border-r
      // border-slate-200/80 w-24", and it is the ACTIVE LINK that carries
      // thin-glass-frame. An earlier pass here put the frame on the rail itself,
      // which is the same component in the wrong place.
      e('nav', { className: 'gg-sos__rail', id: 'gg-sos-rail', ref: railRef, 'aria-label': 'Portal preview' },
        // THE HEAD, and it is the PORTAL'S OWN FILE rather than a copy. It serves with
        // access-control-allow-origin star, so linking it live means a rebrand there
        // lands here with no deploy. Same reasoning as asking them to serve the nav.
        e('div', { className: 'gg-sos__brand' },
          e('img', {
            className: 'gg-sos__brandimg',
            src: 'https://portal.gogorilla.com/brand/gogorilla-head.png',
            alt: '', 'aria-hidden': 'true', loading: 'lazy', decoding: 'async'
          })),
        rail.map(function (it) {
          var live = it.kind === 'live';
          return e('button', {
            key: it.id, type: 'button',
            // SELECTED TAKES thin-glass-frame, verbatim from FreelancerRail.tsx:
            //   active ? 'thin-glass-frame text-slate-900'
            //          : 'text-slate-600 hover:bg-blue-50 hover:text-slate-900'
            // Selected here means whichever page the visitor is looking at, so it is
            // Grow until they open a glimpse and then it follows them.
            className: 'gg-sos__item' + (live ? ' is-live' : '')
              + ((open ? open === it.id : live) ? ' is-selected thin-glass-frame' : ''),
            'aria-current': live && !open ? 'page' : undefined,
            /* TWO WAYS BACK TO GROW, Nicole 7 August: click Grow itself, or the
               Back to Grow trail in the glimpse. So Grow is a real control rather than
               a decorative highlight, and clicking it closes whatever is open.

               ⚠️ AND BOTH OF THEM MUST CLEAR REFER MODE. 26 August 2026, reported by
               Nicole and reproduced before it was touched.
               THE DEFECT: the trail cleared refer mode and this did not. So a reader who
               ticked Refer a business, landed on Referrals, then clicked Grow on the rail
               arrived back on the calculator still in refer mode, with the referral
               forecast in the sidebar, the "You are now referring" banner up, and NO
               control to turn it off, because the Refer a business checkbox only renders
               in the NOT-referring branch. Measured, not reasoned: referModeStillOn true,
               checkbox absent, RECURRING COMMISSION present on Grow.
               THIS IS THE EXACT TRAP THE 25 AUGUST NOTE DESCRIBED, arriving through a
               door nobody had guarded: "closing without clearing leaves the calculator
               showing You are now referring with no control to dismiss it, and nothing
               errors." It was written about the trail, the trail was fixed, and the rail
               was never checked because it looked like a different control.
               TWO EXITS, ONE BEHAVIOUR. Whatever closes the Referrals view clears the
               mode, so there is no ordering in which a reader can end up stranded. */
            onClick: function () {
              if (live) {
                try { if (typeof window.ggExitReferMode === 'function') window.ggExitReferMode(); } catch (err) {}
                setOpen(null);
              } else {
                setOpen(it.id);
              }
            },
            // DESCRIBEDBY RATHER THAN A title ATTRIBUTE. A native title would paint a
            // second, unstyled tooltip beside ours after a browser delay we do not
            // control, and it cannot carry the pointer. All seven items measured
            // title null, aria-label null and aria-describedby null before this.
            'aria-describedby': RAIL_TIPS[it.id] ? ('gg-sos-tip-' + it.id) : undefined
          },
            // NO PADLOCK ON REFERRALS. It renders live now, so a lock badge beside it
            // promises a wall that is not there - and it was still sitting on the rail
            // after the lock CARD was removed from the surface, which is the half of
            // this I missed. Grow is live because you are standing on it; Referrals is
            // live because we can honour its zero state. Everything else keeps the lock.
            e('span', { className: 'gg-sos__iconwrap' }, Icon(it.icon), (live || it.id === 'refer') ? null : Padlock()),
            e('span', { className: 'gg-sos__label' }, it.label),
            // THE TIP IS A CHILD OF THE BUTTON, which is what lets a pure CSS hover
            // and focus-visible drive it with no state and no listener. The rail sets
            // no overflow, so an absolutely positioned child escapes its 96px happily.
            RAIL_TIPS[it.id]
              ? e('span', { className: 'gg-sos__tip', id: 'gg-sos-tip-' + it.id, role: 'tooltip' }, RAIL_TIPS[it.id])
              : null);
        })),


      /* ONE PREDICATE FOR ALL THREE REFER BRANCHES. Referrals used to be special-cased in three
         separate places -- the unblurred wrapper, the ReferralsLive body and the suppressed sign-up
         card -- and they would have drifted apart the moment one was edited. When the portal's own
         referrals panel is being served, referrals is an ordinary blurred glimpse like every other
         surface, so all three follow the same switch. */
      item ? e('div', { className: 'gg-sos__glimpse' + (_referStaysLive ? ' gg-sos__glimpse--live' : ''), role: 'dialog', 'aria-modal': 'false', 'aria-label': item.label },
        // REFERRALS IS THE ONE SURFACE WE CAN HONOUR, so it renders live: no blur
        // wrapper, no aria-hidden, no lock card below. See ReferralsLive above for
        // why this one and not the others.
        _referStaysLive
          // e(ReferralsLive) NOT ReferralsLive(). Calling it directly inlines its
          // hooks into SignedOutSidebar's hook list, and because this branch is
          // conditional the hook COUNT then changes when the surface opens, which
          // is React error #310. As an element it owns its own hook slot.
          ? e('div', { className: 'gg-sos__live' }, e(ReferralsLive, {
              onLeave: function () { setOpen(null); },
              saved: state.referralResult || (typeof window !== 'undefined' ? window.__ggReferralResult : null) || null,
              /* BOTH THE GLOBAL AND THE STORE, and they are not redundant. The global is
                 what sections.v2.jsx's three readers fall back to inside this session; the
                 dispatch is what survives the reload, because only the reducer's value
                 reaches gg.pricing-cart.v1. Writing one without the other fixes half of it. */
              onRegistered: function (v) {
                try { window.__ggReferralResult = v; } catch (e) {}
                try { if (typeof props.dispatch === 'function') props.dispatch({ type: 'SET_REFERRAL_RESULT', value: v }); } catch (e) {}
              }
            }))
          /* THE TAB STRIP STAYS OURS. Their payload sets tabStrip:false and says why:
             tab visibility resolves from feature flags at request time and the payload is
             generated at build time. So they cannot publish it and we keep ours. */
          /* THE TAB STRIP IS OURS AND IT DOES NOT BELONG ABOVE THEIR PANEL. It is a hand-drawn
             strip from the traced era, kept because their payload sets tabStrip:false. On Home it
             invents tabs the real page does not have -- Nicole spotted "Overview | Tasks" sitting
             above a Home that has no tab bar. When we are serving their composition we serve THEIR
             composition, not ours with theirs inside it. Traced surfaces keep the strip. */
          : e('div', { className: 'gg-sos__blur' },
              portalPreviewFor(item.id) ? null : TabBar(item),
              portalPreviewFor(item.id)
                ? e(PortalPreview, {
                    surface: portalPreviewFor(item.id),
                    fallback: PageSkeleton(item, clientTypeId)
                  })
                : PageSkeleton(item, clientTypeId)),
        // .gg-frame-card supplies the position/overflow/isolation the frame needs and
        // lifts every sibling above it, so nothing below needs a z-index of its own.
        _referStaysLive ? null : e('div', { className: 'gg-sos__prompt gg-frame-card', style: PROMPT_CARD_STYLE },
          // The frame is the FIRST child and is purely decorative.
          e('span', { className: 'gg-frame gg-frame--metal', style: { '--gg-frame-slice': '18px' }, 'aria-hidden': 'true' }),
          // THE PADLOCK IS THE BRAND ASSET, NOT THE INLINE GLYPH. assets/icons/lock.webp,
          // shown bare exactly as .alert--metal shows check.webp. The grey circular chip
          // that used to sit behind the drawn padlock is gone with it: these icons are
          // rendered art and a flat chip behind one reads as two icons stacked. The rail
          // items keep Padlock(), which is correct there, it is a 8px currentColor badge
          // and an image cannot take a colour from its host.
          e('img', {
            className: 'gg-sos__promptlockimg',
            src: 'assets/icons/lock.webp',
            alt: '', 'aria-hidden': 'true', loading: 'lazy', decoding: 'async',
            style: { display: 'block', width: '44px', height: '44px', objectFit: 'contain', margin: '0 auto 0.75rem' }
          }),
          e('h3', { className: 'gg-sos__prompttitle' }, item.label),
          e('p', { className: 'gg-sos__promptbody' }, BLURB[item.id] || ''),
          isInvestor
            ? e('p', { className: 'gg-sos__promptnote' }, 'The Investor Portal has not launched yet.')
            : null,
          // THE SIGN-IN CTA IS WIRED. Nicole, 7 August: it goes to portal.gogorilla.com/login.
          // Worth knowing what that page actually is, because the label undersells it. There
          // is NO PASSWORD. Entering an email mints a magic link, so for somebody with no
          // account this is account creation and sign-in in one step, which is why it can sit
          // behind a button that says sign up.
          //
          // *** IT IS GATED TODAY AND THE GATE IS SILENT. *** POST /api/v1/auth/magic-link is
          // wrapped in FEATURE_MAGIC_LINK_AUTO_INVITE. With the flag off it returns 200 with
          // sent: true AND SENDS NOTHING, deliberately, so the response cannot be used to
          // enumerate accounts. The cost is that a real person gets a check-your-email screen
          // and no email. Raised with the portal on 7 August. If this CTA appears to do
          // nothing, that is where to look first, not here.
          //
          // The other two are deliberately NOT wired yet and must not be guessed at. The
          // investor waiting list has no destination decided, and Book a call has no chosen
          // event for founders or investors, only the freelancer assessment call which is the
          // wrong meeting for both. Dead is better than wrong here.
          e('div', { className: 'gg-sos__actions' },
            // btn btn--primary IS THE NEXT BUTTON, the same gradient the calculator
            // uses to move you through the flow, and btn--ghost is the grey pill
            // beside it. Nicole, 7 August. gg-sos__cta now only makes them fill the
            // card width, it carries no colour of its own any more.
            /* ⚠️ THIS BUTTON POINTED AT A DEAD END UNTIL 25 AUGUST 2026. Nicole ruled the
               change after Loom 20. It read "Sign up to explore our platform" and linked to
               portal.gogorilla.com/login, in a NEW TAB.

               WHY THAT WAS WRONG, AND IT IS TWO SEPARATE FAULTS.
               ONE, THERE IS NO SIGN-UP TO OFFER. The portal has no self-serve registration,
               so /login is a page this visitor cannot get past. The word "sign up" described
               a thing that does not exist, which is the worst kind of copy defect because it
               reads as a working feature.
               TWO, IT BORROWED THE WRONG MOMENT'S LANGUAGE. "Sign in to your portal" is real
               and it lives in sections.v2.jsx, after a referral link is minted or a call is
               booked. At that point platform-api.js has a continuity token and the portal
               grants an instant session. THAT button is correct. This one is shown to someone
               who has no account yet, so it had nothing to sign in to.

               WHAT CREATES AN ACCOUNT IS THE CALCULATOR, so that is where this now goes.
               Finishing a quote creates the account and signs them in, and booking a call does
               the same by the other route, which is the ghost button directly below.

               NO NAVIGATION AT ALL, AND THAT RETIRES THE NEW-TAB RULE ON THIS BUTTON. The old
               comment opened a new tab because "the visitor has a quote in progress on this
               page and a same-tab navigation throws it away", Nicole 7 August. Correct, and it
               stops applying the moment the destination is this same page: setOpen(null) closes
               the glimpse and puts them back on the calculator with the quote untouched. The
               rule is not overruled, its precondition is gone.

               ⚠️ IT NOW DOES THE SAME THING AS "Back to the pricing calculator" BELOW IT.
               Deliberately not resolved here. That link carries Nicole's 7 August wording
               ruling and deleting it is her call, not this lane's. Raised with her. */
            isInvestor
              ? e('button', { type: 'button', className: 'btn btn--primary gg-sos__cta' }, 'Join the waiting list')
              : e('button', {
                  type: 'button',
                  className: 'btn btn--primary gg-sos__cta',
                  onClick: function () { setOpen(null); }
                }, 'Build your quote'),
            // BOOK A CALL, wired 7 August to the team link Nicole supplied. NEW TAB
            // rather than a Cal.com overlay: this is already an overlay, and stacking
            // a second one on top of it is the friction the glimpse exists to avoid.
            // Her own instinct, and it also keeps the quote alive on this page.
            e('a', {
              className: 'btn btn--ghost gg-sos__cta',
              href: 'https://cal.com/team/gogorilla/call',
              target: '_blank', rel: 'noopener noreferrer'
            }, 'Book a call'),
              null),
          /* THE "Back to the pricing calculator" LINK WAS REMOVED HERE, 25 Aug 2026,
             on Nicole's ruling. Recorded rather than silently deleted, because the
             wording it carried was a decision she made and somebody will otherwise
             wonder where it went.

             WHAT IT SAID AND WHY: 'Back to the pricing calculator', deliberately NOT
             'Back to Grow', because Grow is portal vocabulary and this visitor has
             never been in the portal, so it named the thing they were actually
             standing on. Nicole raised it on 7 August and that reasoning is still
             correct.

             WHY IT WENT ANYWAY: the primary button above it now does exactly the same
             thing, setOpen(null), under the better label 'Build your quote'. Two
             controls with identical behaviour and different labels is the confusion
             this card was being fixed for. The label ruling did not lose, it MOVED --
             'Build your quote' also avoids portal vocabulary and also names something
             the visitor can picture doing.

             THE SECOND WAY BACK STILL EXISTS and is documented on the rail item
             handler: clicking Grow closes whatever is open. Nothing became
             unreachable. */
          null)) : null
    );
  }

  window.SignedOutSidebar = SignedOutSidebar;
})();
