1// Directory â who leads what, who is in which pod, and everyone in camp. 2// 3// **Signed in only.** This is a list of real people at a real address, and camp 4// decided it is not for the open internet. Guardrail 5 still holds for the 5// schedule, the live view and the training pages; the directory is the one 6// public-facing screen that now asks who you are first. The server answers 401 7// to a browser it has not met, and a 401 here is *a state, not an error*: a 8// camper who taps Directory before signing in gets an invitation, because the 9// answer to "who are you?" is a name and a pod and takes ten seconds. 10// 11// ## Rule 8, on the screen that would most easily break it 12// 13// **No money for anybody appears here â not even your own.** What somebody owes 14// is on My Shifts, which is theirs and behind their own session, and other 15// people's belong to the admin view behind auth. Weighted shift counts went the 16// same way: a person's count is their own business and camp leadership's, the 17// payload no longer carries one for anybody (`check-me.js` and `check-roster.js` 18// both sweep for it), and this screen draws no shift figure at all. Your own is 19// on My Shifts. 20// 21// The design's photo slots are a drag-and-drop `<image-slot>`. Dropping a file 22// in is still out of scope â there is nowhere to put it that survives 23// `cp data/camp.sqlite` â so no slot is offered as a drop target. What has 24// landed is the other half: a photo camp supplies with the roster arrives as 25// `person.photo`, a path, and the slot draws it. Everybody else keeps the 26// neutral initials disc, which is the state it always drew. 27 28import { el, fill } from '../lib/dom.js'; 29import { getJson, ApiError, OfflineError } from '../lib/api.js'; 30import { personLine, distinctPlaya, count } from '../lib/format.js'; 31// Rule 8: the name a screen may print is the one the server published, never 32// `default_name` â which is simply absent for anybody publishing their playa 33// name alone. See the note above `personDisplayName`. 34import { searchPeople, personDisplayName, personDetail } from '../lib/people.js'; 35import { withViewTime } from '../lib/view-clock.js'; 36// The shared vocabulary (`components/ui.js` has the count of copies this 37// replaces). `personRow` is not among these â see the note above `leaderCard`
38// and `personCard`: this screen's rows carry role tags, a contact line and 39// an is-me treatment `personRow` has no slot for; they keep its *shape* 40// (avatar · name over sub, design §10) in this screen's own classes. The 41// avatar itself is the shared `avatar()` on the one ladder â this file used 42// to hand-roll three disc sizes as `.dir-photo`. 43import { 44 offlineBanner, errorPanel, skeleton, emptyState, sectionHead, buttonRow, button, 45 avatar, card, marker, openPortrait, faceRow, facePortrait } from '../components/ui.js'; 46 47/* --- the screen as a whole ------------------------------------------------ */ 48 49export function mountDirectory(root, { onNavigate = null, onPerson = null } = {}) { 50 const state = { 51 status: 'loading', 52 error: '', 53 offline: !navigator.onLine, 54 data: null, 55 query: '', 56 // Name matching comes from the same server-side fuzzy finder used by the 57 // sign-in and admin people pickers. The Directory payload deliberately 58 // does not carry private first/last names (or a withheld default name), so 59 // client-only filtering can never answer those searches without leaking 60 // the fields. Only matching person ids come back from the finder. 61 searchMatchIds: null, 62 searchRanks: null, 63 searchLoading: false, 64 searchSequence: 0, 65 searchTimer: null, 66 // Which pod is open. `?pod=` seeds it, so a pod is a link somebody can send 67 // â and, since 2026-08-05, so that a profile's pod line has somewhere to go: 68 // camp has no pod page, and the pod card open on this screen is the closest 69 // true thing to one (`app.js#openPod`). 70 openPod: podFromUrl(), 71 // Set once, when a `?pod=` arrival is first drawn: the card is halfway down 72 // a long page and landing at the top of it looks like the link did nothing. 73 scrollToPod: podFromUrl() !== null, 74 showAll: false, 75 focusId: null, 76 onShowAll: null, 77 }; 78 79 function draw() { 80 renderDirectory(root, state, actions); 81 if (state.focusId) { 82 document.getElementById(state.focusId)?.focus({ preventScroll: true }); 83 state.focusId = null; 84 } 85 // Once, and only for a pod this page actually has: a link to a pod camp has 86 // retired scrolls nowhere rather than to the wrong card. 87 if (state.scrollToPod && state.status === 'ready') { 88 state.scrollToPod = false; 89 // Not `card` â that is the shared primitive imported above, and shadowing 90 // an import inside one block is how a later edit reaches for the wrong one. 91 const target = document.getElementById(`dir-pod-${state.openPod}`); 92 if (target) { 93 const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 94 target.scrollIntoView({ behavior: still ? 'auto' : 'smooth', block: 'center' }); 95 } 96 } 97 } 98 99 async function load() { 100 if (state.status !== 'ready') state.status = 'loading'; 101 draw(); 102 try { 103 state.data = await getJson('/api/directory'); 104 state.status = 'ready'; 105 state.error = ''; 106 state.offline = false; 107 } catch (err) { 108 state.offline = err instanceof OfflineError; 109 if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { 110 // Not a failure â a browser camp has not met yet. Say what is behind 111 // the door and how cheap the key is. 112 state.data = null; 113 state.status = 'signed-out'; 114 state.error = ''; 115 } else { 116 state.status = state.data ? 'ready' : 'error'; 117 state.error = messageFor(err, 118 'The directory did not load. Check the camp server is running, then try again.'); 119 } 120 } 121 draw(); 122 } 123 124 const actions = { 125 go: onNavigate, 126 // Every name on this page is a door to that person's own page. `null` when 127 // the shell has not registered one, and every card below degrades to the 128 // static block it always was rather than drawing a button that goes nowhere. 129 openPerson: onPerson, 130 131 retry() { 132 state.offline = !navigator.onLine; 133 load(); 134 }, 135 136 search(value) { 137 state.query = value.trim(); 138 clearTimeout(state.searchTimer); 139 const sequence = ++state.searchSequence; 140 state.searchMatchIds = null;
141 state.searchRanks = null; 142 state.searchLoading = state.query.length >= 2; 143 // Redrawing the whole screen would take focus off the box mid-word. 144 redrawResults(root, state, actions); 145 // The page's own preamble is context when you are browsing and noise when 146 // you are looking for one person â on a phone it is most of the screen 147 // above the results. It hides itself while there is a query, without a 148 // redraw that would cost the caret. 149 for (const id of ['dir-lede']) { 150 const node = root.querySelector(`#${id}`); 151 if (node) node.hidden = !!state.query; 152 } 153 154 // One character is too broad to send to the people finder. Published 155 // names, roles and pods still narrow locally. At two characters the 156 // ranked search takes over, and the provisional local list disappears: 157 // showing alphabetical rows now and relevance-ranked rows a moment later 158 // moved a different person under somebody's pointer. 159 if (!state.searchLoading) return; 160 161 const query = state.query; 162 state.searchTimer = setTimeout(async () => { 163 const found = await searchPeople(query, { 164 limit: 25, 165 pods: state.data?.pods, 166 }); 167 // A slower answer to an older query must never repaint a newer one. 168 if (sequence !== state.searchSequence || query !== state.query) return; 169 state.searchMatchIds = new Set(found.map((person) => person.id)); 170 state.searchRanks = new Map(found.map((person, index) => [person.id, index])); 171 state.searchLoading = false; 172 redrawResults(root, state, actions); 173 }, 180); 174 }, 175 176 togglePod(id) { 177 state.openPod = state.openPod === id ? null : id; 178 state.focusId = `dir-pod-${id}`; 179 // The open pod rides the address, so the card somebody is looking at is a 180 // link they can send â and so a profile's pod line has an address to point 181 // at. `replaceState`, not push: opening and shutting cards is reading this 182 // page, not navigating away from it, and a history entry per tap is a back 183 // button that takes six presses to leave. 184 const params = new URLSearchParams(location.search); 185 if (state.openPod === null) params.delete('pod'); 186 else params.set('pod', String(state.openPod)); 187 history.replaceState(null, '', withViewTime(`${location.pathname}?${params}`)); 188 draw(); 189 }, 190 191 /** 192 * A pod lead's face, big enough to recognise on playa â the owner's ask 193 * (2026-08-05). It is the shared sheet, so it closes on the scrim, on 194 * Escape and on the picture itself, and focus comes back to the disc that 195 * opened it (`components/ui.js#openPortrait`). 196 */ 197 openPortrait, 198 199 showAll() { 200 state.showAll = true; 201 redrawResults(root, state, actions); 202 }, 203 }; 204 state.onShowAll = actions.showAll; 205 206 const listeners = [ 207 [window, 'offline', () => { state.offline = true; draw(); }], 208 [window, 'online', () => { state.offline = false; load(); }], 209 ]; 210 for (const [target, event, fn] of listeners) target.addEventListener(event, fn); 211 const destroy = () => { 212 clearTimeout(state.searchTimer); 213 state.searchSequence += 1; 214 for (const [target, event, fn] of listeners) target.removeEventListener(event, fn); 215 }; 216 217 const ready = load(); 218 return { state, reload: load, ready, destroy }; 219} 220 221/** 222 * The pod the address asks for, or null. 223 * 224 * A pod id, because that is what the payload keys a pod on and what 225 * `togglePod` already carries â a name in an address would be a second 226 * identity for a row that has one (guardrail 4's cousin: Rule 1's pod table is 227 * the maintained list, and its ids are how the rest of the app refers to it). 228 */ 229function podFromUrl() { 230 const raw = new URLSearchParams(location.search).get('pod'); 231 return /^\d+$/.test(String(raw ?? '')) ? Number(raw) : null; 232} 233 234function messageFor(err, fallback) { 235 if (err instanceof OfflineError) { 236 return 'No connection to the camp server. Check the wifi or the box, then try again.'; 237 } 238 if (err instanceof ApiError) return err.message; 239 return fallback; 240} 241 242/* --- searching ------------------------------------------------------------ */ 243 244const queryKey = (state) => state.query.trim().toLowerCase(); 245 246// This is the offline-capable answer for a one-character query, and supplements 247// the finder's bounded answer after a longer search completes. It is never 248// drawn provisionally while ranking is on the wire. `default_name` matters 249// here: with playa-first display order it is not `personDisplayName(person)`, 250// even though it is still publishable and present in the payload. Private 251// profile names remain server-side only. 252const publishedHaystack = (person) => [ 253 personDisplayName(person), person.default_name, person.playa_name, person.pod,
254].filter(Boolean).join(' ').toLowerCase(); 255 256const matches = (person, state) => { 257 const query = queryKey(state); 258 if (!query) return true; 259 return publishedHaystack(person).includes(query) 260 || Boolean(state.searchMatchIds?.has(person.id)); 261}; 262 263/* --- rendering ------------------------------------------------------------ */ 264 265export function renderDirectory(root, state, actions) { 266 fill(root, [ 267 state.offline && state.data ? offlineBanner('the directory', actions.retry) : null, 268 body(state, actions), 269 ]); 270} 271 272function body(state, actions) { 273 if (state.status === 'signed-out') return invitation(actions); 274 if (!state.data) { 275 return state.status === 'error' ? directoryErrorPanel(state, actions) : skeleton('Loading the directoryâ¦', 6); 276 } 277 278 const view = el('div', { class: 'dir' }, [ 279 header(state, actions), 280 el('div', { 281 class: 'dir-results', id: 'dir-results', 282 'aria-busy': state.searchLoading ? 'true' : 'false', 283 }, results(state, actions)), 284 el('p', { class: 'note' }, 285 'Everyone who has signed up for anything in camp. Photos come in with the roster, so ' 286 + 'most cards show initials â there is no way to add one from here yet.'), 287 ]); 288 if (state.status === 'loading') view.classList.add('is-stale'); 289 return view; 290} 291 292/** Redraw only the part the search box changes, so the caret stays put. */ 293function redrawResults(root, state, actions) { 294 const host = root.querySelector('#dir-results'); 295 if (!host) return renderDirectory(root, state, actions); 296 host.setAttribute('aria-busy', state.searchLoading ? 'true' : 'false'); 297 fill(host, results(state, actions)); 298 const status = root.querySelector('#dir-search-status'); 299 if (status) { 300 status.textContent = state.searchLoading 301 ? 'Searching every name on fileâ¦' 302 : (state.query ? 'Search results ready.' : ''); 303 } 304 return host; 305} 306 307function directoryErrorPanel(state, actions) { 308 return errorPanel({ title: 'The directory did not load', message: state.error, onRetry: actions.retry }); 309} 310 311/** 312 * The 401, as a camper reads it. An invitation, not a wall: it names what is 313 * behind the door and how little it costs to open. Sign-in itself lives on My 314 * Shifts and only there â a second copy of that form is a second place for the 315 * name-and-pod identity rule to drift. 316 */ 317function invitation(actions) { 318 // `button()` only ever renders a `<button>` â there is no `href` it can take 319 // â so the no-router fallback (this screen mounted standalone, with nothing 320 // wired to `actions.go`) stays a plain `<a>` here. 321 const signIn = actions.go 322 ? button('Sign in', () => actions.go('me'), { tone: 'primary' }) 323 : el('a', { class: 'btn btn--primary', href: withViewTime('/?screen=me') }, 'Sign in'); 324 325 return el('div', { class: 'dir-invite' }, [ 326 el('h1', { class: 'display-sm' }, 'Sign in to see whoâs who in camp'), 327 // The shared card, so the invitation reads as a room with a door in it 328 // rather than free-floating text â same surface as every panel in the app. 329 card([ 330 el('p', { class: 'dir-lede' }, 331 'The directory is campâs address book â who leads what, who is in which pod, and ' 332 + 'everyone signed up. It is for camp, not for the open internet, so it asks who you ' 333 + 'are first.'), 334 el('p', { class: 'dir-lede' }, 335 'Your name and your pod is all it takes. No email, no password â and this device ' 336 + 'remembers you until you sign out.'), 337 el('div', { class: 'dir-invite-actions' }, [ 338 buttonRow([ 339 signIn, 340 actions.go ? button('Just show me the schedule â', () => actions.go('schedule'), { tone: 'quiet' }) : null, 341 ]), 342 ]), 343 ]), 344 el('p', { class: 'note' }, 345 'The schedule, the live view and the training pages stay open to everybody.'), 346 ]); 347} 348 349function header(state, actions) { 350 return el('header', { class: 'dir-head' }, [ 351 el('h1', { class: 'display-sm' }, 'Directory'), 352 el('p', { class: 'dir-lede', id: 'dir-lede', hidden: !!state.query }, 353 'Camp, build, strike and territory leadership, then every pod and everyone signed up. ' 354 + 'Headcounts come from the sign-ups, so they move as the week fills.'), 355 el('label', { class: 'dir-search' }, [ 356 el('span', { class: 'sr-only' }, 'Search the directory'), 357 el('input', { 358 class: 'field', 359 type: 'search', 360 id: 'dir-q', 361 value: state.query, 362 placeholder: 'Search by name, playa name or pod',
363 oninput: (e) => actions.search(e.target.value), 364 }), 365 el('span', { class: 'sr-only', id: 'dir-search-status', role: 'status' }, 366 state.searchLoading ? 'Searching every name on fileâ¦' : ''), 367 ]), 368 ]); 369} 370 371/** 372 * Browsing and searching want opposite orders. 373 * 374 * Browsing, the page reads top-down the way camp is shaped (the owner's 375 * two-section ruling, 2026-08-05): Camp leadership â every lead role except 376 * the pod leads, one card per person â then the pod leads and their pods, then 377 * everyone. Searching, somebody has typed a name and wants that person â so 378 * the people come first and the rest of camp follows underneath. 379 */ 380function results(state, actions) { 381 // Clickable answers arrive in one final order. Until the shared finder has 382 // ranked this query there is deliberately no provisional person, role or pod 383 // row to move â or to be clicked â underneath a waiting pointer. 384 if (state.searchLoading) return [emptyState('Searching every name on fileâ¦')]; 385 const leadership = leadershipSection(state, actions); 386 const pods = podSection(state, actions); 387 const everyone = everyoneSection(state, actions); 388 return state.query ? [everyone, leadership, pods] : [leadership, pods, everyone]; 389} 390 391/* --- leadership ----------------------------------------------------------- 392 * 393 * The owner's ruling (2026-08-05): one "Camp leadership" section holding every 394 * person with any lead role except the pod leads â camp lead, build lead, 395 * strike lead, teahouse lead, and so on â one card per person however many 396 * roles they hold, the roles as tags on the card, and **no placeholder cards 397 * or missing-role notices**. Admins belong in this 398 * section too; `leadershipEntries` reads the payload's `is_admin` for them. 399 */ 400 401/** 402 * Which `groups` rows are the pod leads: the ones whose roles carry a 403 * `pod_id`. The server draws exactly this line (`lead_role.pod_id`, 404 * `api-me.js#directory`) and since 2026-08-05 says so on every role in the 405 * payload â this used to be derived by matching the group's filled people 406 * against `pods[].leads`, a heuristic that could misclassify a group whose 407 * seats happened to equal that set. A group counts as pod-leads when any of 408 * its roles names a pod; mixed groups do not exist server-side (the pod query 409 * is `pod_id IS NOT NULL`, the grouping is by title). 410 */ 411const isPodLeadGroup = (group) => group.roles.some((r) => r.pod_id != null); 412 413/** 414 * Everyone in camp leadership, one entry per person. 415 * 416 * A person holding two roles â camp has several â is one card with two tags, 417 * in the order camp wrote the roles (`sort_order`), first appearance first. 418 * Role contacts ride along; the entry keeps the person's own shape for 419 * `openable` and the avatar. 420 * 421 * **Admins are here too** ("all admins are also part of that group"): 422 * `/api/directory` publishes `is_admin` on `people` (2026-08-05, the owner's 423 * ruling that who runs camp is directory material), and the guard below picks 424 * them up â an admin with no lead role gets a card of their own. 425 * 426 * They no longer wear a **tag** for it: "the Camp admin tag is unnecessary but 427 * keep all camp admins in there" (the owner, later the same day). So `is_admin` 428 * decides who has a card here and says nothing on it; an admin who also leads 429 * something shows the thing they lead, and an admin who leads nothing shows 430 * their name and their pod. Nothing else on this page changed â the flag still 431 * comes from the payload, never from a password or a session. 432 */ 433function leadershipEntries(data) { 434 const byPerson = new Map(); 435 const add = (person, roleTitle, contact) => { 436 const entry = byPerson.get(person.id) ?? { person, roles: [], contacts: [] }; 437 if (roleTitle && !entry.roles.includes(roleTitle)) entry.roles.push(roleTitle); 438 if (contact && !entry.contacts.includes(contact)) entry.contacts.push(contact); 439 byPerson.set(person.id, entry); 440 }; 441 for (const group of data.groups) { 442 if (isPodLeadGroup(group)) continue; 443 for (const role of group.roles) { 444 if (role.person) add(role.person, role.role_title, role.contact); 445 } 446 } 447 for (const person of data.people) { 448 // A card, no tag. `add` with no title registers the person and leaves the 449 // role list alone, so an admin who is also the Teahouse lead keeps exactly 450 // one line on their card and it is the true one. 451 if (person.is_admin === true) add(person, null, null); 452 } 453 return [...byPerson.values()]; 454} 455 456/** 457 * A leadership entry matches a search if the person does, or if the query 458 * names one of their roles â "teahouse" finds the teahouse lead. Browsing with 459 * nobody confirmed yet keeps the heading and says so; searching, a section
460 * with no hits drops out under the person the search was for. 461 */ 462function leadershipSection(state, actions) { 463 const query = queryKey(state); 464 const entries = leadershipEntries(state.data).filter((entry) => !query 465 || matches(entry.person, state) 466 || entry.roles.some((role) => role.toLowerCase().includes(query))); 467 if (query && !entries.length) return null; 468 return el('section', { class: 'dir-section' }, [ 469 sectionHead([ 470 el('span', {}, 'Camp leadership'), 471 el('span', { class: 'dir-section-note' }, 'who to ask, and about what'), 472 ], { rule: true }), 473 entries.length 474 ? el('div', { class: 'dir-cards' }, 475 entries.map((entry) => leaderCard(entry, actions))) 476 : emptyState('Camp has not confirmed anybody yet.'), 477 ]); 478} 479 480/** 481 * `<span>` throughout rather than `<p>` and `<div>`, because this card becomes a 482 * `<button>` when there is a profile to open and a button may only contain 483 * phrasing content. The pod card beside it has always been built this way for 484 * the same reason, and the stylesheet already gives these classes `display: 485 * block` â so the two shapes render identically. 486 * 487 * The §10 row: avatar, the name (with the "you" marker, so you can find 488 * yourself in leadership the way you can everywhere else), each role as its 489 * own tag line, then `personDetail` (lib/people.js) â the shared second line: 490 * the name the heading is not showing, only when it says something new, then 491 * the pod. The card always holds a real person now; empty seats stopped 492 * rendering on the owner's ruling. 493 */ 494function leaderCard({ person, roles, contacts }, actions) { 495 const title = leadTitle(roles); 496 return faceRow({ 497 class: ['dir-card', 'dir-lead'], 498 face: faceControl(person, 's', actions), 499 label: openLabel(person, actions), 500 onOpen: opener(person, actions), 501 body: el('span', { class: 'dir-card-body' }, [ 502 el('span', { class: 'dir-card-name' }, personDisplayName(person)), 503 title ? el('span', { class: 'dir-card-role' }, title) : null, 504 el('span', { class: 'dir-card-meta' }, [ 505 person.is_me ? marker('you', { tone: 'you' }) : null, 506 el('span', { class: 'dir-card-sub' }, personDetail(person, { noPod: 'camp-wide' })), 507 ]), 508 ...contacts.map((contact) => el('span', { class: 'dir-card-sub' }, contact)), 509 ]), 510 }); 511} 512 513/** 514 * What this person is called in leadership: **one title, and the job in it.** 515 * 516 * Camp's ruling, twice. First on 2026-08-05, about the duty suffixes â *"remove 517 * the following labels: showers, camp mayor, shower training, shifts and dues"* 518 * â which migration 0010 took out of the rows. Then again on 2026-08-06, 519 * looking at a deployment that has not run 0010 yet and at people holding three 520 * rows apiece: *"the leaders still have unnecessary tags ... that's just 521 * unnecessary. We never need to show those."* 522 * 523 * So the card prints the *first* title and nothing after it, and it drops a 524 * duty suffix if the row still carries one. A card exists to answer "who do I 525 * ask", and a stack of five tags answers "what does he do all week". 526 * 527 * This is formatting, not structure (guardrail 4): the row is untouched, camp 528 * still owns the words, and 0010 is still what fixes them at the source â this 529 * only stops the screen reading out the parts nobody asked for on a database 530 * that has not been migrated yet. 531 */ 532function leadTitle(roles) { 533 const first = (roles || [])[0]; 534 if (!first) return null; 535 return first.split('·')[0].trim(); 536} 537 538/** Whose page this row opens, said out loud â "Mary G" as a button label tells 539 * a screen-reader user nothing about what pressing it does. */ 540function openLabel(person, actions) { 541 if (!actions.openPerson || !person || !person.id) return null; 542 return `${personDisplayName(person)} â open their page`; 543} 544 545/** 546 * The act behind a row, or nothing when there is no profile screen registered. 547 * 548 * Every profile link says where it came from, so the back link on the other 549 * side can name a screen you were actually on. This one is the Directory, 550 * which is also the fallback â named anyway rather than relying on the default, 551 * because a default that happens to be right is not the same as a caller that 552 * says what it means. 553 */ 554function opener(person, actions) { 555 if (!actions.openPerson || !person || !person.id) return null; 556 return () => actions.openPerson(person.id, 'directory'); 557} 558 559/** 560 * Every disc on this page is the shared `avatar()` on the one ladder 561 * (`--avatar-xsâ¦xl`) â this file used to hand-roll 64, 52 and 46px discs as 562 * `.dir-photo`, three of the "five unrelated numbers" the design's token table 563 * names (§08). The photo path comes off the payload (`person.photo`), never 564 * from a name-to-file rule in here â guardrail 4, and the reason adding the 565 * next camper's portrait is a seed change and not a screen change. An empty 566 * seat gets the dashed disc, deliberate rather than missing. 567 */ 568function dirAvatar(person, size) { 569 const disc = avatar({ 570 name: person ? personDisplayName(person) : '', 571 playa: person ? person.playa_name : null, 572 photo: person ? person.photo : null, 573 size, 574 }); 575 if (!person) disc.classList.add('dir-avatar-empty'); 576 return disc; 577} 578 579/* --- the pods --------------------------------------------------------------- 580 * 581 * **There is no "Pod leads" section any more** (the owner, 2026-08-05: "remove 582 * the pod leader section, it's not necessary as we have the pods"). It was a 583 * grid of leader cards sitting above a grid of pod cards that already name 584 * every one of those leaders and show their faces â the same people, twice, 585 * one scroll apart. A pod's lead is a fact about the pod, and it is on the pod. 586 * 587 * A lead is still findable by name: they are in "Everyone in camp" like anybody 588 * else, and a search matches the pod they lead through the pod card. 589 */ 590 591/** 592 * Searching, a pod appears only when the query names *the pod*. 593 * 594 * It used to appear whenever one of its members matched, which sounds helpful 595 * and is not: the person is already in the section above, and the card that 596 * came with them used to lead on a leadership placeholder sitting between a 597 * camper and the one name they searched for. A pod card is 598 * how you browse a pod; the people section is how you find a person. 599 */ 600function podSection(state, actions) { 601 const query = queryKey(state); 602 const pods = state.data.pods 603 .map((pod) => ({ ...pod, shown: pod.members.filter((p) => matches(p, state)) })) 604 // A pod whose *lead* is the person being searched for is a pod w
604orth 605 // showing: with the lead cards gone, this card is the only place that 606 // answers "which pod does Roo lead". 607 .filter((pod) => !query || pod.name.toLowerCase().includes(query) 608 || leadsOf(pod).some((lead) => matches(lead, state))); 609 610 // A name search that matched no pod says nothing about pods. Browsing, the 611 // heading stays even when the list is empty, because "camp has no pods" is 612 // itself news. 613 if (query && !pods.length) return null; 614 615 return el('section', { class: 'dir-section' }, [ 616 sectionHead([ 617 el('span', {}, 'Pods'), 618 el('span', { class: 'dir-section-note' }, 'who leads it â tap one to see who is in it'), 619 ], { rule: true }), 620 pods.length 621 ? el('div', { class: 'dir-cards' }, pods.map((pod) => podCard(pod, state, actions))) 622 : (query ? emptyState('No pod by that name.') : emptyState('Camp has no pods yet.')), 623 ]); 624} 625 626/** 627 * A pod's leaders, as the card says them. 628 * 629 * Camp has pods with two, and neither is the other's deputy â so two names are 630 * two names, joined with "and", with a photo disc each. A pod with no recorded 631 * lead simply shows the pod and its members; configuration gaps are not people. 632 */ 633const leadsOf = (pod) => (Array.isArray(pod.leads) ? pod.leads : [pod.lead].filter(Boolean)); 634 635function podLeadLine(leads) { 636 const names = leads.map(personDisplayName); 637 const label = leads.length === 1 ? 'Lead' : 'Leads'; 638 return `${label} · ${names.slice(0, -1).join(', ')}${names.length > 1 ? ' and ' : ''}${names[names.length - 1]}`; 639} 640 641function podLeadSub(leads) { 642 // One lead: their playa name, as before. Two: both playa names in the same 643 // order as the names above, so the line reads as the pair. If any of them has 644 // no playa name the pairing stops lining up â a half-list under a full list 645 // invites you to match the wrong one â so it says what it is instead. 646 const playa = leads.map((p) => distinctPlaya(p)).filter(Boolean); 647 if (leads.length === 1) return playa[0] || (leads[0].playa_name ? '' : 'no playa name'); 648 // A pair where one of them goes by their own name â Dust Hazard is Roo and 649 // Mike O â used to fall through to "2 leads", printed directly under a line 650 // already reading "Leads · Roo and Mike O". Counting to two for somebody who 651 // can see two names is worse than saying nothing. 652 if (playa.length !== leads.length) return ''; 653 return `${playa.slice(0, -1).join(', ')} and ${playa[playa.length - 1]}`; 654} 655 656/** 657 * A face, and a way to see it properly. 658 * 659 * **Every disc with a photo behind it is its own control** (the owner, 660 * 2026-08-05 for the pod leads, 2026-08-06 for the rest: "it should be for any 661 * picture so you can see them full screen, especially useful for mobile ... if 662 * you are looking for someone on playa"). That is what makes every card on this 663 * page a *row of two controls* rather than one big `<button>`: a `<button>` may 664 * not contain another, so the surface moved out to a wrapper and the padding 665 * moved into the two children â each of them a tap target of its own rather 666 * than a hit area with a hole in it. 667 * 668 * A disc with no photo is not a control. There is nothing to enlarge â a 669 * monogram at 132px is the same information as at 30px â so it stays the plain 670 * span it has always been, and nothing on this page looks tappable and is not. 671 */ 672function faceControl(person, size, actions) { 673 if (!person) return dirAvatar(null, size); 674 return facePortrait(person, { 675 size, name: personDisplayName(person), onOpen: actions.openPortrait, 676 }); 677} 678 679/** 680 * The lead faces on a pod card, as one shape whatever the count. 681 * 682 * Camp has pods with one lead, with two, and with four, and a row of four 48px 683 * discs beside four names made that card visibly taller than the three next to 684 * it â "the cards really should all have the same height" (the owner, 685 * 2026-08-06). So the faces get a **fixed footprint**: one disc alone, a pair 686 * overlapping the way a pair of faces reads as one group, and three or more in 687 * a cluster of small discs â a triangle at three (the third spans the bottom), 688 * a quadrant at four. The row's height stops being a function of how many 689 * people lead the pod. 690 * 691 * The names are not abbreviated to match. Four leads are four names and camp 692 * says all four; what absorbs the extra line is the card stretching to its 693 * row-mates' height (directory.css § the pods), not a truncation. 694 */ 695const CLUSTER_FROM = 3; 696 697function podFaces(leads, actions, podName) { 698 if (!leads.length) return dirAvatar({ default_name: podName }, 's'); 699 const cluster = leads.length >= CLUSTER_FROM; 700 return el('span', { class: ['dir-photos', cluster && 'dir-photos--cluster'] },
701 leads.map((person) => faceControl(person, cluster ? 'xs' : 's', actions))); 702} 703 704/** Whether this member is one of the pod's leads â by `person_id`, never by 705 * name (guardrail 3). */ 706function isLeadOf(person, leads) { 707 return leads.some((lead) => lead.id === person.id); 708} 709 710/** 711 * The pod's own leads at the top of its list, everybody else in the order the 712 * server sent. 713 * 714 * "The pod leader should be the first to be shown" (the owner, 2026-08-06), and 715 * it is the same question the card above already answers â a pod opened to find 716 * out who to talk to should not make you read four names to find the one with 717 * the marker on it. A stable sort, so the rest of the list does not reshuffle 718 * around them. 719 */ 720function leadsFirst(members, leads) { 721 if (!leads.length) return members; 722 return members.slice().sort((a, b) => Number(isLeadOf(b, leads)) - Number(isLeadOf(a, leads))); 723} 724 725function podCard(pod, state, actions) { 726 const open = state.openPod === pod.id; 727 const members = state.query ? pod.shown : pod.members; 728 const leads = leadsOf(pod); 729 730 const head = faceRow({ 731 tag: 'div', 732 class: ['dir-card', 'dir-pod-row'], 733 face: podFaces(leads, actions, pod.name), 734 label: open ? `Hide ${pod.name}` : `${pod.name} â see who is in it`, 735 onOpen: () => actions.togglePod(pod.id), 736 body: el('span', { class: 'dir-card-body' }, [ 737 el('span', { class: 'dir-pod-name' }, pod.name), 738 el('span', { class: 'dir-card-sub' }, `${count(pod.count, 'person', 'people')} signed up`), 739 leads.length ? el('span', { class: 'dir-card-name' }, podLeadLine(leads)) : null, 740 leads.length && podLeadSub(leads) 741 ? el('span', { class: 'dir-card-sub' }, podLeadSub(leads)) : null, 742 el('span', { class: 'dir-pod-cta' }, open ? 'Hide pod â' : 'See who is in it â'), 743 ]), 744 }); 745 // The disclosure state belongs on the control, and `faceRow` builds it. 746 const toggle = head.querySelector('.ui-facerow-open'); 747 if (toggle) { 748 toggle.id = `dir-pod-${pod.id}`; 749 toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); 750 } 751 752 return el('div', { class: ['dir-pod', open && 'is-open'] }, [ 753 head, 754 open 755 ? el('div', { class: 'dir-pod-open' }, [ 756 el('div', { class: 'dir-pod-open-head' }, [ 757 el('span', { class: 'dir-pod-open-title' }, pod.name), 758 el('button', { 759 type: 'button', class: 'linkish', onclick: () => actions.togglePod(pod.id), 760 }, 'Hide'), 761 ]), 762 members.length 763 ? el('div', { class: 'dir-people' }, 764 leadsFirst(members, leads).map((m) => personCard(m, actions, { 765 isLead: isLeadOf(m, leads), 766 }))) 767 : emptyState('Nobody has joined this pod yet.'), 768 ]) 769 : null, 770 ]); 771} 772 773/* --- everyone ------------------------------------------------------------- */ 774 775/** 776 * A state the design did not have to face: camp is ~180 people, and drawing 777 * every card unasked makes a phone page thousands of pixels long that nobody 778 * scrolls to the end of. The list opens at `FIRST_PAGE` and says how many more 779 * there are; searching always shows every match, because a search is a 780 * question and truncating the answer would be a lie. 781 * 782 * Flagged for design review (04_DESIGN_HANDOFF.md § "If the design is missing 783 * a state") â it is a restrained addition, not a redesign. 784 */ 785const FIRST_PAGE = 40; 786 787/** 788 * Every person who leads a pod, by id. 789 * 790 * The same marker the open pod draws, on the same people, in the list that is 791 * ~180 cards long â because "who do I ask about this pod" is exactly the 792 * question somebody searching a name has. Built from `pods[].leads`, which is 793 * the payload's own answer; nothing here decides who a lead is. 794 */ 795function podLeadIds(data) { 796 const ids = new Set(); 797 for (const pod of data.pods || []) { 798 for (const lead of leadsOf(pod)) if (lead && lead.id) ids.add(lead.id); 799 } 800 return ids; 801} 802 803function everyoneSection(state, actions) { 804 const people = state.data.people 805 .filter((p) => matches(p, state)) 806 // Sorted by the name actually on the card. The payload arrives in the 807 // server's `default_name` order, and for anybody publishing only their 808 // playa name (Rule 8) that is a name this list never shows â leav
808ing 809 // "Ant" filed under Z, which reads as a broken sort rather than as a 810 // deliberate substitution. 811 .slice() 812 .sort((a, b) => { 813 // Once the shared finder answers, keep its relevance order. Published 814 // names that were not in its bounded answer follow in the Directory's 815 // normal alphabetical order. This is the only interactive ordering a 816 // two-or-more-character query ever paints; `results()` holds a loading 817 // state until these ranks exist. 818 const rankA = state.searchRanks?.get(a.id) ?? Infinity; 819 const rankB = state.searchRanks?.get(b.id) ?? Infinity; 820 return rankA - rankB || personDisplayName(a).localeCompare(personDisplayName(b)); 821 }); 822 const capped = !state.query && !state.showAll && people.length > FIRST_PAGE; 823 const shown = capped ? people.slice(0, FIRST_PAGE) : people; 824 // Once, not once per card: this list is forty rows by default and every row 825 // in camp when somebody presses "show all". 826 const leadIds = podLeadIds(state.data); 827 828 return el('section', { class: 'dir-section' }, [ 829 sectionHead([ 830 el('span', {}, state.query ? 'People' : 'Everyone in camp'), 831 el('span', { class: 'dir-section-note' }, count(people.length, 'person', 'people')), 832 ], { rule: true }), 833 people.length 834 ? el('div', { class: 'dir-people' }, 835 shown.map((p) => personCard(p, actions, { isLead: leadIds.has(p.id) }))) 836 : emptyState(state.query 837 ? (state.searchLoading 838 ? 'Searching every name on fileâ¦' 839 : `Nobody in camp answers to â${state.query}â. Try a first name, last name, playa name, or pod.`) 840 : 'Nobody has signed up for anything yet. The directory fills itself in as they do.'), 841 capped 842 ? el('div', { class: 'dir-more' }, [ 843 button(`Show all ${people.length} â or search above`, state.onShowAll), 844 ]) 845 : null, 846 ]); 847} 848 849/** 850 * One person, small. Name and pod â no money and no shift count, for anybody, 851 * ever. Your own row is marked so you can find yourself; your own numbers are 852 * on My Shifts, where they are yours to see. (`weighted_shifts` has left 853 * `/api/directory` â check-me.js sweeps the payload for it â so the guard that 854 * used to draw it while it lasted is gone with the field.) 855 */ 856function personCard(person, actions, { isLead = false } = {}) { 857 // Spans, not paragraphs â see `leaderCard`. This card is the one a name 858 // search lands on, so it is the most important door on the page. The disc is 859 // the ladder's smallest step â ~180 of these on one page is why. 860 // 861 // A row of two controls, like the pod card above it: the face opens the 862 // photo, the rest opens the page. `lead` rides beside `you` â the same small 863 // neutral chip, saying the one thing about this person that changes who you 864 // would go and talk to. 865 return faceRow({ 866 class: ['dir-person', person.is_me && 'is-me'], 867 face: faceControl(person, 'xs', actions), 868 label: openLabel(person, actions), 869 onOpen: opener(person, actions), 870 body: el('span', { class: 'dir-person-body' }, [ 871 // The name on its own line, the chips on the next one with the pod. 872 // They shared a line until 2026-08-06, and a card carrying two of them â 873 // a pod lead who is also you â wrapped to a third line and grew taller 874 // than every card beside it (the owner: "the cards are extended because 875 // it can't fit all the information in ... this is not a good look"). 876 el('span', { class: 'dir-person-name' }, personLine(person)), 877 el('span', { class: 'dir-person-meta' }, [ 878 isLead ? marker('lead') : null, 879 person.is_me ? marker('you', { tone: 'you' }) : null, 880 el('span', { class: 'dir-card-sub' }, person.pod || 'no pod'), 881 ]), 882 ]), 883 }); 884}
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.