PageSourceSearch

https://softlanding-camp.pages.dev/screens/directory.js

js softlanding-camp.pages.dev collected 2026-10-03 05:56:12 UTC 39,933 bytes, 884 lines download raw bytes

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.