PageSourceSearch

https://members.yec.co/site.js

js yec.co collected 2026-10-02 03:25:50 UTC 84,173 bytes, 1,447 lines download raw bytes

1/* =================================================================
2   YEC. : shared site behavior (full-site preview build)
3   Nav + footer injection, scroll reveals, mobile menu, orbit layout,
4   FAQ accordion, countdown, and auto-hiding of concluded events.
5   Adapted from the Claude Design handoff: tweaks panel removed,
6   real URLs and social profiles wired in.
7   ================================================================= */
8(function () {
9  "use strict";
10
11  /* ---------- Browser error beacon (ops log, docs/OPS-LOG.md) ----------
12     Uncaught errors and unhandled rejections report to /api/ops/client-error
13     so a broken page shows up in the tools.yec.co error log instead of only
14     in the browser console of a visitor who never tells us. Noise control:
15     production hostnames only (members.yec.co is the portal's from Oct 1),
16     extension errors dropped, at most 3 reports per page load (a render loop
17     must not become a flood), pathname only (query strings carry preference
18     tokens and must never be logged), and the server enforces its own flood
19     cap on top. */
20  (function () {
21    if (["yec.co", "www.yec.co", "members.yec.co"].indexOf(location.hostname) < 0) return;
22    var sent = 0;
23    function head(s, n) { return s == null ? "" : String(s).slice(0, n); }
24    function beacon(p) {
25      if (sent >= 3) return;
26      sent++;
27      p.page = head(location.pathname, 120);
28      try {
29        fetch("/api/ops/client-error", {
30          method: "POST",
31          headers: { "content-type": "application/json" },
32          body: JSON.stringify(p),
33          keepalive: true,
34        }).catch(function () {});
35      } catch (e) { /* the beacon must never become the error */ }
36    }
37    window.addEventListener("error", function (e) {
38      // resource-load failures fire here with no message; the sitecheck
39      // monitor owns those, so only genuine script errors report
40      if (!e || (!e.message && !e.error)) return;
41      var src = head(e.filename, 200);
42      if (/-extension:\/\//.test(src)) return; // a visitor's extension is not our bug
43      beacon({ message: head(e.message, 300), src: src, line: e.lineno | 0, col: e.colno | 0,
44        stack: head(e.error && e.error.stack, 500) });
45    });
46    window.addEventListener("unhandledrejection", function (e) {
47      var r = e && e.reason;
48      beacon({ message: head((r && (r.message || r)) || "unhandled rejection", 300),
49        src: "unhandledrejection", stack: head(r && r.stack, 500) });
50    });
51  })();
52
53  /* ---------- Site config ---------- */
54  var ROOT = "";
55  /* The public home, for the signed-out header's logo and "Back to yec.co".
56     From Oct 1 the portal's host is members.yec.co, where "/" opens sign-in
57     (MEMBERS-DOMAIN-PLAN 4.2), so there the link names the public twin the
58     router bounces to. An exact list, as in the router's hosts.js: never
59     "host is not members", or staging and previews would leave for yec.co. */
60  var PUBLIC_TWIN = { "members.yec.co": "https://yec.co", "members-staging.yec.co": "https://staging.yec-coming-soon.pages.dev" };
61  var HOME = (PUBLIC_TWIN.hasOwnProperty(location.hostname) ? PUBLIC_TWIN[location.hostname] : ROOT) + "/";
62
63  // Team review widget: pre-launch only, and ONLY on gated surfaces — the
64  // public pages that wear this chrome (404, legal, launch) must not show
65  // internal tooling (audit 7/11). Cutover rewrites ROOT to "" = never public.
66  if (ROOT && (location.pathname.indexOf("/preview") === 0 || location.pathname.indexOf("/blog") === 0)) {
67    var yr = document.createElement("script");
68    yr.src = ROOT + "/feedback.js";
69    yr.defer = true;
70    document.head.appendChild(yr);
71  }
72  var NAV = [
73    { label: "About", href: ROOT + "/about" },
74    { label: "The Council", href: ROOT + "/the-council" },
75    { label: "Membership", href: ROOT + "/membership" },
76    { label: "Blog", href: "/blog" },
77    { label: "Events", href: ROOT + "/events" }
78  ];
79  var MORE = [
80    { label: "Member Stories", href: ROOT + "/stories" },
81    { label: "FAQ", href: ROOT + "/faq" },
82    { label: "Contact", href: ROOT + "/contact" }
83  ];
84  /* Member Login lands on the portal in the same tab: sign-in when signed
85     out, Home when signed in (S1-19). check-build fails on a Mighty link. */
86  var PORTAL = ROOT + "/member-login"; // flips from Mighty to /members/ at noon Oct 1 (functions/member-login.js)
87  var APPLY = ROOT + "/apply";
88
89  var SOCIAL = [
90    { label: "LinkedIn",  href: "https://www.linkedin.com/company/yec/", icon: '<path d="M20.45 20.45h-3.56v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.23.79 24 1.77 24h20.45c.98 0 1.78-.77 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"/>' },
91    { label: "Instagram", href: "https://www.instagram.com/yec", icon: '<rect x="2" y="2" width="20" height="20" rx="5.5" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4.6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="17.6" cy="6.4" r="1.4"/>' },
92    { label: "X",         href: "https://x.com/yec", icon: '<path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24h-6.66l-5.21-6.82-5.97 6.82H1.68l7.73-8.84L1.25 2.25H8.08l4.71 6.23zm-1.16 17.52h1.83L7.08 4.13H5.12z"/>' },
93    { label: "Facebook",  href: "https://www.facebook.com/theyec", icon: '<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.96.93-1.96 1.89v2.25h3.33l-.53 3.49h-2.8V24C19.61 23.1 24 18.1 24 12.07z"/>' },
94    { label: "YouTube",   href: "https://www.youtube.com/yec", icon: '<path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.5 0-9.38.51A3.02 3.02 0 0 0 .5 6.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 5.8 3.02 3.02 0 0 0 2.12 2.14c1.88.51 9.38.51 9.38.51s7.5 0 9.38-.51a3.02 3.02 0 0 0 2.12-2.14A31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-5.8zM9.55 15.57V8.43L15.82 12z"/>' },
95    { label: "TikTok",    href: "https://www.tiktok.com/@officialyec", icon: '<path d="M16.6 5.82a4.28 4.28 0 0 1-1.04-2.82h-3.3v13.4a2.59 2.59 0 0 1-2.59 2.5 2.59 2.59 0 0 1-2.59-2.59 2.59 2.59 0 0 1 3.3-2.49V8.9a5.88 5.88 0 0 0-.71-.04A5.89 5.89 0 0 0 4 14.75a5.89 5.89 0 0 0 5.89 5.89 5.89 5.89 0 0 0 5.89-5.89V8.66a7.55 7.55 0 0 0 4.42 1.42V6.78a4.28 4.28 0 0 1-3.6-.96z"/>' }
96  ];
97  function socialHtml() {
98    return '<div class="footer-social">' + SOCIAL.map(function (s) {
99      return '<a href="' + s.href + '" aria-label="' + s.label + ' (opens in new tab)" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' + s.icon + "</svg></a>";
100    }).join("") + "</div>";
101  }
102
103  var herePath = location.pathname.replace(/\/$/, "");
104
105  /* =================================================================
106     NAV
107     ================================================================= */
108  /* IS THIS PAGE INSIDE THE PORTAL?
109     Drew, 2026-08-08: a signed-in member should not be shown "Member Login",
110     and should not be sold a seat at the table they already paid for. Both
111     make the site look like it does not know who it is talking to.
112
113     Read off the body's data-portal hook rather than the URL, so the marketing
114     pages are untouched and the portal pages get it for free. The applicant
115     status page carries the same hook — somebody mid-application should not be
116     asked to log in to a portal they have no account for either. */
117  function inPortal() {
118    return !!(document.body && document.body.getAttribute("data-portal"));
119  }
120
121  function navHtml(navMode) {
122    var logo = '<a class="nav-logo" href="' + HOME + '" aria-label="YEC home">' +
123          '<img class="logo-light" src="' + ROOT + '/assets/yec-white.png" alt="YEC" width="400" height="183" fetchpriority="high" />' +
124          '<img class="logo-dark" src="' + ROOT + '/assets/yec-teal.png" alt="YEC" width="400" height="183" />' +
125        "</a>";
126    /* Outside the shell, data-portal means sign-in, /vote or
127       /application-status: nobody there has a session, so "Member home" and
128       "Your account" looped back to sign-in (S1-12, audit P1-027). The logo
129       and "Back to yec.co", both home to yec.co, are the whole header. No
130       burger, so no drawer. */
131    if (inPortal()) return '<div class="nav-inner container">' + logo + '<a class="nav-back" href="' + HOME + '">Back to yec.co</a></div>';
132    var linksHtml = NAV.map(function (n) {
133      var active = n.href.replace(/\/$/, "") === herePath ? " is-active" : "";
134      return '<a class="nav-link' + active + '" href="' + n.href + '">' + n.label + "</a>";
135    }).join("");
136    return '<div class="nav-inner container">' + logo +
137        '<nav class="nav-links" aria-label="Primary">' + linksHtml +
138          '<a class="nav-link nav-portal" href="' + PORTAL + '">Member Login</a>' +
139        "</nav>" +
140        '<div class="nav-actions">' +
141          '<a class="btn btn-accent nav-cta" href="' + APPLY + '">Earn a seat at the table</a>' +
142          '<button class="nav-burger" aria-label="Open menu" aria-expanded="false"><span></span><span></span><span></span></button>' +
143        "</div>" +
144      "</div>";
145  }
146  /* =================================================================
147     PORTAL SHELL — the grouped sidebar
148     =================================================================
149     Drew, 2026-09-02, on the Hampton layout study: "It should be for
150     members too, and we want to change the size up there as well ...
151     This needs to be responsive on mobile and fixed so we can do it
152     via our phone. I don't know how we're going to do that."
153
154     The phone answer was the marketing drawer with a different panel;
155     since S3-01 commit 2 (members) and A-STAFF-1 (staff) it is the tab
156     bar and the More sheet (buildTabBar), one build for both sides.
157
158     NOT GATED ON data-portal, deliberately. That attribute is also on
159     /vote, /application-status and /members/login — a vote link works
160     for somebody who is not signed in at all, and none of the three
161     has a person to hang a sidebar on. The gate is the path. */
162  function inShell() {
163    return /^\/members(\/|$)/.test(location.pathname) &&
164           !/^\/members\/login/.test(location.pathname);
165  }
166  function isStaffArea() { return /^\/members\/admin(\/|$)/.test(location.pathname); }
167  /* THE BARE SHELL (S1-11, CP0 A4): the logo, the page title and Sign out.
168     No sidebar, burger, bell or drawer, because every one of those rows
169     bounced a walled member straight back here (the wall lives in
170     functions/members/_middleware.js). /members/welcome is bare whatever
171     /me says (contract 1): it is the setup flow. Presentation only.
172     Commit 2: the other pages a walled member can open (WALL_EXEMPT in the
173     middleware; tools/verify-portal-shell.mjs reads that list and loads each
174     one) are bare too, with "Finish setup", until /me says walled === false.
175     A 401, a 503 or a missing field keeps them bare, so the full menu never
176     flashes at a walled member. */
177  var unwalled = false; // set once /me says member.walled === false
178  function isWallExempt(path) { return /^\/members\/(profile|account|billing|communications|notifications|spotlight|code-of-conduct)(\/|$)/.test(path || location.pathname); }
179  /* THE DUES PAYWALL (plan we-also-need-to-stateful-moler): /members/renew/
180     is bare whatever /me says, like /welcome: it is the dues page. The pages
181     a paywalled member may still open (PAYWALL_EXEMPT in the middleware:
182     Account, Billing, the Code of Conduct) stay bare once /me says paywalled,
183     with "Pay dues" where "Finish setup" was. */
184  var paywalled = false; // set with unwalled, from /me's paywalled
185  function isPaywalled(d) { return !!d && (d.paywalled === true || (!!d.member && d.member.paywalled === true)); }
186  function isPaywallExempt() { return /^\/members\/(renew|billing|account|code-of-conduct|messages|communications)(\/|$)/.test(location.pathname); }
187  /* Messages is open to a paywalled member only for the staff thread (the
188     dues page's "Message the YEC team"): bare until /me answers, then bare
189     with Pay dues while paywalled (no bell or menu, whose calls answer 402),
190     the full shell otherwise. */
191  function isPaywallOnly() { return /^\/members\/messages(\/|$)/.test(location.pathname); }
192  /* Limited mode (Drew 2026-09-27): the member is IN, so /members/renew/ is
193     drawn in the full shell once /me says paywall.mode is limited (bare until
194     then, and always bare in mode on). */
195  var renewFull = false;
196  function isRenew() { return /^\/members\/renew(\/|$)/.test(location.pathname); }
197  function isBare() { return /^\/members\/welcome(\/|$)/.test(location.pathname) || (isRenew() && !renewFull) || ((isWallExempt() || isPaywallOnly()) && (!unwalled || paywalled)); }
198
199  /* THE STAFF MENU (S3-05 shell half, A-STAFF-1): one flat list in Drew's
200     order, each row an icon and a name, drawn like the member menu (menuRow)
201     in the sidebar and the phone's More sheet. `bar` names the row's tab in
202     the phone tab bar. `count` is a badge key from /api/members/admin/counts.
203     Press keeps its dropdown (Drew, 2026-09-18): a row with `items` renders
204     as a <details> group, open on any of its pages, so each tab is one click
205     away. Every href is a real permalink: tools/verify-portal-shell.mjs
206     checks them against the `permalink:` front matter. Icons come from the
207     sprite as it stands (docs/design/system/icons.json is Stream B's). */
208  var SHELL_STAFF = [
209    { href: "/members/admin/",              label: "Staff home",    icon: "house", bar: "Home" },
210    { href: "/members/admin/review/",       label: "Review queue",  icon: "circle-check", bar: "Review", count: "review_decide" },
211    { href: "/members/admin/approvals/",    label: "Approvals",     icon: "check", count: "approvals_waiting" },
212    { href: "/members/admin/interview/",    label: "Interview",     icon: "circle-help" },
213    { href: "/members/admin/members/",      label: "Members",       icon: "users", bar: "Members" },
214    { href: "/members/admin/events/",       label: "Events",        icon: "calendar", bar: "Events" },
215    { href: "/members/admin/videos/",       label: "Videos",        icon: "play" },
216    { href: "/members/admin/ai-content/",   label: "AI content",    icon: "library" },
217    { href: "/members/admin/active/",       label: "Who is active", icon: "clock" },
218    { href: "/members/admin/features/",     label: "Features",      icon: "settings" },
219    { href: "/members/admin/access/",       label: "Member access", icon: "shield-check" },
220    { href: "/members/admin/speakers/",     label: "Speakers",      icon: "user" },
221    { href: "/members/admin/links/",        label: "Links page",    icon: "link" },
222    { href: "/members/admin/suggestions/",  label: "Suggestions",   icon: "message-square", count: "event_suggestions" },
223    { href: "/members/admin/perks/",        label: "Member offers", icon: "gift", count: "perk_suggestions" },
224    { href: "/members/admin/circles/",      label: "Circles",       icon: "link" },
225    { href: "/members/admin/inbox/",        label: "Inbox",         icon: "mail", count: "inbox_unread" },
226    { href: "/members/admin/moderation/",   label: "Moderation",    icon: "shield-check", count: "reports_week" },
227    { href: "/members/admin/notices/",      label: "Notices",       icon: "info" },
228    { href: "/members/admin/announcements/",label: "Announcements", icon: "megaphone" },
229    { href: "/members/admin/feed/",         label: "Feed",          icon: "library" },
230    { href: "/members/admin/automations/",  label: "Automations",   icon: "bell" },
231    { href: "/members/admin/audit/",        label: "Audit",         icon: "search" },
232    { href: "/members/admin/checkins/",     label: "Check-ins",     icon: "check" },
233    { href: "/members/admin/unsigned/",     label: "Unsigned",      icon: "triangle-alert", count: "unsigned" },
234    { href: "/members/admin/momentum/",     label: "Momentum",      icon: "user-plus" },
235    { label: "Press", icon: "newspaper", items: [
236      { href: "/members/admin/press/",            label: "Profiles" },
237      { href: "/members/admin/press/placements/", label: "Placements" },
238      { href: "/members/admin/press/inbox/",      label: "Inbox" },
239      { href: "/members/admin/press/opportunities/", label: "Opportunities" },
240      { href: "/members/admin/press/pitches/",    label: "Pitches" },
241      { href: "/members/admin/press/asks/",       label: "YEC Asks" },
242      { href: "/members/admin/press/blog/",       label: "Blog" },
243      { href: "/members/admin/press/settings/",   label: "Settings" }
244    ] }
245  ];
246  /* Pinned under the rows, in the sidebar and the More sheet: the way back to
247     the member side (the member side gets "Staff tools" from addStaffSwitch).
248     "All staff tools" went: Staff home is the first row now. */
249  var SHELL_STAFF_FOOT = [
250    { href: "/members/", label: "Member view", switch: true }
251  ];
252
253  /* THE MEMBER MENU (S3-01, CP0 B7 and B8): one flat table of 8 rows,
254     drawn in the sidebar and the phone's tab bar and More sheet. A row with
255     `tabs` is a section: the row opens its first tab, lights on any of them,
256     and site.js draws the tab row on each tab's page (sectionTabs). `match`
257     lights a row on other pages under it. `sources` are a row's pages with
258     no tab row. Library is tabs since Drew 2026-09-28: Replays first, then
259     Newsletters; with neither open a member does not see it (UX polish
260     2026-09-30: nothing that is not ready shows). menu: false is a row with 
260no
261     menu row: the bell leads to Notifications (S2-12). No Notifications
262     row and no AI rows (hidden until built; their switches still hold).
263     NAV PROPOSAL (2026-09-29, Mobbin: Discord, Slack, LinkedIn, Substack,
264     Circle, Klarna): the order is the phone's, so the sidebar and the tab
265     bar read the same; Messages is a tab bar tab (daily use) and Press moves
266     to More; the notification settings page is Account's third tab, so
267     settings live in one section. */
268  var SHELL_MEMBER = [
269    { label: "Home", icon: "house", href: "/members/", bar: true },
270    { label: "Events", icon: "calendar", href: "/members/events/", feature: "events", match: ["/members/room", "/members/event"], bar: true },
271    { label: "People", icon: "users", bar: true, tabs: [
272      { href: "/members/directory/", label: "Directory", feature: "directory" },
273      { href: "/members/map/", label: "Map", feature: "member_map" },
274      { href: "/members/asks/", label: "Asks and gives", feature: "asks" }
275    ], match: ["/members/vouch", "/members/circle", "/members/intros"] },
276    { label: "Messages", icon: "message-square", href: "/members/messages/", feature: "messages", bar: true },
277    { label: "Press", icon: "newspaper", tabs: [
278      { href: "/members/pitch/", label: "Pitch", feature: "press_pitch" },
279      { href: "/members/press/", label: "Wins", feature: "press_wins" }
280    ] },
281    { label: "Library", icon: "library", tabs: [
282      { href: "/members/videos/", label: "Replays", feature: "replay_library" },
283      { href: "/members/newsletters/", label: "Newsletters", feature: "newsletters" }
284    ], match: ["/members/videos", "/members/ai"] },
285    { label: "You", icon: "user", tabs: [
286      { href: "/members/profile/", label: "Profile" },
287      { href: "/members/checkin/", label: "Check-in", feature: "checkin" }
288    ] },
289    { label: "Account", icon: "settings", tabs: [
290      { href: "/members/account/", label: "Account" },
291      { href: "/members/billing/", label: "Billing", feature: "billing" },
292      { href: "/members/communications/", label: "Settings" }
293    ] },
294    { label: "Notifications", menu: false, href: "/members/notifications/", feature: "notifications" }
295  ];
296  /* Pinned under the rows, in the sidebar and the drawer. Sign out stays in
297     the page footer. Referrals is Invite's second tab (PHASE-0-ANSWERS M1),
298     so sectionTabs reads this table too. The icons are for the phone's More
299     sheet. */
300  var SHELL_MEMBER_FOOT = [
301    { href: "/members/perks/", label: "Member offers", icon: "gift", feature: "perks" },
302    { href: "/members/invite/", label: "Invite someone", icon: "user-plus", feature: "invite", tabs: [
303      { href: "/members/invite/", label: "Invite", feature: "invite" },
304      { href: "/members/referrals/", label: "Referrals", feature: "referrals" }
305    ] },
306    { href: "/members/code-of-conduct/", label: "Code of Conduct" }
307  ];
308  /* The phone's More sheet: these groups, split by a rule (no headings, which
309     would be new copy), then the foot. "You" is the member's name and face
310     once /me answers. */
311  var MORE_ROWS = [["Press", "Library", "Member offers", "Invite someone"], ["You", "Account"]];
312
313  function isHere(item) { return item.href.replace(/\/$/, "") === herePath; }
314  /* A menu row: lit on any of its tabs or sources, or under one of its match
315     prefixes. Plain links (the foot, staff rows) go through here too. */
316  function rowHere(r) {
317    return (r.tabs || r.sources || r.items || [r]).some(isHere) || (r.match || []).some(function (p) { return herePath === p || herePath.indexOf(p + "/") === 0; });
318  }
319  function icon(id) {
320    return '<svg class="ps-ico" aria-hidden="true" focusable="false"><use href="/assets/icons.svg#' + id + '"></use></svg>';
321  }
322  /* The row carries every tab's switch: it goes Soon only when all of them
323     are closed, and paintFeatures points it at the first open tab (People
324     with Directory on Soon opens Asks and gives). The phone tab bar and the
325     More sheet draw their rows here too, with their own class; a tab shows
326     its short name (`bar`). A staff row's count badge lands here as on
327     shellLink (applyStaffCounts refreshes .ps-link[data-count]). */
328  function menuRow(r, cls) {
329    var kids = r.tabs || r.sources || [r];
330    var keys = kids.every(function (k) { return k.feature; }) ? kids.map(function (k) { return k.feature; }).join(" ") : "";
331    var on = rowHere(r), tab = cls === "ps-tb";
332    return '<a class="' + (cls || "ps-link ps-row") + (on ? " is-on" : "") + '" href="' + kids[0].href + '"' +
333           (on ? ' aria-current="page"' : "") +
334           (keys ? ' data-feature="' + keys + '"' : "") +
335           (keys && kids.length > 1 ? ' data-hrefs="' + kids.map(function (k) { return k.href; }).join(" ") + '"' : "") +
336           (r.count ? ' data-count="' + r.count + '"' : "") + ">" +
337           icon(r.icon) + '<span class="ps-row-t">' + (tab && typeof r.bar === "string" ? r.bar : r.label) + "</span>" +
338           (r.count ? countBadge(r.count) : "") + "</a>";
339  }
340  /* Section tabs, drawn from the same table at the foot of the page's head
341     (a .page-hero, or the design system's .y-page-head, People's since
342     S2-11), under its H1, so no template (pitch.njk and press.js are the
343     press session's) needs an edit. A closed tab is hidden for members by
344     paintFeatures, like any other data-feature node that is not a menu
345     row. With neither head, the row goes first in #main. The bare shell keeps a
346     tab row only when every tab is open to a walled member (Account |
347     Billing yes, Profile | Check-in no). */
348  function sectionTabs() {
349    if (isStaffArea() || document.querySelector(".ps-tabs")) return;
350    var sec = SHELL_MEMBER.concat(SHELL_MEMBER_FOOT).filter(function (r) { return r.tabs && r.tabs.some(isHere); })[0];
351    var main = document.getElementById("main");
352    if (!sec || !main) return;
353    if (isBare() && !sec.tabs.every(function (t) { return isWallExempt(t.href); })) return;
354    var nav = document.createElement("nav");
355    nav.className = "ps-tabs";
356    nav.setAttribute("aria-label", sec.label);
357    nav.innerHTML = sec.tabs.map(function (t) {
358      return '<a class="ps-tab" href="' + t.href + '"' + (isHere(t) ? ' aria-current="page"' : "") +
359             (t.feature ? ' data-feature="' + t.feature + '"' : "") + ">" + t.label + "</a>";
360    }).join("");
361    var head = main.querySelector(".page-hero, .y-page-head");
362    var host = head ? head.querySelector(".container") || head : main;
363    if (head) host.appendChild(nav); else host.insertBefore(nav, host.firstChild);
364  }
365  function countBadge(keys) {
366    var c = window.yecStaffCounts, n = 0;
367    if (c) keys.split(" ").forEach(function (k) { n += Number(c[k]) || 0; });
368    return n ? '<span class="ps-cnt">' + n + '<span class="sr-only"> waiting</span></span>' : ""; // heard as "3 waiting", not a bare number
369  }
370  /* A row's switches, as menuRow reads them: a row with tabs carries every
371     tab's key and href, so the sidebar and the More sheet agree (Invite off,
372     Referrals on: both open Referrals). */
373  function shellKeys(item) {
374    var kids = item.tabs;
375    if (!kids || !kids.every(function (k) { return k.feature; })) return item.feature ? ' data-feature="' + item.feature + '"' : "";
376    return ' data-feature="' + kids.map(function (k) { return k.feature; }).join(" ") + '"' +
377      (kids.length > 1 ? ' data-hrefs="' + kids.map(function (k) { return k.href; }).join(" ") + '"' : "");
378  }
379  function shellLink(item) {
380    if (item.items) return shellGroup(item);
381    var on = rowHere(item) ? " is-on" : "";
382    return '<a class="ps-link' + on + (item.switch ? " ps-switch" : "") + '" href="' + item.href + '"' +
383           (on ? ' aria-current="page"' : "") +
384           shellKeys(item) +
385           (item.count ? ' data-count="' + item.count + '"' : "") + '>
385' +
386           '<span class="ps-dot" aria-hidden="true"></span>' + item.label +
387           (item.count ? countBadge(item.count) : "") + "</a>";
388  }
389  /* A dropdown of related rows. Native <details>, so keyboard (Enter/Space
390     on the summary) and screen readers get expand/collapse for free. Open
391     when the current page is inside it. The summary carries the sum of its
392     children's counts, shown only while the group is closed (css/chrome.css),
393     so a waiting suggestion is never folded out of sight. */
394  function shellGroup(g) {
395    var open = g.items.some(isHere);
396    var keys = g.items.filter(function (i) { return i.count; }).map(function (i) { return i.count; }).join(" ");
397    return '<details class="ps-grp"' + (open ? " open" : "") + ">" +
398             '<summary class="ps-link ps-sum"' + (keys ? ' data-count="' + keys + '"' : "") + ">" +
399               (g.icon ? icon(g.icon) : '<span class="ps-dot" aria-hidden="true"></span>') + '<span class="ps-sum-lab">' + g.label + "</span>" +
400               (keys ? countBadge(keys) : "") +
401             "</summary>" +
402             '<div class="ps-sub">' + g.items.map(shellLink).join("") + "</div>" +
403           "</details>";
404  }
405  /* One nav for either side: the flat rows in order, a group as a dropdown,
406     then the pinned foot inside it. Two tables, one drawing. */
407  function shellTable() {
408    var staff = isStaffArea();
409    return { label: staff ? "Staff" : "Main", rows: staff ? SHELL_STAFF : SHELL_MEMBER, foot: staff ? SHELL_STAFF_FOOT : SHELL_MEMBER_FOOT };
410  }
411  function shellGroupsHtml() {
412    var t = shellTable();
413    return '<nav class="ps-menu" aria-label="' + t.label + '">' +
414           t.rows.filter(function (r) { return r.menu !== false; }).map(function (r) { return r.items ? shellGroup(r) : menuRow(r); }).join("") +
415           '<div class="ps-foot">' + t.foot.map(shellLink).join("") + "</div></nav>";
416  }
417
418  /* The page already says what it is in its <title>. Reusing that keeps the
419     header honest with no per-page edit and no second place to update.
420     Titles here are "Application review. YEC." — strip the suffix, then any
421     separator left behind, so "Foo | YEC." does not render as "Foo |". */
422  function shellTitle() {
423    return (document.title || "")
424      .replace(/\s*YEC\.?\s*$/, "")
425      .replace(/[.\u00b7|\u2013\u2014\-\s]+$/, "");
426  }
427
428  /* Members at 900px and below (S3-01 commit 2, C4, C5): the title and
429     the bell as a plain link to Notifications (Messages is a tab); the tab bar
430     and the More sheet replace the burger, and the avatar goes. .ps-wide
431     and .ps-phone pick the width (css/chrome.css). Staff keep their bell
432     button and avatar at every width (A-STAFF-1): the tab bar took the
433     burger, and the Member view pill hides on a phone (the More sheet's
434     foot has it). */
435  function shellBarHtml() {
436    var staff = isStaffArea(), wide = staff ? "" : " ps-wide";
437    return '<div class="ps-bar">' +
438        '<span class="ps-title" id="ps-title"></span>' +
439        '<div class="ps-who">' +
440          (staff
441            ? '<a class="ps-pill" href="/members/">Member view</a>'
442            : '<a class="ps-pill" href="/members/account/">Your account</a>' +
443              '<a class="ps-bell ps-phone" href="/members/notifications/" aria-label="Notifications" data-feature="notifications">' + icon("bell") +
444                '<span class="ps-bell-dot" hidden></span></a>') +
445          '<button type="button" class="ps-bell' + wide + '" id="ps-bell" data-feature="notifications" aria-label="Notifications" aria-expanded="false" aria-controls="ps-bell-menu">' +
446            '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 22a2.5 2.5 0 0 0 2.45-2h-4.9A2.5 2.5 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5.5-6.84V3.5a1.5 1.5 0 0 0-3 0v.66A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg>
446' +
447            '<span class="ps-bell-dot" id="ps-bell-dot" hidden></span>' +
448          "</button>" +
449          '<div class="ps-bell-menu' + wide + '" id="ps-bell-menu" hidden><p class="ps-bell-head">Notifications</p><div id="ps-bell-list"></div><div class="ps-bell-foot"><a class="ps-bell-all" href="/members/notifications/">See all notifications</a><a class="ps-bell-all" href="/members/communications/">Settings</a></div></div>' +
450          // Drew 09-30: the chip (headshot, else initials) opens a short menu
451          // with Sign out. A member's phone has no chip: Sign out is in More.
452          '<button type="button" class="ps-av' + wide + '" id="ps-av" aria-label="Your account" aria-haspopup="menu" aria-expanded="false" aria-controls="ps-av-menu">&nbsp;</button>' +
453          '<div class="ps-bell-menu ps-av-menu" id="ps-av-menu" role="menu" aria-label="Your account" hidden>' +
454            '<a class="ps-av-row" role="menuitem" href="/members/account/">Account</a>' +
455            '<a class="ps-av-row" role="menuitem" href="/members/profile/">Profile</a>' +
456            '<button type="button" class="ps-av-row ps-av-out" role="menuitem">Sign out</button>' +
457          "</div>" +
458        "</div>" +
459      "</div>";
460  }
461
462  /* THE PHONE TAB BAR AND MORE SHEET (S3-01 commit 2, CP0 B6). Home, Events,
463     People and Messages are tabs; More opens a yecSheet with Press, Library,
464     Member offers, Invite, a rule, the member's name (the You row) and
465     Account, a rule, the Code of Conduct, Sign out, and Staff tools for
466     staff (addStaffSwitch). Tabs and rows carry the same data-feature keys as the menu: a closed tab is simply
467     not there (paintFeatures), and so is a closed sheet row. Never on the
468     bare shell; hidden above 900px and in an open message thread
469     (css/chrome.css). body.y-has-tabs lifts .y-sticky-bar and toasts over it
470     (components.css, INTEGRATION.md #6).
471     Staff (A-STAFF-1): Home, Review, Members, Events and More, from the same
472     table; More holds every other staff row in order, the Press dropdown,
473     then Member view in the foot, and Sign out. */
474  function buildTabBar() {
475    if (document.getElementById("ps-tabbar")) return;
476    var t = shellTable(), all = t.rows.concat(t.foot);
477    var pick = function (label) { return all.filter(function (r) { return r.label === label; })[0]; };
478    var onMore = all.some(function (r) { return !r.bar && r.menu !== false && rowHere(r); });
479    var bar = document.createElement("nav");
480    bar.id = "ps-tabbar";
481    bar.className = "ps-tabbar";
482    bar.setAttribute("aria-label", t.label + " tabs");
483    bar.innerHTML = t.rows.filter(function (r) { return r.bar; }).map(function (r) { return menuRow(r, "ps-tb"); }).join("") +
484      '<button type="button" class="ps-tb" aria-haspopup="dialog" aria-controls="ps-more" aria-expanded="false"' + (onMore ? ' aria-current="page"' : "") + ">" +
485        icon("ellipsis") + '<span class="ps-row-t">More</span></button>';
486    var more = document.createElement("dialog");
487    more.id = "ps-more";
488    more.className = "ps-more";
489    more.setAttribute("aria-labelledby", "ps-more-t");
490    var body = isStaffArea()
491      ? t.rows.filter(function (r) { return !r.bar; }).map(function (r) { return r.items ? shellGroup(r) : menuRow(r, "ps-link ps-mrow"); }).join("") +
492        '<div class="ps-more-foot">' + t.foot.map(shellLink).join("") + "</div>"
493      : MORE_ROWS.map(function (g) {
494          return g.map(function (l) { return menuRow(pick(l), "ps-link ps-mrow" + (l === "You" ? " ps-more-me" : "")); }).join("");
495        }).join('<hr class="ps-more-hr">') + '<hr class="ps-more-hr">' +
496        '<div class="ps-more-foot">' + shellLink(pick("Code of Conduct")) + "</div>";
497    more.innerHTML =
498      '<div class="ps-more-head"><h2 id="ps-more-t">More</h2>' +
499        '<button type="button" class="ps-close" aria-label="Close" data-sheet-close>' + icon("x") + "</button></div>" +
500      '<div class="ps-more-body">' + body + "</div>" +
501      '<div class="ps-more-end"><button type="button" class="ps-more-out">' + icon("log-out") + "Sign out</button></div>";
502    var nav = document.getElementById("site-nav");
503    nav.parentNode.insertBefore(bar, nav.nextSibling);
504    document.body.appendChild(more);
505    document.body.classList.add("y-has-tabs");
506    // The scroll-padding-bottom hook (css/chrome.css). A class, not :has,
507    // which Safari before 15.4 and Firefox before 121 do not read.
508    document.documentElement.classList.add("ps-has-tabs");
509    // Focus first: Safari does not focus a tapped button, and yecSheet hands
510    // focus back to whatever had it when the sheet opened.
511    // showModal puts focus on the sheet's first control (Close); yecSheet
512    // returns it to More on close. aria-expanded follows the sheet.
513    var moreBtn = bar.querySelector("button");
514    moreBtn.addEventListener("click", function () { this.focus(); window.yecSheet.open(more); moreBtn.setAttribute("aria-expanded", "true"); });
515    more.addEventListener("close", function () { moreBtn.setAttribute("aria-expanded", "false"); });
516    // A followed row shuts the sheet, as the drawer's links did, so Back
517    // (the page restored from the back-forward cache) is not a modal sheet
518    // over an inert, scroll-locked page.
519    more.addEventListener("click", function (e) { if (e.target.closest("a[href]")) more.close(); });
520    // Left open any other way (the phone's Back or a swipe while it is up):
521    // the cached page comes back with it open, so shut it on the way in.
522    window.addEventListener("pageshow", function (e) { if (e.persisted && more.open) more.close(); });
523    wireSignout(more.querySelector(".ps-more-out"));
524    // Widened past the phone layout (an iPad turned, a window dragged): the
525    // More button is gone, so the sheet goes with it, and focus goes to the
526    // sidebar's lit row (or the page) rather than the hidden More. yecSheet's
527    // own close handler runs first (capture on document), so this one wins.
528    PHONE.addEventListener("change", function () {
529      paintTitle();
530      if (PHONE.matches || !more.open) return;
531      more.addEventListener("close", function () {
532        var to = document.querySelector("#ps-side .is-on") || document.getElementById("main");
533        if (to) to.focus();
534      }, { once: true });
535      more.close();
536    });
537  }
538
539  /* A member's phone bar names the lit menu row (copy deck "topbar"), so it
540     holds still when they switch tabs. Only a row's own pages count, not its
541     match prefixes: the live page and People's side pages keep their own
542     title, and so does every desktop bar (roadmap: 900px and below). */
543  var PHONE = window.matchMedia("(max-width: 900px)");
544  function paintTitle() {
545    var t = document.getElementById("ps-title");
546    if (!t) return;
547    var sec = PHONE.matches && document.getElementById("ps-tabbar") &&
548      SHELL_MEMBER.filter(function (r) { return (r.tabs || r.sources || [r]).some(isHere); })[0];
549    t.textContent = sec ? sec.label : shellTitle();
550  }
551
552  /* The logo is an image, not a link: a walled member sent to /members/
553     would land back here. Sign out is wired with the footer's. The cream
554     logo is the dark theme's (S3-02); css/chrome.css shows one of the two,
555     and lazy keeps the hidden one from loading. */
556  function bareBarHtml() {
557    return '<div class="ps-bar">' +
558        '<img class="ps-bare-logo" src="' + ROOT + '/assets/yec-navy.png" alt="YEC" width="400" height="183" />' +
559        '<img class="ps-bare-logo ps-bare-logo-dark" src="' + ROOT + '/assets/yec-cream.png" alt="YEC" width="400" height="183" loading="lazy" />' +
560        '<span class="ps-title" id="ps-title"></span>' +
561        (paywalled && (isWallExempt() || isPaywallOnly()) ? '<a class="ps-bare-setup" href="/members/renew/">Pay dues</a>' : !isWallExempt() ? "" : '<a class="ps-bare-setup" href="/members/welcome/">Finish setup</a>') +
562        '<button type="button" class="ps-footer-signout ps-bare-signout">Sign out</button>' +
563      "</div>";
564  }
565
566  /* Stacked tables on a phone: copy each header's text onto the cells in
567     its column as data-label, so css/portal.css can print it above the value
568     under 700px. Rows arrive later from fetches, so a MutationObserver
569     labels them as they land. One helper for every portal table. */
570  function initStackTables() {
571    document.querySelectorAll("table.stack-phone").forEach(function (table) {
572      var labels = [];
573      function readHeads() {
574        labels = [].map.call(table.querySelectorAll("thead th"), function (th) { return (th.textContent || "").trim(); });
575      }
576      function labelRows() {
577        if (!labels.length) readHeads();
578        table.querySelectorAll("tbody tr").forEach(function (tr) {
579          [].forEach.call(tr.children, function (td, i) {
580            if (td.tagName !== "TD" || td.hasAttribute("data-label")) return;
581            td.setAttribute("data-label", labels[i] || "");
582          });
583        });
584      }
585      labelRows();
586      new MutationObserver(labelRows).observe(table, { childList: true, subtree: true });
587    });
588  }
589  window.yecStackTables = initStackTables;
590
591  /* One sheet for every pop-up (R5). yecSheet.open(dialog) shows a
592     <dialog class="y-sheet"> with showModal(), which brings Esc, the focus
593     trap and an inert page. This adds what the native dialog does not: a
594     click on the backdrop or on any [data-sheet-close] closes it, the page
595     behind stops scrolling, and focus goes back to the control that opened
596     it. One sheet at a time: opening a second closes the first, and focus
597     later returns to the first opener. tools/verify-sheet.mjs drives it. */
598  var sheet = null, sheetOpener = null, sheetDown = null, sheetPushed = false, sheetPopping = false, sheetLeave = null;
599  window.yecSheet = {
600    open: function (dialog, opts) {
601      if (dialog === sheet && dialog.open) return;
602      var opener = sheet ? sheetOpener : document.activeElement;
603      /* QA-4 C-6: one history entry per open sheet, so browser Back closes it
604         (popstate below) instead of leaving the page. { history: false } is
605         for a page that keeps its own entry (People's ?profile=). */
606      if (!(opts && opts.history === false) && !sheet) { history.pushState({ yecSheet: 1 }, ""); sheetPushed = true; }
607      if (sheet) sheet.close();
608      dialog.showModal();
609      sheet = dialog;
610      sheetOpener = opener;
611      document.documentElement.style.overflow = "hidden";
612    },
613    close: function () { if (sheet) sheet.close(); }
614  };
615  // A press that starts inside the sheet (selecting text, then releasing on
616  // the backdrop) must not close it, so the press has to start outside too.
617  document.addEventListener("pointerdown", function (e) { sheetDown = e.target; });
618  document.addEventListener("click", function (e) {
619    if (!sheet || !sheet.contains(e.target)) return;
620    var r = sheet.getBoundingClientRect();
621    var onBackdrop = e.target === sheet && sheetDown === sheet &&
622      (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom);
623    if (onBackdrop || e.target.closest("[data-sheet-close]")) sheet.close();
624    /* A link to another page: take the sheet's entry out first, then go
625       (popstate below), so one Back from there lands on this page, restored
626       from the back-forward cache on the entry it left from. */
627    var a = e.target.closest("a[href]");
628    if (a && sheetPushed && !e.defaultPrevented && e.button === 0 && !(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && !a.target && !a.hasAttribute("download") &&
629        /^https?:$/.test(a.protocol) && (a.origin !== location.origin || a.pathname !== location.pathname || a.search !== location.search)) {
630      e.preventDefault();
631      sheetPushed = false;
632      sheetLeave = a.href;
633      if (sheet.open) sheet.close();
634      history.back();
635    }
636  });
637  /* The same for a page left by script from inside a sheet (Sign out in the
638     More sheet: fetch, then location.assign; any href/assign). The Navigation
639     API's navigate event sees every push, so it covers them all at once.
640     Replaces (location.replace) already overwrite the sheet's entry, form
641     posts cannot be replayed as a GET, and a browser without the API keeps
642     the old extra entry. ponytail: Navigation API only; a yecSheet.leave(url)
643     helper for scripts if an older browser matters. */
644  if (window.navigation && navigation.addEventListener) navigation.addEventListener("navigate", function (e) {
645    if (!sheetPushed || !e.cancelable || e.navigationType !== "push" || e.formData || e.hashChange || e.downloadRequest || e.destination.sameDocument) return;
646    e.preventDefault();
647    sheetPushed = false;
648    sheetLeave = e.destination.url;
649    if (sheet && sheet.open) sheet.close();
650    history.back();
651  });
652  /* The page's own popstate listeners (Pitch re-reads its filters) never see
653     a sheet's entry come or go: this capture listener runs first (site.js
654     loads before page scripts) and stops the event. */
655  window.addEventListener("popstate", function (e) {
656    if (sheetLeave) { var go = sheetLeave; sheetLeave = null; e.stopImmediatePropagation(); location.assign(go); return; } // a link followed from a sheet
657    if (sheetPopping) { sheetPopping = false; e.stopImmediatePropagation(); return; } // our own history.back() after a close
658    if (sheet && sheet.open && sheetPushed) { sheetPushed = false; e.stopImmediatePropagation(); sheet.close(); } // browser Back
659  }, true);
660  // "close" fires for every way out (Esc, backdrop, button, close()) and does not bubble.
661  document.addEventListener("close", function (e) {
662    if (e.target !== sheet || sheet.open) return; // replaced by a newer sheet, or reopened since
663    sheet = null;
664    document.documentElement.style.overflow = "";
665    if (sheetPushed) { sheetPushed = false; sheetPopping = true; history.back(); } // closed by Esc, the backdrop or a button: take its entry back out
666    if (sheetOpener && sheetOpener.isConnected && sheetOpener.focus) sheetOpener.focus();
667  }, true);
668
669  function buildShellSidebar() {
670    if (document.getElementById("ps-side")) return;
671    var side = document.createElement("aside");
672    side.id = "ps-side";
673    side.setAttribute("aria-label", isStaffArea() ? "Staff sections" : "Portal sections");
674    /* THE REAL LOGO, not a typeset approximation of it. The wordmark has its
675       own letterforms and the "." is part of the mark; setting "YEC" in
676       Montserrat and adding a full stop was close enough to look like a
677       mistake rather than a choice. yec-white.png is the light-on-dark
678       variant, which is what a navy sidebar wants. Explicit width and height
679       so the nav does not reflow when it loads. */
680    side.innerHTML =
681      '<a class="ps-brand" href="' + (isStaffArea() ? "/members/admin/" : "/members/") + '" aria-label="YEC home">' +
682        '<img src="' + ROOT + '/assets/yec-white.png" alt="YEC" width="400" height="183" />' +
683      "</a>" +
684      shellGroupsHtml();
685    var head = document.getElementById("site-nav");
686    document.body.insertBefore(side, head);
687    loadStaffCounts();
688  }
689
690  /* Sidebar badges (M13, 2026-09-03): one fetch per staff page, applied to
691     every link carrying data-count, now and on the drawer when it opens. A
692     zero or an uncounted number shows no badge. */
693  window.yecStaffCounts = null;
694  function applyStaffCounts() {
695    var c = window.yecStaffCounts;
696    if (!c) return;
697    document.querySelectorAll(".ps-link[data-count], .ps-tb[data-count]").forEach(function (a) {
698      var old = a.querySelector(".ps-cnt");
699      if (old) old.remove();
700      a.insertAdjacentHTML("beforeend", countBadge(a.getAttribute("data-count")));
701    });
702  }
703  function loadStaffCounts() {
704    if (!isStaffArea()) return;
705    fetch("/api/members/admin/counts", { credentials: "same-origin", headers: { accept: "application/json" } })
706      .then(function (r) { return r.ok ? r.json() : null; })
707      .then(function (d) { if (d && d.ok && d.counts) { window.yecStaffCounts = d.
707counts; applyStaffCounts(); } })
708      .catch(function () {});
709  }
710
711  /* The headshot or initials in the corner. NOT CACHED, deliberately: sessionStorage survives
712     a same-tab sign-out (js/members/account.js signs out by setting
713     window.location), so a cache here showed the previous person's initials to
714     the next person signing in on a shared laptop, until the tab was closed.
715     One authenticated GET per portal page is the cheaper mistake. A miss
716     leaves the circle blank rather than inventing a person. */
717  /* THE SWITCH BACK. Drew, 2026-09-02: "When I log in as Drew, I'm an admin,
718     so I need to always have the staff tools at the bottom to switch back and
719     forth." The staff sidebar already offered "Member view"; the member
720     sidebar offered no way back, so an admin who crossed over was stuck
721     typing a URL.
722
723     Added after the identity lands rather than rendered up front, because
724     whether somebody is staff is the server's answer and not something this
725     file may assume. It goes into the sidebar AND the drawer — the phone is
726     where being stranded actually costs you something. On a member's phone
727     that is the More sheet (S3-01 commit 2). */
728  function addStaffSwitch() {
729    if (isStaffArea()) return;   // that side already has "Member view"
730    ["ps-side", "nav-drawer", "ps-more"].forEach(function (id) {
731      var host = document.getElementById(id);
732      if (!host) return;
733      var foot = host.querySelector(".ps-foot, .ps-more-foot");
734      if (!foot || foot.querySelector(".ps-switch")) return;
735      var row = document.createElement("div");
736      row.innerHTML = shellLink({ href: "/members/admin/", label: "Staff tools" });
737      var link = row.firstChild;
738      link.className += " ps-switch";
739      foot.insertBefore(link, foot.firstChild);
740    });
741  }
742
743  /* ONE request for "who is signed in" per page. The shell needs it for the
744     avatar, the home for its identity line, Asks and Gives for its own
745     contactId; each used to fetch it again (audit P1, 2026-09-03: two to
746     three calls per page). window.yecMe resolves to the /api/members/me JSON
747     or null (401, network); it never rejects. */
748  /* THE DUES PAYWALL, API side: once the switch is on, any member API may
749     answer 402 {error:"paywalled"}. One wrapper here, which loads before every
750     page script, sends the browser to the dues page, so no page needs its own
751     check; the caller still gets its response. Never from the pages a
752     paywalled member may open, where a redirect could only loop. */
753  if (inShell() && !isStaffArea() && typeof window.fetch === "function") {
754    var pageFetch = window.fetch;
755    window.fetch = function (input) {
756      var url = typeof input === "string" ? input : (input && input.url) || "";
757      return pageFetch.apply(window, arguments).then(function (r) {
758        if (r && r.status === 402 && /^(https?:\/\/[^/]+)?\/api\/members\//.test(url)) {
759          r.clone().json().then(function (d) {
760            if (d && d.error === "paywalled" && !isPaywallExempt()) location.replace("/members/renew/");
761            else if (d && d.error === "dues_required") duesCard(); // limited mode: a locked feature
762          }, function () {});
763        }
764        return r;
765      });
766    };
767  }
768  function whoAmI() {
769    if (window.yecMe) return window.yecMe;
770    window.yecMe = fetch("/api/members/me", { credentials: "same-origin", headers: { accept: "application/json" } })
771      .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; })
772      .catch(function () { return null; });
773    return window.yecMe;
774  }
775  window.yecWhoAmI = whoAmI;
776
777  /* THE PAY DUES CARD (limited mode, Drew 2026-09-27). Through Dec 31 a
778     member behind on dues is in the portal, but answering journalists, adding
779     a press win and playing replays wait on current dues. A tap on one of
780     those, or a 402 dues_required from its endpoint (the wrapper above),
781     opens this one short sheet: yecSheet brings Esc, the focus trap, the page
782     lock and focus back to the control that opened it. Pay dues goes to the
783     renew page (the same flow). yecDuesHeld() is what /me said (false until
784     it answers; the server's 402 covers that moment). Copy:
785     docs/PORTAL-REDESIGN-2026-09-20/PAYWALL-COPY.md "Limited mode". */
786  /* yecDuesHeld(area): is that area locked for this member (per-area locks,
787     Drew 2026-09-27; /me paywall.locked)? No area: locked anywhere. */
788  var duesLocked = [], duesSheet = null;
789  window.yecDuesHeld = function (area) { return area ? duesLocked.indexOf(area) !== -1 : duesLocked.length > 0; };
790  function duesCard() {
791    if (!document.body || !document.createElement) return;
792    if (!duesSheet) {
793      duesSheet = document.createElement("dialog");
794      duesSheet.className = "y-sheet";
795      duesSheet.id = "yec-dues-card";
796      duesSheet.setAttribute("aria-labelledby", "yec-dues-title");
797      duesSheet.setAttribute("aria-describedby", "yec-dues-body");
798      duesSheet.innerHTML = '<div class="y-sheet__head"><h2 class="y-h2" id="yec-dues-title">For members with current dues</h2>' +
799          '<button class="y-iconbtn" type="button" aria-label="Close" data-sheet-close><svg class="y-icon" aria-hidden="true"><use href="' + ROOT + '/assets/icons.svg#x"></use></svg></button></div>' +
800        '<div class="y-sheet__body"><p class="y-body" id="yec-dues-body">This part of the portal is for members whose dues are current. Once yours are paid, it opens again.</p></div>' +
801        '<div class="y-sheet__foot"><button class="y-btn y-btn--quiet" type="button" data-sheet-close>Not now</button>' +
802          '<a class="y-btn y-btn--primary" href="/members/renew/" autofocus>Pay dues</a></div>';
803      document.body.appendChild(duesSheet);
804    }
805    if (duesSheet.open) return;
806    if (window.yecSheet) window.yecSheet.open(duesSheet); else duesSheet.showModal();
807  }
808  window.yecDuesCard = duesCard;
809  /* yecDuesGate(area): locked -> open the Pay dues card and return true, so a
810     page stops before calling (no second error behind the card). */
811  window.yecDuesGate = function (area) { if (duesLocked.indexOf(area) === -1) return false; duesCard(); return true; };
812  if (inShell() && !isStaffArea()) whoAmI().then(function (d) { var p = d && d.paywall; duesLocked = p && p.held === true && Array.isArray(p.locked) ? p.locked : []; });
813  /* THE PREVIEW BAR (Drew 2026-09-28): while a staff member previews the
814     portal as a member group (Member access page; /me paywall.preview), a
815     strip at the top of every portal page says so, with Exit. Staff only:
816     /me sends preview for staff alone. At the top of main, not sticky: the
817     shell header is already sticky there. */
818  if (inShell()) whoAmI().then(function (d) {
819    var p = d && d.paywall;
820    if (!p || !p.preview) return;
821    var main = document.getElementById("main");
822    if (!main || document.getElementById("yec-preview-bar")) return;
823    var bar = document.createElement("div"), txt = document.createElement("span"), exit = document.createElement("button");
824    bar.id = "yec-preview-bar"; bar.className = "y-preview-bar"; bar.setAttribute("role", "status");
825    txt.textContent = "Previewing as " + (p.preview === "unpaid" ? "an unpaid member" : "a member paid through a date") +
826      ". Only you see this. Ends at " + new Date(p.preview_until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) + ".";
827    exit.type = "button"; exit.className = "y-preview-bar__exit"; exit.textContent = "Exit preview";
828    exit.addEventListener("click", function () {
829      exit.disabled = true;
830      fetch("/api/members/admin/access", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ preview: null }) })
831        .then(function (r) { return r.json(); })
832        .then(function (res) { if (!res || !res.ok) throw new Error(); location.href = "/members/admin/access/"; })
833        .catch(function () { exit.disabled = false; txt.textContent = "Could not end the preview. Try again, or it ends by itself within the hour."; });
834    });
835    bar.appendChild(txt); bar.appendChild(exit);
836    main.insertBefore(bar, main.firstChild);
837  });
838  /* Feature switches (2026-09-03): anything marked data-feature="<key>" is
839     hidden for members while staff have the key off. Staff see everything,
840     which is how they preview a page before releasing it. The server gate
841     in functions/members/_middleware.js is the real wall; this is the door
842     sign. */
843  window.yecFeatures = null;
844  var featStaff = false;
845  function applyFeatures(me) {
846    if (!me || !me.features) return;
847    window.yecFeatures = me.features;
848    featStaff = me.is_staff === true || (me.member && me.member.is_staff === true);
849    paintFeatures();
850  }
851  /* Split from applyFeatures because of a race that made the labels look
852     broken: the /me fetch is fired at parse time, and when it answered before
853     DOMContentLoaded there was no sidebar in the DOM yet to paint — the
854     switches were live on the server and invisible in the menu. buildNav
855     calls this again once the shell and the drawer exist, and it is safe to
856     call any number of times. */
857  function paintFeatures() {
858    var f = window.yecFeatures;
859    if (!f) return;
860    document.querySelectorAll("[data-feature]").forEach(function (n) {
861      /* A menu row carries one key per tab and closes only when every one is
862         closed; with its first tab closed it opens the first open one. */
863      var keys = n.getAttribute("data-feature").split(" ");
864      var open = keys.map(function (k) { return f[k] !== "off" && f[k] !== "soon"; });
865      var alt = n.getAttribute("data-hrefs");
866      if (open.indexOf(true) > 0 && alt && !featStaff) n.setAttribute("href", alt.split(" ")[open.indexOf(true)]);
867      if (open.indexOf(true) > -1) return;
868      var state = keys.some(function (k) { return f[k] === "soon"; }) ? "soon" : "off";
869      /* HIDE WHAT IS NOT READY (Drew, UX polish 2026-09-30): anything closed,
870         Soon or off, row, tab, bell, hub card or form, is simply not there
871         for a member. Staff keep it: hiding a feature is for previewing it,
872         not for locking staff out. */
873      if (!featStaff) { n.hidden = true; return; }
874      /* Staff see a row or section tab tagged, "Coming soon" on a tab (the
875         tab row has the width for the two words; the sidebar pill does not). */
876      var tab = n.classList.contains("ps-tab");
877      if (!n.classList.contains("ps-link") && !tab) return;
878      if (n.classList.contains("ps-staff-soon")) return; // already painted
879      var t = n.querySelector(".ps-soon-tag");
880      if (!t) {
881        t = document.createElement("span");
882        t.className = "ps-soon-tag y-soon";
883        t.textContent = state === "soon" ? (tab ? "Coming soon" : "Soon") : "Hidden";
884        n.appendChild(t);
885      }
886      /* STAFF SEE THE LABEL AND KEEP THE LINK. Before this, staff returned
887         early and saw an ordinary menu, so the only person who could check
888         that a switch had taken hold was a member — which is why the labels
889         read as "not showing up". Staff now see exactly which rows a member
890         cannot reach, and can still open them to preview. */
891      n.classList.add("ps-staff-soon");
892    });
893  }
894  window.yecPaintFeatures = paintFeatures;
895  /* Every shell page, not just data-portal ones (S1-05): People and Press
896     wins come from docShell without that hook, and their Soon rows were live
897     links. data-portal still counts for /vote, /application-status and login. */
898  if (inShell() || (document.body && document.body.getAttribute("data-portal") === "1")) whoAmI().then(applyFeatures);
899  /* The bell (M2, 2026-09-03): one fetch, the five newest, a dot while any
900     is unread. Clicking a row marks it read, then follows its link. */
901  var bellUnread = 0;
902  window.yecBellSet = function (n) {
903    bellUnread = Number(n) || 0;
904    // Both bells: the desktop button and the phone link (C4).
905    document.querySelectorAll(".ps-bell-dot").forEach(function (dot) { dot.hidden = bellUnread === 0; });
906    document.querySelectorAll(".ps-bell").forEach(function (bell) {
907      bell.setAttribute("aria-label", bellUnread ? "Notifications, " + bellUnread + " unread" : "Notifications");
908    });
909  };
910  function bellRow(n) {
911    var a = document.createElement("a");
912    a.className = "ps-bell-row" + (n.read_at ? "" : " unread");
913    a.href = n.url || "/members/notifications/";
914    var t = document.createElement("span"); t.className = "ps-bell-title"; t.textContent = n.title || "";
915    var b = document.createElement("span"); b.className = "ps-bell-body"; b.textContent = n.body || "";
916    a.appendChild(t); if (n.body) a.appendChild(b);
917    a.addEventListener("click", function (e) {
918      if (n.read_at) return;
919      e.preventDefault();
920      fetch("/api/members/notifications", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ action: "read", id: n.id }) })
921        .catch(function () {})
922        .then(function () { location.assign(a.href); });
923    });
924    return a;
925  }
926  function wireBell() {
927    var bell = document.getElementById("ps-bell"), menu = document.getElementById("ps-bell-menu"), list = document.getElementById("ps-bell-list");
928    if (!bell || !menu || !list) return;
929    fetch("/api/members/notifications?limit=5", { credentials: "same-origin", headers: { accept: "application/json" } })
930      .then(function (r) { return r.ok ? r.json() : null; })
931      .then(function (d) {
932        if (!d || !d.ok) return;
933        window.yecBellSet(d.unread);
934        list.textContent = "";
935        if (!d.notifications.length) {
936          var p = document.createElement("p"); p.className = "ps-bell-empty"; p.textContent = "Nothing yet. Messages, RSVPs and press updates show up here.";
937          list.appendChild(p);
938        }
939        d.notifications.forEach(function (n) { list.appendChild(bellRow(n)); });
940      })
941      .catch(function () {});
942    function close() { menu.hidden = true; bell.setAttribute("aria-expanded", "false"); }
943    bell.addEventListener("click", function () {
944      var open = menu.hidden;
945      menu.hidden = !open;
946      bell.setAttribute("aria-expanded", open ? "true" : "false");
947    });
948    document.addEventListener("click", function (e) { if (!menu.hidden && !menu.contains(e.target) && e.target !== bell && !bell.contains(e.target)) close(); });
949    document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !menu.hidden) { close(); bell.focus(); } });
950  }
951  /* The account chip's menu: a menu button (aria-haspopup), focus into the
952     first row on open, arrows move, Escape or an outside click closes. */
953  function wireAvatarMenu() {
954    var chip = document.getElementById("ps-av"), menu = document.getElementById("ps-av-menu");
955    if (!chip || !menu) return;
956    var rows = [].slice.call(menu.querySelectorAll('[role="menuitem"]'));
957    function close(back) { menu.hidden = true; chip.setAttribute("aria-expanded", "false"); if (back) chip.focus(); }
958    chip.addEventListener("click", function () {
959      if (!menu.hidden) return close(false);
960      menu.hidden = false;
961      chip.setAttribute("aria-expanded", "true");
962      rows[0].focus();
963    });
964    menu.addEventListener("keydown", function (e) {
965      var i = rows.indexOf(document.activeElement);
966      if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); rows[(i + (e.key === "ArrowDown" ? 1 : rows.length - 1)) % rows.length].focus(); }
967      else if (e.key === "Tab") close(false);
968    });
969    document.addEventListener("click", function (e) { if (!menu.hidden && !menu.contains(e.target) && !chip.contains(e.target)) close(false); });
970    document.addEventListener("keydown", function (e) { if (e.key === "Escape" && !menu.hidden) close(true); });
971    wireSignout(menu.querySelector(".ps-av-out"));
972    // Narrowed past 900px, a member's chip hides (.ps-wide): the menu goes with it.
973    PHONE.addEventListener("change", function () { if (!menu.hidden && !chip.getClientRects().length) close(false); });
974  }
975  function fillShellAvatar() {
976    var el = document.getElementById("ps-av");
977    if (!el) return;
978    whoAmI()
979      .then(function (d) {
980        var m = d && d.member;
981        if (!m) return;
982        var f = (m.first_name || m.firstName || "").trim();
983        var l = (m.last_name || m.lastName || "").trim();
984        var init = ((f[0] || "") + (l[0] || "")).toUpperCase(), name = (f + " " + l).trim();
985        /* The headshot, round, when /me has one (UX polish 2026-09-30);
986           initials with none or when it fails to load. */
987        function initials(box) { box.textContent = init; }
988        function face(box, onFail) {
989          if (!m.avatar_url) return initials(box);
990          var img = document.createElement("img");
991          img.alt = "";
992          img.addEventListener("error", function () { initials(box); if (onFail) onFail(); });
993          img.src = m.avatar_url;
994          box.textContent = "";
995          box.appendChild(img);
996        }
997        function chipLabel() { el.setAttribute("aria-label", init ? "Your account, " + init : "Your account"); } // the name must contain the visible text (WCAG 2.5.3)
998        // A headshot with no name on file still shows; the name is then just "Your account".
999        if (m.avatar_url) { el.setAttribute("aria-label", name ? "Your account, " + name : "Your account"); face(el, chipLabel); }
1000        else if (init) { initials(el); chipLabel(); }
1001        // The More sheet's first row is the member: face (headshot or initials),
1002        // name and job title (MoreSheet.html:28). It says "You" until /me answers.
1003        var me = document.querySelector("#ps-more .ps-more-me");
1004        if (me && (f || l)) {
1005          var box = document.createElement("span");
1006          box.className = "y-face";
1007          box.setAttribute("aria-hidden", "true");
1008          face(box);
1009          var ico = me.querySelector(".ps-ico");
1010          if (ico) ico.parentNode.replaceChild(box, ico);
1011          var t = me.querySelector(".ps-row-t");
1012          t.textContent = (f + " " + l).trim();
1013          if (m.title) { var sub = document.createElement("span"); sub.className = "y-small ps-more-sub"; sub.textContent = m.title; t.appendChild(sub); }
1014        }
1015        if (m.is_staff === true) addStaffSwitch();
1016      })
1017      .catch(function () {});
1018  }
1019
1020  /* The marketing drawer. Behind the login the phone menu is the tab bar and
1021     the More sheet on both sides (buildTabBar), so no shell page has a burger
1022     or a drawer any more. */
1023  function drawerHtml() {
1024    return '<div class="nav-drawer-inner">' +
1025        NAV.concat(MORE).map(function (n) { return '<a href="' + n.href + '">' + n.label + "</a>"; }).join("") +
1026        '<a href="' + PORTAL + '">Member Login</a>' +
1027        '<a class="btn btn-accent full center mt-2" href="' + APPLY + '" style="justify-content:center;">Earn a seat at the table</a>' +
1028      "</div>";
1029  }
1030
1031  function buildNav() {
1032    var mount = document.getElementById("site-nav");
1033    if (!mount) return;
1034    var navMode = document.body.getAttribute("data-nav") || "dark";
1035
1036    // Server-rendered chrome (post-build inject) leaves .nav-inner already
1037    // present — skip innerHTML and only wire behavior. Empty container =
1038    // runtime fallback (build it now, unchanged from before).
1039    if (inShell()) {
1040      // .pshell is what turns the CSS grid on, and it is added only once the
1041      // sidebar is actually in the DOM — a failed script leaves the old
1042      // chrome standing rather than a half-built shell.
1043      var bare = isBare();
1044      if (bare) document.body.setAttribute("data-shell", "bare");
1045      else document.body.removeAttribute("data-shell");
1046      mount.className = "site-nav";
1047      mount.innerHTML = bare ? bareBarHtml() : shellBarHtml();
1048      if (!bare) { buildShellSidebar(); buildTabBar(); }
1049      sectionTabs();
1050      paintFeatures(); // the bare branch returns before the repaint below, and /me may have landed first
1051      if (!unwalled) initStackTables(); // once: the re-entry below skips it
1052      document.body.classList.add("pshell");
1053      paintTitle();
1054      if (bare) {
1055        // Re-enters buildNav once on walled === false: the full shell, or,
1056        // paywalled, the bare header again with Pay dues. The footer wired
1057        // the first header's Sign out; the rebuilt one is wired here.
1058        if (unwalled) wireSignout(mount.querySelector(".ps-bare-signout"));
1059        else if (isRenew()) whoAmI().then(function (d) {
1060          if (d && d.member && d.member.walled === false && d.paywall && d.paywall.mode === "limited") { unwalled = true; renewFull = true; buildNav(); }
1061        });
1062        else if (isWallExempt() || isPaywallOnly()) whoAmI().then(function (d) {
1063          if (d && d.member && d.member.walled === false) { unwalled = true; paywalled = isPaywalled(d); buildNav(); }
1064          /* Messages with /me unreadable: the full shell, as on any page. */
1065          else if (isPaywallOnly()) { unwalled = true; buildNav(); }
1066        });
1067        return; // no avatar, bell, drawer or burger to wire
1068      }
1069      fillShellAvatar();
1070      wireAvatarMenu();
1071      wireBell();
1072    } else if (!mount.querySelector(".nav-inner")) {
1073      mount.className = "site-nav nav-" + navMode;
1074      mount.innerHTML = navHtml(navMode);
1075    }
1076
1077    function onScroll() {
1078      if (window.scrollY > 24) mount.classList.add("scrolled");
1079      else mount.classList.remove("scrolled");
1080    }
1081    onScroll();
1082    window.addEventListener("scroll", onScroll, { passive: true });
1083
1084    var burger = mount.querySelector(".nav-burger");
1085    var drawer = document.getElementById("nav-drawer");
1086    if (!drawer && burger) {
1087      drawer = document.createElement("div");
1088      drawer.className = "nav-drawer";
1089      drawer.id = "nav-drawer";
1090      drawer.setAttribute("role", "dialog");
1091      drawer.setAttribute("aria-modal", "true");
1092      drawer.setAttribute("aria-label", "Menu");
1093      drawer.innerHTML = drawerHtml();
1094      document.body.appendChild(drawer);
1095    }
1096    /* The shell and the drawer both exist now. If /me already answered, this
1097       is the call that actually paints the switches; if it has not, the
1098       fetch's own .then does it and this is a no-op. */
1099    paintFeatures();
1100
1101    if (!burger) return;
1102    burger.setAttribute("aria-controls", "nav-drawer");
1103
1104    function drawerLinks() {
1105      // summary: the group toggles. getClientRects drops hidden rows, but NOT
1106      // rows folded inside a closed group (Chrome still lays them out), and
1107      // those cannot take focus either (cf193079, P1-028).
1108      return [].slice.call(drawer.querySelectorAll('a[href], button:not([disabled]), summary'))
1109        .filter(function (el) {
1110          var shut = el.closest("details:not([open])");
1111          return el.getClientRects().length && (!shut || el.parentElement === shut && el.tagName === "SUMMARY");
1112        });
1113    }
1114    function openDrawer() {
1115      document.body.classList.add("drawer-open");
1116      burger.setAttribute("aria-expanded", "true");
1117      burger.setAttribute("aria-label", "Close menu");
1118      document.addEventListener("keydown", onDrawerKey);
1119      // defer focus until the drawer has settled: it flips from
1120      // visibility:hidden on open and isn't focusable until style/layout
1121      // recalculates (double rAF is the reliable "after paint" hook)
1122      requestAnimationFrame(function () {
1123        requestAnimationFrame(function () {
1124          var f = drawerLinks();
1125          if (f.length) f[0].focus();
1126        });
1127      });
1128    }
1129    function closeDrawer() {
1130      document.body.classList.remove("drawer-open");
1131      burger.setAttribute("aria-expanded", "false");
1132      burger.setAttribute("aria-label", "Open menu");
1133      document.removeEventListener("keydown", onDrawerKey);
1134      burger.focus();
1135    }
1136    function onDrawerKey(e) {
1137      if (e.key === "Escape") { closeDrawer(); return; }
1138      if (e.key !== "Tab") return;
1139      var f = drawerLinks();
1140      if (!f.length) return;
1141      var first = f[0], last = f[f.length - 1];
1142      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
1143      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
1144    }
1145    burger.addEventListener("click", function () {
1146      if (document.body.classList.contains("drawer-open")) closeDrawer();
1147      else openDrawer();
1148    });
1149    drawer.addEventListener("click", function (e) {
1150      if (e.target.closest("a, .ps-close")) closeDrawer();
1151    });
1152
1153    /* A drawer opened on a phone and then widened past the breakpoint — an
1154       iPad turned to landscape, a window dragged bigger — was left open with
1155       the burger that closes it display:none. Escape still worked, which is
1156       no help on a tablet. Closing it when its own button stops being
1157       visible fixes the shell and the marketing nav in the same place. */
1158    window.addEventListener("resize", function () {
1159      if (!document.body.classList.contains("drawer-open")) return;
1160      if (getComputedStyle(burger).display === "none") closeDrawer();
1161    });
1162  }
1163
1164  /* =================================================================
1165     FOOTER
1166     ================================================================= */
1167  function footerHtml() {
1168    var explore =
1169      [{ label: "Home", href: ROOT + "/" }]
1170      .concat(NAV).concat(MORE)
1171      .map(function (n) { return '<li><a href="' + n.href + '">' + n.label + "</a></li>"; }).join("");
1172    return '<div class="container">' +
1173        '<div class="footer-top">' +
1174          '<div class="footer-brand">' +
1175            '<img src="' + ROOT + '/assets/yec-cream.png" alt="YEC" class="footer-logo" width="400" height="183" />' +
1176            '<p class="footer-tag">Driven by Ambition.<br>Empowered by Community.</p>' +
1177            '<a class="link-arrow footer-cta" href="' + APPLY + '">See if the table is yours <span class="arrow">&rarr;</span></a>' +
1178            socialHtml() +
1179          "</div>" +
1180          '<div class="footer-col">' +
1181            '<h2 class="footer-h">Explore</h2>' +
1182            '<ul class="footer-list">' + explore + "</ul>" +
1183          "</div>" +
1184          '<div class="footer-col">' +
1185            '<h2 class="footer-h">Connect</h2>' +
1186            '<ul class="footer-list">' +
1187              '<li><a href="mailto:[email protected]">[email protected]</a></li>' +
1188              '<li><a href="' + ROOT + '/contact">Contact</a></li>' +
1189              '<li><a href="' + PORTAL + '">Member Login</a></li>' +
1190              '<li><a href="' + APPLY + '">Apply</a></li>' +
1191            "</ul>" +
1192          "</div>" +
1193        "</div>" +
1194        '<hr class="rule footer-rule" />' +
1195        '<div class="footer-bottom">' +
1196          '<p class="small muted">&copy; 2026 YEC Global, LLC.</p>' +
1197          '<div class="footer-legal">' +
1198            '<a href="/terms">Terms &amp; Conditions</a>' +
1199            '<a href="/privacy">Privacy Policy</a>' +
1200            '<a href="/accessibility">Accessibility</a>' +
1201            '<a href="/privacy#do-not-sell">Do Not Sell or Share My Personal Information</a>' +
1202          "</div>" +
1203        "</div>" +
1204      "</div>";
1205  }
1206
1207  /* Behind the login the marketing footer is wrong twice over: it invites a
1208     signed-in member to apply, and its "Member Login" still points at the
1209     retired Mighty network. The portal gets its own short footer: the links a
1210     member actually needs, and Sign out, which the audit found sitting in the
1211     home hero (U2, U3, 2026-09-03). */
1212  function portalFooterHtml() {
1213    var member = inShell(); // S1-12: signed out, only the public links are left
1214    return '<div class="container ps-footer-in">' +
1215        '<nav class="ps-footer-links" aria-label="Portal">' +
1216          (member ? '<a href="/members/code-of-conduct/">Code of Conduct</a>' +
1217            '<a href="/members/account/">Your account</a>' : "") +
1218          '<a href="/privacy">Privacy</a>' +
1219          '<a href="/accessibility">Accessibility</a>' +
1220          '<a href="mailto:[email protected]">[email protected]</a>' +
1221          (member ? '<button type="button" class="ps-footer-signout" id="ps-signout">Sign out</button>' : "") +
1222        "</nav>" +
1223        '<p class="small">&copy; 2026 YEC Global, LLC.</p>' +
1224      "</div>";
1225  }
1226  function wireSignout(btn) {
1227    btn.addEventListener("click", function () {
1228      btn.disabled = true;
1229      btn.textContent = "Signing out\u2026";
1230      fetch("/api/members/logout", { method: "POST", credentials: "same-origin" })
1231        .catch(function () {})
1232        .then(function () { location.assign("/members/login/"); });
1233    });
1234  }
1235  function wirePortalSignout() {
1236    document.querySelectorAll(".ps-footer-signout").forEach(wireSignout);
1237  }
1238  function buildFooter() {
1239    var mount = document.getElementById("site-footer");
1240    if (!mount) return;
1241    if (inShell() || inPortal()) {
1242      mount.className = "site-footer ps-footer";
1243      mount.innerHTML = portalFooterHtml();
1244      wirePortalSignout();
1245      return;
1246    }
1247    // server-rendered footer already carries .footer-top — leave it (no JS
1248    // behavior); empty container = runtime fallback, build it now
1249    if (mount.querySelector(".footer-top")) return;
1250    mount.className = "site-footer bg-navy";
1251    mount.innerHTML = footerHtml();
1252  }
1253
1254  /* =================================================================
1255     SCROLL REVEALS
1256     ================================================================= */
1257  function initReveals() {
1258    var els = [].slice.call(document.querySelectorAll(".reveal"));
1259    if (!("IntersectionObserver" in window) || !els.length) {
1260      els.forEach(function (e) { e.classList.add("in"); });
1261      return;
1262    }
1263    var io = new IntersectionObserver(function (entries) {
1264      entries.forEach(function (en) {
1265        if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); }
1266      });
1267    }, { threshold: 0.12, rootMargin: "0px 0px -8% 0px" });
1268    els.forEach(function (e) { io.observe(e); });
1269  }
1270
1271  /* =================================================================
1272     ORBIT LAYOUT : place .orbit-dot around the stage by data-angle
1273     ================================================================= */
1274  function layoutOrbits() {
1275    [].slice.call(document.querySelectorAll(".orbit-stage")).forEach(function (stage) {
1276      var dots = [].slice.call(stage.querySelectorAll(".orbit-dot"));
1277      var rect = stage.getBoundingClientRect();
1278      var R = rect.width / 2;
1279      dots.forEach(function (dot) {
1280        var ang = parseFloat(dot.getAttribute("data-angle")) || 0;
1281        var rad = parseFloat(dot.getAttribute("data-radius")) || 0.82;
1282        var a = (ang - 90) * Math.PI / 180;
1283        var x = Math.cos(a) * R * rad;
1284        var y = Math.sin(a) * R * rad;
1285        // anchor the NODE SQUARE (not the node+label row) on the ring point,
1286        // so the square sits centered ON the orbit line (Drew, 7/8).
1287        // data-flip overrides which side the label hangs (outer dots point inward).
1288        var flipAttr = dot.getAttribute("data-flip");
1289        var isFlip = flipAttr === null ? ang > 180 : flipAttr === "1";
1290        dot.classList.toggle("flip", isFlip);
1291        var nodeHalf = 9; // .node is 18px square
1292        var anchor = isFlip
1293          ? "translate(calc(-100% + " + nodeHalf + "px), -50%)"
1294          : "translate(-" + nodeHalf + "px, -50%)";
1295        dot.style.transform = "translate(" + x + "px," + y + "px) " + anchor;
1296      });
1297    });
1298  }
1299
1300  /* =================================================================
1301     SMALL INTERACTIONS : FAQ accordion + chip groups
1302     ================================================================= */
1303  function initInteractions() {
1304    // items server-rendered open (.faq-item.open) get a real measured height,
1305    // not a hardcoded cap that could clip long answers (audit L10)
1306    [].slice.call(document.querySelectorAll(".faq-item.open .faq-a")).forEach(function (a) {
1307      a.style.maxHeight = a.scrollHeight + "px";
1308    });
1309    [].slice.call(document.querySelectorAll(".faq-q")).forEach(function (q) {
1310      q.addEventListener("click", function () {
1311        var item = q.closest(".faq-item");
1312        var ans = item.querySelector(".faq-a");
1313        var open = item.classList.toggle("open");
1314        ans.style.maxHeight = open ? ans.scrollHeight + "px" : "0px";
1315        q.setAttribute("aria-expanded", open ? "true" : "false");
1316      });
1317    });
1318  }
1319
1320  /* =================================================================
1321     COUNTDOWN : drives any [data-countdown-to="ISO date"] element
1322     ================================================================= */
1323  function initCountdown() {
1324    var LABELS = { days: "Days", hrs: "Hours", min: "Minutes", sec: "Seconds" };
1325    [].slice.call(document.querySelectorAll("[data-countdown-to]")).forEach(function (el) {
1326      var target = new Date(el.getAttribute("data-countdown-to")).getTime();
1327      if (isNaN(target)) return;
1328      var sep = el.hasAttribute("data-no-sep") ? "" : '<span class="cd-sep">:</span>';
1329      el.classList.add("countdown");
1330      el.innerHTML = ["days", "hrs", "min", "sec"].map(function (u) {
1331        return '<div class="cd-unit"><span class="cd-num" data-u="' + u + '">00</span><span class="cd-lab">' + LABELS[u] + "</span></div>";
1332      }).join(sep);
1333      function pad(n) { return (n < 10 ? "0" : "") + n; }
1334      function setU(u, v) { var n = el.querySelector('[data-u="' + u + '"]'); if (n) n.textContent = pad(v); }
1335      function tick() {
1336        var diff = target - Date.now();
1337        if (diff < 0) diff = 0;
1338        setU("days", Math.floor(diff / 86400000));
1339        setU("hrs", Math.floor((diff % 86400000) / 3600000));
1340        setU("min", Math.floor((diff % 3600000) / 60000));
1341        setU("sec", Math.floor((diff % 60000) / 1000));
1342        if (diff <= 0) { clearInterval(el.__cd); el.setAttribute("data-complete", "true"); }
1343      }
1344      tick();
1345      el.__cd = setInterval(tick, 1000);
1346    });
1347  }
1348
1349  /* =================================================================
1350     EVENTS : concluded rows ([data-ends] in the past) move to the
1351     Past events section (dimmed, replay note); hidden if none exists
1352     ================================================================= */
1353  function initEventExpiry() {
1354    var now = Date.now();
1355    var PAST_VISIBLE = 3; // show a few; "See more" reveals the rest (Drew 2026-08-06: three)
1356    var pastWrap = document.getElementById("past-events");
1357    var pastList = pastWrap ? pastWrap.querySelector(".event-list") : null;
1358    var rows = document.querySelectorAll(".event-list.upcoming .event-row[data-ends]");
1359    if (!rows.length) rows = document.querySelectorAll(".event-row[data-ends]");
1360    var moved = [];
1361    [].slice.call(rows).forEach(function (row) {
1362      var t = Date.parse(row.getAttribute("data-ends"));
1363      if (isNaN(t) || now <= t) return;
1364      if (pastList) moved.push({ row: row, t: t });
1365      else row.hidden = true; // no Past section on this page: original behavior
1366    });
1367    if (pastList && moved.length) {
1368      // Client-expired rows are NEWER than every server-rendered past row
1369      // (they ended after the page was rendered), so they belong at the TOP
1370      // of the newest-first list. Insert oldest-moved first so the newest
1371      // ends up on top.
1372      moved.sort(function (a, b) { return a.t - b.t; });
1373      moved.forEach(function (m) {
1374        m.row.classList.add("is-past");
1375        pastList.insertBefore(m.row, pastList.firstElementChild);
1376      });
1377      pastWrap.hidden = false;
1378      var link = document.querySelector(".past-link");
1379      if (link) link.hidden = false;
1380    }
1381    // The See-more cap applies to the WHOLE past list — server-rendered rows
1382    // and client-moved rows alike (it used to count only the moved ones, so
1383    // a fully server-rendered history never got the button).
1384    if (pastList) {
1385      var allPast = [].slice.call(pastList.querySelectorAll(".event-row"));
1386      allPast.forEach(function (r, i) { r.hidden = i >= PAST_VISIBLE; });
1387      var more = document.getElementById("past-more");
1388      if (more && allPast.length > PAST_VISIBLE) {
1389        more.hidden = false;
1390        more.addEventListener("click", function () {
1391          allPast.forEach(function (r) { r.hidden = false; });
1392          more.hidden = true;
1393        });
1394      }
1395    }
1396    var list = document.querySelector(".event-list.upcoming") || document.querySelector(".event-list");
1397    if (list && list.querySelectorAll(".event-row:not([hidden])").length === 0) {
1398      var empty = document.querySelector(".events-empty");
1399      if (empty) empty.hidden = false;
1400    }
1401  }
1402
1403  /* Event times in the visitor's own time zone, with the event's zone kept
1404     in parens: "11:00 AM CT (12:00 PM ET)". Skips when the zones match, and
1405     leaves the server-rendered text for no-JS visitors and crawlers. */
1406  function initLocalTimes() {
1407    var els = document.querySelectorAll(".ev-localtime[data-starts]");
1408    if (!els.length || !window.Intl || !Intl.DateTimeFormat) return;
1409    [].slice.call(els).forEach(function (el) {
1410      var t = Date.parse(el.getAttribute("data-starts"));
1411      if (isNaN(t)) return;
1412      var local;
1413      try {
1414        local = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short" }).format(t);
1415      } catch (e) { return; }
1416      local = local.replace(/\b([ECMP])[DS]T\b/, "$1T"); // "11:00 AM CDT" -> "11:00 AM CT"
1417      var srcLabel = el.getAttribute("data-tz-label") || "";
1418      if (srcLabel && local.indexOf(" " + srcLabel) > -1) return; // viewer is in the event's zone
1419      el.textContent = local + " (" + el.textContent + ")";
1420    });
1421  }
1422
1423  /* ---------- Boot ---------- */
1424  /* Who is active: active vs dormant minutes (QA-4 E-4). Member pages only. */
1425  function loadActivity() {
1426    if (!/^\/members\//.test(location.pathname) || document.getElementById("yec-activity")) return;
1427    var s = document.createElement("script");
1428    s.id = "yec-activity";
1429    s.src = "/js/members/activity.js?v=3";
1430    s.defer = true;
1431    document.head.appendChild(s);
1432  }
1433
1434  document.addEventListener("DOMContentLoaded", function () {
1435    loadActivity();
1436    buildNav();
1437    buildFooter();
1438    initReveals();
1439    layoutOrbits();
1440    initInteractions();
1441    initCountdown();
1442    initEventExpiry();
1443    initLocalTimes();
1444  });
1445  window.addEventListener("resize", function () { clearTimeout(window.__orbT); window.__orbT = setTimeout(layoutOrbits, 120); });
1446  window.addEventListener("load", layoutOrbits);
1447})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.