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"> </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">→</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">© 2026 YEC Global, LLC.</p>' + 1197 '<div class="footer-legal">' + 1198 '<a href="/terms">Terms & 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">© 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.