PageSourceSearch

https://followamir.com/genealogy/app.ae7ceb0cc793a890.js

js followamir.com collected 2026-09-24 14:43:11 UTC 162,895 bytes, 3,846 lines download raw bytes

1import {
2  BOARD_PAD_Y,
3  NODE_HEIGHT,
4  NODE_WIDTH,
5  ROW_HEIGHT,
6  buildDeterministicGenealogyGraph
7} from "./layout.js";
8
9const root = document.getElementById("genealogy-root");
10
11const GENEALOGY_BUILD_TOKEN = "20260630-genealogy-hover-stable";
12const DATA_URL = `./data/secure-genealogy.json?v=${GENEALOGY_BUILD_TOKEN}`;
13const TEMPLATE_URLS = {
14  locked: "./stitch/password_entry_page/code.html",
15  treeDesktop: "./stitch/dynamic_family_tree_view/code.html",
16  treeMobile: "./stitch/mobile_family_tree_view/code.html",
17  profileDesktop: "./stitch/member_detail_profile/code.html",
18  profileMobile: "./stitch/mobile_member_profile/code.html",
19  timelineDesktop: "./stitch/main_interactive_timeline/code.html",
20  researchDesktop: "./stitch/speculative_data_hub/code.html"
21};
22
23const SPOUSE_GAP = 64;
24const HOUSEHOLD_GAP = 140;
25const COMPONENT_GAP = 180;
26const BOARD_PAD_X = 120;
27const FIT_VIEWPORT_PADDING = 32;
28const STAGE_PADDINGS = {
29  desktop: { top: 32, right: 48, bottom: 40, left: 112, railWidth: 88 },
30  mobile: { top: 24, right: 24, bottom: 24, left: 76, railWidth: 60 }
31};
32const ZOOM_MIN = 0.04;
33const ZOOM_MAX = 2.5;
34const ZOOM_STEP = 0.18;
35
36const state = {
37  encryptedPayload: null,
38  data: null,
39  graph: null,
40  renderedTree: null,
41  templates: {},
42  searchQuery: "",
43  searchDisplay: "",
44  activeFamilyFilter: "",
45  activeView: "tree",
46  selectedPersonId: "",
47  passwordVisible: false,
48  lockedTreeHighlight: null,
49  familySidebarCollapsed: false,
50  treeViewport: {
51    scale: 1,
52    displayPercent: null,
53    scrollLeft: 0,
54    scrollTop: 0,
55    restorePending: false,
56    fitPending: false,
57    autoFocusPending: false,
58    focusPeopleIds: []
59  },
60  hoverBranchPreview: null
61};
62
63function escapeHtml(value) {
64  return String(value || "")
65    .replaceAll("&", "&")
66    .replaceAll("<", "&lt;")
67    .replaceAll(">", "&gt;")
68    .replaceAll("\"", "&quot;")
69    .replaceAll("'", "&#39;");
70}
71
72function escapeRegExp(value) {
73  return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74}
75
76function unique(values) {
77  return [...new Set(values.filter(Boolean))];
78}
79
80function average(values) {
81  if (!values.length) {
82    return null;
83  }
84  return values.reduce((sum, value) => sum + value, 0) / values.length;
85}
86
87function clamp(value, min, max) {
88  return Math.min(max, Math.max(min, value));
89}
90
91function currentStageMetrics() {
92  return window.matchMedia("(max-width: 767px)").matches ? STAGE_PADDINGS.mobile : STAGE_PADDINGS.desktop;
93}
94
95function currentStageDimensions() {
96  const graph = state.renderedTree || state.graph;
97  const metrics = currentStageMetrics();
98  return {
99    ...metrics,
100    width: (graph?.boardWidth || 0) + metrics.left + metrics.right,
101    height: (graph?.boardHeight || 0) + metrics.top + metrics.bottom
102  };
103}
104
105function truncate(value, length = 180) {
106  const text = String(value || "").replace(/\s+/g, " ").trim();
107  return text.length > length ? `${text.slice(0, length - 3).trimEnd()}...` : text;
108}
109
110function normalizeSearchText(value) {
111  return String(value || "")
112    .normalize("NFKD")
113    .replace(/[\u0300-\u036f]/g, "")
114    .replace(/[^a-zA-Z0-9\s]/g, " ")
115    .replace(/\s+/g, " ")
116    .trim()
117    .toLowerCase();
118}
119
120function searchTokens(value) {
121  return normalizeSearchText(value).split(" ").filter(Boolean);
122}
123
124function levenshteinDistance(left, right, maxDistance = 2) {
125  if (left === right) {
126    return 0;
127  }
128  if (Math.abs(left.length - right.length) > maxDistance) {
129    return maxDistance + 1;
130  }
131  const costs = Array.from({ length: right.length + 1 }, (_, index) => index);
132  for (let row = 1; row <= left.length; row += 1) {
133    let previous = row - 1;
134    costs[0] = row;
135    let rowMin = costs[0];
136    for (let col = 1; col <= right.length; col += 1) {
137      const current = costs[col];
138      const substitution = left[row - 1] === right[col - 1] ? previous : previous + 1;
139      costs[col] = Math.min(
140        costs[col] + 1,
141        costs[col - 1] + 1,
142        substitution
143      );
144      previous = current;
145      rowMin = Math.min(rowMin, costs[col]);
146    }
147    if (rowMin > maxDistance) {
148      return maxDistance + 1;
149    }
150  }
151  return costs[right.length];
152}
153
154function subsequenceMatch(query, candidate) {
155  let pointer = 0;
156  for (const character of candidate) {
157    if (character === query[pointer]) {
158      pointer += 1;
159      if (pointer === query.length) {
160        return true;
161      }
162    }
163  }
164  return false;
165}
166
167function pluralize(count, singular, plural = `${singular}s`) {
168  return `${count} ${count === 1 ? singular : plural}`;
169}
170
171function emphasizeInfoText(value) {
172  const text = String(value || "").replace(/\s+/g, " ").trim();
173  if (!text) {
174    return "";
175  }
176
177  const matches = [];
178  const pushMatch = (regex, className, priority = 0) => {
179    regex.lastIndex = 0;
180    for (const result of text.matchAll(regex)) {
181      const matchText = result[0];
182      if (!matchText) {
183        continue;
184      }
185      matches.push({
186        start: result.index,
187        end: result.index + matchText.length,
188        className,
189        priority
190      });
191    }
192  };
193
194  const searchTerms = unique(searchTokens(state.searchDisplay || state.searchQuery))
195    .filter((token) => token.length >= 2);
196  for (const term of searchTerms) {
197    pushMatch(new RegExp(`\\b${escapeRegExp(term)}\\w*`, "gi"), "genealogy-inline-match", 3);
198  }
199
200  pushMatch(/\b\d{4}(?:-\d{2}-\d{2})?\b/g, "genealogy-inline-accent", 2);
201  pushMatch(/\b(?:candidate|confirmed|working|unresolved|living|deceased|unknown)\b/gi, "genealogy-inline-accent", 2);
202  pushMatch(/\b(?:born|birth|died|death|married|marriage|burial|residence|migration|moved)\b/gi, "genealogy-inline-accent", 1);
203  pushMatch(/\b(?:mother|father|parent|parents|child|children|spouse|sibling|brother|sister)\b/gi, "genealogy-inline-accent", 1);
204
205  matches.sort((left, right) =>
206    left.start - right.start
207    || right.priority - left.priority
208    || (right.end - right.start) - (left.end - left.start)
209  );
210
211  const accepted = [];
212  for (const match of matches) {
213    if (accepted.some((item) => !(match.end <= item.start || match.start >= item.end))) {
214      continue;
215    }
216    accepted.push(match);
217  }
218  accepted.sort((left, right) => left.start - right.start);
219
220  let cursor = 0;
221  let output = "";
222  for (const match of accepted) {
223    output += escapeHtml(text.slice(cursor, match.start));
224    output += `<span class="${match.className}">${escapeHtml(text.slice(match.start, match.end))}</span>`;
225    cursor = match.end;
226  }
227  output += escapeHtml(text.slice(cursor));
228  return output;
229}
230
231function renderPersonKeyFacts(person, summary) {
232  const facts = [];
233  const usefulTag = (person.tags || []).find((tag) => {
234    const normalized = normalizeSearchText(tag);
235    return normalized && !["genealogy", "family", "record", "archive"].includes(normalized);
236  });
237  if (person.status && !["confirmed", "working"].includes(String(person.status).toLowerCase())) {
238    facts.push({ label: "Status", value: person.status });
239  } else if (String(person.life?.death?.label || "").toLowerCase() === "living") {
240    facts.push({ label: "Status", value: "Living" });
241  }
242  if (person.life?.birth?.place) {
243    facts.push({ label: "Born", value: person.life.birth.place });
244  } else if (person.life?.death?.place) {
245    facts.push({ label: "Lived", value: person.life.death.place });
246  }
247  if (summary.parents?.length) {
248    facts.push({ label: "Parents", value: pluralize(summary.parents.length, "linked parent") });
249  } else if (summary.children?.length) {
250    facts.push({ label: "Children", value: pluralize(summary.children.length, "child", "children") });
251  }
252  if (usefulTag) {
253    facts.push({ label: "Note", value: usefulTag });
254  }
255
256  return facts.slice(0, 3).map((fact) => `
257    <span class="genealogy-key-fact">
258      <span class="genealogy-key-fact-label">${escapeHtml(fact.label)}</span>
259      <span class="genealogy-key-fact-value">${emphasizeInfoText(fact.value)}</span>
260    </span>
261  `).join("");
262}
263
264function formatLifeSpan(person) {
265  const birth = person?.life?.birth?.label || "unknown";
266  const death = person?.life?.death?.label || "living";
267  return `${birth} - ${death}`;
268}
269
270function getBirthYear(person) {
271  const sortValue = person?.life?.birth?.sortValue;
272  const year = Number.parseInt(String(sortValue || "").slice(0, 4), 10);
273  return Number.isFinite(year) ? year : null;
274}
275
276function personSort(leftPerson, rightPerson) {
277  return (getBirthYear(leftPerson) ?? 9999) - (getBirthYear(rightPerson) ?? 9999)
278    || String(leftPerson?.displayName || "").localeCompare(String(rightPerson?.displayName || ""), "en-GB");
279}
280
281function familyLabel(person) {
282  return person?.family || "Archive";
283}
284
285function relationshipNames(ids, peopleById, fallback) {
286  const names = unique(ids).map((id) => peopleById.get(id)?.displayName).filter(Boolean);
287  return names.length ? names.join(", ") : fallback;
288}
289
290function matchesActiveFilters(person) {
291  if (!matchesSearch(person)) {
292    return false;
293  }
294  if (!state.activeFamilyFilter) {
295    return true;
296  }
297  return familyLabel(person) === state.activeFamilyFilter;
298}
299
300function familyHighlightPayload(family) {
301  const peopleIds = (state.graph?.people || [])
302    .filter((person) => familyLabel(person) === family)
303    .map((person) => person.id);
304  const edgeIds = unique(
305    peopleIds.flatMap((personId) => state.graph.connectedEdgeIdsByPerson.get(personId) || [])
306  );
307  return {
308    key: `family:${family}`,
309    peopleIds,
310    edgeIds
311  };
312}
313
314function branchPreviewPayload(personId) {
315  if (!state.graph?.peopleById.has(personId)) {
316    return { key: `hover:${personId}`, peopleIds: [], edgeIds: [] };
317  }
318  const ids = new Set([personId]);
319  const addAncestors = (id) => {
320    for (const parentId of state.graph.summaryById.get(id)?.parents || []) {
321      if (ids.has(parentId)) {
322        continue;
323      }
324      ids.add(parentId);
325      addAncestors(parentId);
326    }
327  };
328  const addDescendants = (id) => {
329    for (const childId of state.graph.summaryById.get(id)?.children || []) {
330      if (ids.has(childId)) {
331        continue;
332      }
333      ids.add(childId);
334      addDescendants(childId);
335    }
336  };
337  addAncestors(personId);
338  [...ids].forEach(addDescendants);
339  [...ids].forEach((id) => {
340    for (const spouseId of state.graph.summaryById.get(id)?.spouses || []) {
341      ids.add(spouseId);
342    }
343  });
344  const peopleIds = [...ids].filter((id) => state.graph.peopleById.has(id));
345  const edgeIds = unique(
346    peopleIds.flatMap((id) => state.graph.connectedEdgeIdsByPerson.get(id) || [])
347  );
348  return {
349    key: `hover:${personId}`,
350    peopleIds,
351    edgeIds
352  };
353}
354
355function datasetHighlightPayload(dataset, fallbackKey = "") {
356  return {
357    key: fallbackKey,
358    peopleIds: String(dataset.highlightPeople || "").split(",").filter(Boolean),
359    edgeIds: String(dataset.highlightEdges || "").split(",").filter(Boolean)
360  };
361}
362
363function representativeForFamily(family) {
364  const members = (state.graph?.people || [])
365    .filter((person) => familyLabel(person) === family)
366    .sort(personSort);
367  return members.find((person) => resolvedPhotoUrl(person)) || members[0] || null;
368}
369
370function renderHeroFamilyFilters(limit = 3) {
371  return state.graph.familyLegend.slice(0, limit).map((entry) => {
372    const representative = representativeForFamily(entry.family);
373    const isActive = state.activeFamilyFilter === entry.family;
374    return `
375      <button
376        class="genealogy-hero-filter${isActive ? " is-active" : ""}"
377        type="button"
378        data-family-filter="${escapeHtml(entry.family)}"
379        aria-pressed="${isActive ? "true" : "false"}"
380      >
381        <span class="genealogy-hero-filter-photo">
382          ${photoMarkup(representative || { displayName: entry.family }, "w-full h-full object-cover")}
383        </span>
384        <span class="genealogy-hero-filter-label">${escapeHtml(entry.family)}</span>
385      </button>
386    `;
387  }).join("");
388}
389
390function rememberTreeViewport() {
391  const scroller = root.querySelector("[data-tree-scroll]");
392  if (!scroller) {
393    return;
394  }
395  state.treeViewport.scrollLeft = scroller.scrollLeft;
396  state.treeViewport.scrollTop = scroller.scrollTop;
397}
398
399function restoreTreeViewport() {
400  const scroller = root.querySelector("[data-tree-scroll]");
401  if (!scroller) {
402    return;
403  }
404  scroller.scrollLeft = state.treeViewport.scrollLeft || 0;
405  scroller.scrollTop = state.treeViewport.scrollTop || 0;
406}
407
408function focusTreeOnPeople(peopleIds, options = {}) {
409  const treeGraph = state.renderedTree || state.graph;
410  const uniqueIds = unique(peopleIds || []);
411  if (!uniqueIds.length) {
412    return;
413  }
414
415  const scroller = root.querySelector("[data-tree-scroll]");
416  const board = root.querySelector(".genealogy-tree-board");
417  if (!scroller || !board || !treeGraph) {
418    return;
419  }
420
421  const positions = uniqueIds
422    .map((personId) => treeGraph.positions.get(personId))
423    .filter(Boolean);
424  if (!positions.length) {
425    return;
426  }
427
428  const boardLeft = Number.parseFloat(board.style.left || "0") || 0;
429  const boardTop = Number.parseFloat(board.style.top || "0") || 0;
430  const minX = Math.min(...positions.map((position) => position.x));
431  const maxX = Math.max(...positions.map((position) => position.x + NODE_WIDTH));
432  const minY = Math.min(...positions.map((position) => position.y));
433  const maxY = Math.max(...positions.map((position) => position.y + NODE_HEIGHT));
434  const paddingX = Math.max(96, Math.round((maxX - minX) * 0.12));
435  const paddingY = Math.max(88, Math.round((maxY - minY) * 0.14));
436  const targetLeft = Math.max(0, boardLeft + minX - paddingX);
437  const targetTop = Math.max(0, boardTop + minY - paddingY);
438  const targetWidth = maxX - minX + paddingX * 2;
439  const targetHeight = maxY - minY + paddingY * 2;
440
441  if (options.fit !== false) {
442    state.treeViewport.scale = clamp(
443      Math.min(scroller.clientWidth / targetWidth, scroller.clientHeight / targetHeight),
444      options.minScale ?? ZOOM_MIN,
445      1
446    );
447    state.treeViewport.displayPercent = null;
448    applyTreeViewport();
449  }
450
451  const scale = state.treeViewport.scale || 1;
452  const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
453  const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
454  scroller.scrollLeft = clamp(
455    targetLeft * scale - Math.max(0, (scroller.clientWidth - targetWidth * scale) / 2),
456    0,
457    maxScrollLeft
458  );
459  scroller.scrollTop = clamp(
460    targetTop * scale - Math.max(0, (scroller.clientHeight - targetHeight * scale) / 2),
461    0,
462    maxScrollTop
463  );
464}
465
466function formatGenerationYears(ids, peopleById) {
467  const years = ids
468    .map((id) => getBirthYear(peopleById.get(id)))
469    .filter((year) => Number.isFinite(year));
470  if (!years.length) {
471    return "Undated";
472  }
473  const first = Math.min(...years);
474  const last = Math.max(...years);
475  return first === last ? String(first) : `${first}-${last}`;
476}
477
478function photoMarkup(person, className) {
479  const safePhoto = resolvedPhotoUrl(person);
480  if (safePhoto) {
481    return `<img class="${className}" src="${escapeHtml(safePhoto)}" alt="${escapeHtml(person.displayName)} portrait">`;
482  }
483  const initials = String(person.displayName || "?")
484    .split(/\s+/)
485    .filter(Boolean)
486    .slice(0, 2)
487    .map((part) => part[0]?.toUpperCase() || "")
488    .join("");
489  return `<div class="${className} genealogy-photo-placeholder">${escapeHtml(initials || "P")}</div>`;
490}
491
492function resolvedPhotoUrl(person) {
493  const candidate = person?.primaryPhoto && typeof person.primaryPhoto === "object"
494    ? (person.primaryPhoto.previewUrl || person.primaryPhoto.fileUrl || "")
495    : (person?.primaryPhoto || person?.photo || "");
496  return /^(https?:\/\/|\/|\.\/|\.\.\/)/i.test(String(candidate || "")) ? String(candidate) : "";
497}
498
499function attachmentTitle(attachment) {
500  return attachment?.title || attachment?.document || attachment?.type || "Archive record";
501}
502
503function attachmentDateLabel(attachment) {
504  return attachment?.dateLabel || attachment?.date?.label || "Undated";
505}
506
507function attachmentSummary(attachment) {
508  return truncate(attachment?.relevance || attachment?.summary || "Linked family record.", 150);
509}
510
511function attachmentPersonLabel(attachment) {
512  const names = attachment?.personNames || (attachment?.personName ? [attachment.personName] : []);
513  return names.length ? names.join(", ") : "Archive record";
514}
515
516function attachmentEvidenceFiles(attachment) {
517  if (Array.isArray(attachment?.evidenceFiles) && attachment.evidenceFiles.length) {
518    return attachment.evidenceFiles
519      .filter((file) => file?.fileUrl)
520      .map((file, index) => ({
521        title: file.title || `File ${index + 1}`,
522        fileUrl: file.fileUrl,
523        mimeType: file.mimeType || ""
524      }));
525  }
526  if (Array.isArray(attachment?.assetUrls) && attachment.assetUrls.length) {
527    return attachment.assetUrls
528      .filter(Boolean)
529      .map((fileUrl, index) => ({
530        title: `File ${index + 1}`,
531        fileUrl,
532        mimeType: /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(fileUrl) ? "image" : ""
533      }));
534  }
535  return unique([attachment?.fileUrl, attachment?.previewUrl])
536    .filter(Boolean)
537    .map((fileUrl, index) => ({
538      title: index === 0 ? attachmentTitle(attachment) : `File ${index + 1}`,
539      fileUrl,
540      mimeType: /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(fileUrl) ? "image" : ""
541    }));
542}
543
544function isImageEvidenceFile(file) {
545  return /^image\//i.test(file?.mimeType || "") || /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(file?.fileUrl || "");
546}
547
548function attachmentTranscriptText(attachment) {
549  return String(attachment?.transcript || "").trim();
550}
551
552function attachmentHasTranscript(attachment) {
553  return Boolean(attachmentTranscriptText(attachment));
554}
555
556function attachmentTranscriptExcerpt(attachment, length = 260) {
557  return truncate(attachmentTranscriptText(attachment).replace(/\s+/g, " "), length);
558}
559
560function attachmentTranscriptDisclaimer(attachment) {
561  return String(
562    attachment?.transcriptDisclaimer
563    || attachment?.transcriptNotes
564    || "Transcript from the vault. Some words may be interpreted or uncertain where the original document is hard to read."
565  ).trim();
566}
567
568function attachmentIcon(attachment) {
569  const kind = String(attachment?.kind || attachment?.type || "").toLowerCase();
570  if (kind.includes("photo") || kind.includes("image") || kind.includes("portrait")) {
571    return "image";
572  }
573  if (kind.includes("will")) {
574    return "history_edu";
575  }
576  if (kind.includes("certificate")) {
577    return "badge";
578  }
579  if (kind.includes("obituary")) {
580    return "article";
581  }
582  if (kind.includes("transcription") || kind.includes("note")) {
583    return "description";
584  }
585  return "folder_open";
586}
587
588function attachmentPreviewMarkup(attachment, className = "w-full h-full object-cover") {
589  const evidenceFiles = attachmentEvidenceFiles(attachment);
590  const previewUrl = attachment?.previewUrl
591    || evidenceFiles.find((file) => /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(file.fileUrl))?.fileUrl
592    || attachment?.fileUrl
593    || "";
594  if (previewUrl && /\.(jpg|jpeg|png|webp|gif|svg)$/i.test(previewUrl)) {
595    return `
596      <img class="${className}" src="${escapeHtml(previewUrl)}" alt="${escapeHtml(attachmentTitle(attachment))}">
597      ${evidenceFiles.length > 1 ? `<span class="genealogy-evidence-count">${escapeHtml(pluralize(evidenceFiles.length, "file"))}</span>` : ""}
598    `;
599  }
600  return `<div class="${className} genealogy-attachment-placeholder"><span class="material-symbols-outlined">${escapeHtml(attachmentIcon(attachment))}</span></div>`;
601}
602
603function attachmentEvidenceMarkup(attachment, compact = false) {
604  const evidenceFiles = attachmentEvidenceFiles(attachment);
605  if (evidenceFiles.length <= 1) {
606    return "";
607  }
608  return `
609    <div class="genealogy-evidence-files${compact ? " is-compact" : ""}">
610      ${evidenceFiles.map((file, index) => `
611        <a class="genealogy-evidence-file-card" href="${escapeHtml(file.fileUrl)}" target="_blank" rel="noreferrer">
612          <span class="genealogy-evidence-file-media">
613            ${isImageEvidenceFile(file)
614              ? `<img class="genealogy-evidence-file-image" src="${escapeHtml(file.fileUrl)}" alt="${escapeHtml(file.title)}">`
615              : `<span class="material-symbols-outlined">description</span>`}
616          </span>
617          <span class="genealogy-evidence-file-label">${escapeHtml(compact ? `File ${index + 1}` : file.title)}</span>
618        </a>
619      `).join("")}
620    </div>
621  `;
622}
623
624function attachmentActionLinks(attachment, compact = false) {
625  const links = [];
626  const primaryFileUrl = attachment?.fileUrl || attachmentEvidenceFiles(attachment)[0]?.fileUrl || attachment?.previewUrl || "";
627  if (primaryFileUrl) {
628    links.push(`
629      <a class="genealogy-record-link" href="${escapeHtml(primaryFileUrl)}" target="_blank" rel="noreferrer">
630        <span class="material-symbols-outlined">open_in_new</span>
631        ${compact ? "Record" : "Open record"}
632      </a>
633    `);
634  }
635  if (attachment?.transcriptSourceUrl || attachment?.sourceNoteUrls?.[0]) {
636    links.push(`
637      <a class="genealogy-record-link is-secondary" href="${escapeHtml(attachment.transcriptSourceUrl || attachment.sourceNoteUrls[0])}" target="_blank" rel="noreferrer">
638        <span class="material-symbols-outlined">description</span>
639        ${compact ? "Note" : "Open note"}
640      </a>
641    `);
642  }
643  return links.join("");
644}
645
646function attachmentTranscriptMarkup(attachment, { compact = false } = {}) {
647  if (!attachmentHasTranscript(attachment)) {
648    return "";
649  }
650
651  const transcriptSource = attachment?.transcriptSourceTitle || attachment?.sourceNoteTitles?.[0] || "";
652  return `
653    <section class="genealogy-transcript-shell${compact ? " is-compact" : ""}">
654      <p class="genealogy-transcript-eyebrow">Readable transcript</p>
655      <p class="genealogy-transcript-excerpt">${escapeHtml(attachmentTranscriptExcerpt(attachment, compact ? 180 : 280))}</p>
656      <p class="genealogy-transcript-disclaimer">${escapeHtml(attachmentTranscriptDisclaimer(attachment))}</p>
657      <details class="genealogy-transcript-details">
658        <summary>${compact ? "Read transcript" : "Open full transcript"}</summary>
659        ${transcriptSource ? `<p class="genealogy-transcript-source">From ${escapeHtml(transcriptSource)}</p>` : ""}
660        <pre class="genealogy-transcript-text">${escapeHtml(attachmentTranscriptText(attachment))}</pre>
661      </details>
662    </section>
663  `;
664}
665
666function renderAttachmentCard(attachment, { variant = "source", compact = false } = {}) {
667  const noteTitle = (attachment.sourceNoteTitles || [])[0] || "";
668  const actions = attachmentActionLinks(attachment, compact);
669  const evidenceMarkup = attachmentEvidenceMarkup(attachment, compact);
670
671  if (variant === "record") {
672    return `
673      <article class="genealogy-record-card">
674        <div class="genealogy-record-card-preview">${attachmentPreviewMarkup(attachment)}</div>
675        <div class="genealogy-record-card-body">
676          <p class="genealogy-record-card-title">${escapeHtml(attachmentTitle(attachment))}</p>
677          <p class="genealogy-record-card-meta">${escapeHtml(attachmentPersonLabel(attachment))} | ${escapeHtml(attachmentDateLabel(attachment))}</p>
678          <p class="genealogy-record-card-copy">${escapeHtml(attachmentSummary(attachment))}</p>
679          ${attachmentTranscriptMarkup(attachment, { compact: true })}
680          ${evidenceMarkup}
681          ${actions ? `<div class="genealogy-record-actions">${actions}</div>` : ""}
682        </div>
683      </article>
684    `;
685  }
686
687  const isSource = variant === "source";
688  return `
689    <article
690      class="${isSource ? "genealogy-source-card" : "group genealogy-attachment-card"}"
691      style="--family-color:${escapeHtml(attachment.familyColor || "#0041c8")}"
692    >
693      <div class="${isSource ? "genealogy-source-preview" : "genealogy-attachment-card-media"}">${attachmentPreviewMarkup(attachment)}</div>
694      <div class="${isSource ? "genealogy-source-body" : "genealogy-attachment-card-body"}">
695        <div class="${isSource ? "genealogy-source-topline" : "genealogy-attachment-card-topline"}">
696          <span class="${isSource ? "genealogy-family-chip" : "genealogy-attachment-kind"}">
697            ${isSource ? "" : `<span class="material-symbols-outlined">${escapeHtml(attachmentIcon(attachment))}</span>`}
698            ${escapeHtml(isSource ? (attachment.family || "Archive") : (attachment.kind || "record"))}
699          </span>
700          <span class="${isSource ? "genealogy-source-date" : "genealogy-attachment-date"}">${escapeHtml(attachmentDateLabel(attachment))}</span>
701        </div>
702        <h3${isSource ? "" : " class=\"mb-2 text-lg font-bold group-hover:text-primary\""}>${escapeHtml(attachmentTitle(attachment))}</h3>
703        ${isSource ? `<p class="genealogy-source-owner">${escapeHtml(attachmentPersonLabel(attachment))}</p>` : ""}
704        <p class="${isSource ? "genealogy-source-summary" : "text-sm text-on-surface-variant"}">${escapeHtml(attachmentSummary(attachment))}</p>
705        ${noteTitle ? `<p class="genealogy-attachment-note-link">${escapeHtml(noteTitle)}</p>` : ""}
706        ${attachmentTranscriptMarkup(attachment, { compact })}
707        ${evidenceMarkup}
708        ${actions ? `<div class="genealogy-record-actions">${actions}</div>` : ""}
709      </div>
710    </article>
711  `;
712}
713
714function eventTitleText(event) {
715  const baseTitle = event?.title || event?.event || "Family event";
716  const normalized = normalizeSearchText(baseTitle);
717  const subject = event?.subjectLabel || eventParticipantsLabel(event, state.graph?.peopleById || new Map());
718  if (!subject || subject === "Archive note" || normalized.includes(subject.toLowerCase())) {
719    return baseTitle;
720  }
721  if (["birth", "death", "burial", "baptism", "residence", "migration", "marriage", "union"].includes(normalized)) {
722    return `${baseTitle} of ${subject}`;
723  }
724  return baseTitle;
725}
726
727function eventSourceLabel(event) {
728  const refs = event?.sourceRefs || [];
729  return refs.length ? refs[0] : (event?.source || "");
730}
731
732function strongest(order, left, right) {
733  return order.indexOf(left) >= order.indexOf(right) ? left : right;
734}
735
736function hexToRgba(hex, alpha) {
737  const normalized = String(hex || "").trim().replace("#", "");
738  const value = normalized.length === 3
739    ? normalized.split("").map((character) => `${character}${character}`).join("")
740    : normalized;
741  if (!/^[0-9a-fA-F]{6}$/.test(value)) {
742    return `rgba(0, 65, 200, ${alpha})`;
743  }
744  const red = Number.parseInt(value.slice(0, 2), 16);
745  const green = Number.parseInt(value.slice(2, 4), 16);
746  const blue = Number.parseInt(value.slice(4, 6), 16);
747  return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
748}
749
750function eventIcon(eventName) {
751  const label = String(eventName || "").toLowerCase();
752  if (label.includes("birth")) {
753    return "child_care";
754  }
755  if (label.includes("marriage") || label.includes("union")) {
756    return "favorite";
757  }
758  if (label.includes("migration") || label.includes("move")) {
759    return "travel";
760  }
761  if (label.includes("residence")) {
762    return "home_pin";
763  }
764  if (label.includes("death")) {
765    return "local_florist";
766  }
767  if (label.includes("bapt")) {
768    return "water_drop";
769  }
770  if (label.includes("sibling")) {
771    return "diversity_1";
772  }
773  return "history_edu";
774}
775
776function eventDateLabel(event) {
777  return event?.date?.label || "Undated";
778}
779
780function eventSortValue(event) {
781  return String(event?.date?.sortValue || "9999-99-99");
782}
783
784function eventParticipantsLabel(event, peopleById) {
785  if (event?.subjectLabel) {
786    return event.subjectLabel;
787  }
788  const names = (event?.subjectPeople || event?.participants || []).map((id) =>
788 peopleById.get(id)?.displayName).filter(Boolean);
789  return names.length ? names.join(", ") : "Archive note";
790}
791
792function eventSummaryText(event) {
793  return truncate(event?.summary || event?.notes || event?.source || event?.event || "Recorded family event.", 190);
794}
795
796function personRecordCount(person) {
797  return (person?.attachments || []).filter((attachment) => attachment?.fileUrl || attachment?.previewUrl || (attachment?.sourceNoteUrls || []).length).length;
798}
799
800function attachmentDedupeKey(attachment) {
801  const evidenceKey = attachmentEvidenceFiles(attachment).map((file) => file.fileUrl).sort().join("|");
802  const sourceKey = (attachment?.sourceNoteTitles || attachment?.vaultNoteIds || []).join("|");
803  return sourceKey || evidenceKey || attachment?.fileUrl || attachment?.previewUrl || `${attachmentTitle(attachment)}:${attachmentDateLabel(attachment)}`;
804}
805
806function dedupeAttachmentRecords(attachments) {
807  const records = new Map();
808  for (const attachment of attachments) {
809    const key = attachmentDedupeKey(attachment);
810    if (!records.has(key)) {
811      records.set(key, {
812        ...attachment,
813        personNames: unique([attachment.personName]),
814        personIds: unique([attachment.personId])
815      });
816      continue;
817    }
818    const existing = records.get(key);
819    records.set(key, {
820      ...existing,
821      personNames: unique([...(existing.personNames || []), attachment.personName]),
822      personIds: unique([...(existing.personIds || []), attachment.personId]),
823      family: existing.family === attachment.family ? existing.family : "Multiple branches",
824      familyColor: existing.familyColor || attachment.familyColor
825    });
826  }
827  return [...records.values()];
828}
829
830function collectAllAttachments({ uniqueRecords = false } = {}) {
831  const attachments = (state.graph?.people || []).flatMap((person) =>
832    (person.attachments || []).map((attachment) => ({
833      ...attachment,
834      personId: person.id,
835      personName: person.displayName,
836      family: person.family,
837      familyColor: person.familyColor
838    }))
839  );
840  return uniqueRecords ? dedupeAttachmentRecords(attachments) : attachments;
841}
842
843function actualFamilyEntries() {
844  const entries = (state.graph?.familyLegend || []).filter(
845    (entry) => String(entry.family || "").toLowerCase() !== "unresolved"
846  );
847  return entries.length ? entries : (state.graph?.familyLegend || []);
848}
849
850function speculativeFamilyMembers() {
851  return (state.graph?.people || []).filter((person) => person.status === "candidate" || person.status === "unresolved");
852}
853
854function buildSelectedFamilyModel() {
855  const family = state.activeFamilyFilter;
856  if (!family || !state.graph) {
857    return null;
858  }
859
860  const corePeople = state.graph.people
861    .filter((person) => familyLabel(person) === family)
862    .sort((left, right) => (state.graph.positions.get(left.id)?.generation ?? 0) - (state.graph.positions.get(right.id)?.generation ?? 0) || personSort(left, right));
863  if (!corePeople.length) {
864    return null;
865  }
866
867  const coreIds = new Set(corePeople.map((person) => person.id));
868  const relatedIds = unique(
869    corePeople.flatMap((person) => {
870      const summary = state.graph.summaryById.get(person.id) || {};
871      return [
872        person.id,
873        ...(summary.parents || []),
874        ...(summary.children || []),
875        ...(summary.spouses || [])
876      ];
877    })
878  ).filter((personId) => state.graph.peopleById.has(personId));
879
880  const generationLabelByValue = new Map(state.graph.rows.map((row) => [row.generation, row.label]));
881  const membersByGeneration = new Map();
882  for (const personId of relatedIds) {
883    const person = state.graph.peopleById.get(personId);
884    const generation = state.graph.positions.get(personId)?.generation ?? 0;
885    if (!membersByGeneration.has(generation)) {
886      membersByGeneration.set(generation, []);
887    }
888    membersByGeneration.get(generation).push(person);
889  }
890
891  const generations = [...membersByGeneration.entries()]
892    .sort((left, right) => left[0] - right[0])
893    .map(([generation, people]) => ({
894      generation,
895      label: generationLabelByValue.get(generation) || "Undated",
896      people: people.sort((left, right) => {
897        const leftCore = coreIds.has(left.id) ? 0 : 1;
898        const rightCore = coreIds.has(right.id) ? 0 : 1;
899        return leftCore - rightCore || personSort(left, right);
900      })
901    }));
902
903  const eventMap = new Map();
904  for (const person of corePeople) {
905    for (const event of state.graph.eventMap.get(person.id) || []) {
906      eventMap.set(event.id, event);
907    }
908  }
909  const events = [...eventMap.values()]
910    .sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right)))
911    .slice(0, 10);
912  const records = corePeople
913    .flatMap((person) => (person.attachments || []).map((attachment) => ({
914      ...attachment,
915      personId: person.id,
916      personName: person.displayName
917    })))
918    .filter((attachment) => attachment.fileUrl || attachment.previewUrl || (attachment.sourceNoteUrls || []).length)
919    .slice(0, 8);
920
921  return {
922    family,
923    color: corePeople[0]?.familyColor || "#0041c8",
924    count: corePeople.length,
925    coreIds,
926    relatedIds,
927    generations,
928    events,
929    records,
930    representative: representativeForFamily(family) || corePeople[0]
931  };
932}
933
934function buildVisibleTreeGraph() {
935  if (!state.graph) {
936    return null;
937  }
938  if (!state.activeFamilyFilter) {
939    return state.graph;
940  }
941
942  const model = buildSelectedFamilyModel();
943  const visibleIdSet = new Set(model?.relatedIds || []);
944  if (!visibleIdSet.size) {
945    return state.graph;
946  }
947
948  if (state.selectedPersonId) {
949    visibleIdSet.add(state.selectedPersonId);
950  }
951
952  const visiblePeople = state.graph.people.filter((person) => visibleIdSet.has(person.id));
953  if (!visiblePeople.length) {
954    return state.graph;
955  }
956
957  const visiblePositions = new Map();
958  let minX = Infinity;
959  let minY = Infinity;
960  let maxX = -Infinity;
961  let maxY = -Infinity;
962
963  for (const person of visiblePeople) {
964    const position = state.graph.positions.get(person.id);
965    if (!position) {
966      continue;
967    }
968    minX = Math.min(minX, position.x);
969    minY = Math.min(minY, position.y);
970    maxX = Math.max(maxX, position.x + NODE_WIDTH);
971    maxY = Math.max(maxY, position.y + NODE_HEIGHT);
972  }
973
974  if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
975    return state.graph;
976  }
977
978  const offsetX = BOARD_PAD_X - minX;
979  const offsetY = BOARD_PAD_Y - minY;
980  for (const person of visiblePeople) {
981    const position = state.graph.positions.get(person.id);
982    if (!position) {
983      continue;
984    }
985    visiblePositions.set(person.id, {
986      ...position,
987      x: position.x + offsetX,
988      y: position.y + offsetY
989    });
990  }
991
992  const visibleEdges = state.graph.displayEdges.filter((edge) => {
993    const people = edge.people || [];
994    if (!people.length) {
995      return false;
996    }
997    return edge.type === "possible_link"
998      ? people.some((personId) => visibleIdSet.has(personId))
999      : people.every((personId) => visibleIdSet.has(personId));
1000  });
1001
1002  const visibleUnionIds = new Set(visibleEdges.map((edge) => edge.unionId).filter(Boolean));
1003  const visibleUnionAnchors = new Map(
1004    [...(state.graph.unionAnchors || new Map()).entries()]
1005      .filter(([unionId]) => visibleUnionIds.has(unionId))
1006      .map(([unionId, anchor]) => [
1007        unionId,
1008        {
1009          ...anchor,
1010          x: anchor.x + offsetX,
1011          y: anchor.y + offsetY
1012        }
1013      ])
1014  );
1015
1016  const visibleRows = [...new Set(visiblePeople.map((person) => visiblePositions.get(person.id)?.generation ?? 0))]
1017    .sort((left, right) => left - right)
1018    .map((generation) => {
1019      const ids = visiblePeople
1020        .filter((person) => (visiblePositions.get(person.id)?.generation ?? 0) === generation)
1021        .map((person) => person.id)
1022        .sort((leftId, rightId) => (visiblePositions.get(leftId)?.x ?? 0) - (visiblePositions.get(rightId)?.x ?? 0));
1023      const top = Math.min(...ids.map((id) => visiblePositions.get(id)?.y ?? 0), BOARD_PAD_Y);
1024      return {
1025        generation,
1026        ids,
1027        label: formatGenerationYears(ids, state.graph.peopleById),
1028        top
1029      };
1030    });
1031
1032  return {
1033    ...state.graph,
1034    people: visiblePeople,
1035    positions: visiblePositions,
1036    rows: visibleRows,
1037    displayEdges: visibleEdges,
1038    unionAnchors: visibleUnionAnchors,
1039    boardWidth: Math.max(Math.round(window.innerWidth * 0.72), Math.round(maxX - minX + BOARD_PAD_X * 2)),
1040    boardHeight: Math.max(Math.round(window.innerHeight * 0.7), Math.round(maxY - minY + BOARD_PAD_Y * 2))
1041  };
1042}
1043
1044function renderSelectedFamilyPanel(model, mobile = false) {
1045  if (!model) {
1046    return "";
1047  }
1048
1049  const isCollapsed = state.familySidebarCollapsed;
1050  const eventMarkup = model.events.length
1051    ? model.events.map((event) => `
1052        <li class="genealogy-family-timeline-item">
1053          <span class="genealogy-family-timeline-date">${escapeHtml(eventDateLabel(event))}</span>
1054          <div>
1055            <p class="genealogy-family-timeline-title">${escapeHtml(eventTitleText(event))}</p>
1056            <p class="genealogy-family-timeline-copy">${escapeHtml(truncate(eventSummaryText(event), mobile ? 108 : 140))}</p>
1057            <p class="genealogy-family-timeline-meta">${escapeHtml(eventParticipantsLabel(event, state.graph.peopleById))}${event.placeLabel ? ` • ${escapeHtml(event.placeLabel)}` : ""}</p>
1058          </div>
1059        </li>
1060      `).join("")
1061    : `<li class="genealogy-family-timeline-empty">No timeline events are linked to this family yet.</li>`;
1062  const recordMarkup = model.records.length
1063    ? model.records.map((attachment) => renderAttachmentCard(attachment, { variant: "record", compact: true })).join("")
1064    : `<p class="genealogy-family-timeline-empty">No linked record previews are available for this family yet.</p>`;
1065
1066  if (isCollapsed) {
1067    return `
1068      <aside class="genealogy-family-sidebar genealogy-family-sidebar-collapsed${mobile ? " genealogy-family-sidebar-mobile" : ""}">
1069        <button class="genealogy-family-sidebar-toggle" type="button" data-action="toggle-family-sidebar" aria-expanded="false">
1070          <span class="material-symbols-outlined">right_panel_open</span>
1071          <span>${escapeHtml(model.family)} focus</span>
1072        </button>
1073      </aside>
1074    `;
1075  }
1076
1077  const generationsMarkup = model.generations.map((group) => `
1078    <section class="genealogy-family-mini-generation">
1079      <div class="genealogy-family-mini-generation-label">
1080        <span>Gen ${group.generation + 1}</span>
1081        <span>${escapeHtml(group.label)}</span>
1082      </div>
1083      <div class="genealogy-family-mini-generation-grid">
1084        ${group.people.map((person) => {
1085          const summary = state.graph.summaryById.get(person.id) || { parents: [], children: [], spouses: [] };
1086          const isCore = model.coreIds.has(person.id);
1087          return `
1088            <article class="genealogy-family-mini-card${isCore ? " is-core" : " is-related"}" style="--family-color:${escapeHtml(model.color)}; --family-color-soft:${escapeHtml(hexToRgba(model.color, 0.12))};">
1089              <div class="genealogy-family-mini-card-top">
1090                <div class="genealogy-family-mini-photo">${photoMarkup(person, "w-full h-full object-cover")}</div>
1091                <button class="genealogy-open-button" type="button" data-person-id="${escapeHtml(person.id)}" data-open-profile="true" aria-label="Open profile for ${escapeHtml(person.displayName)}">
1092                  <span class="material-symbols-outlined text-primary">open_in_new</span>
1093                </button>
1094              </div>
1095              <h4>${escapeHtml(person.displayName)}</h4>
1096              <p class="genealogy-family-mini-life">${escapeHtml(formatLifeSpan(person))}</p>
1097              <dl class="genealogy-family-mini-links">
1098                <div>
1099                  <dt>Parents</dt>
1100                  <dd>${escapeHtml(relationshipNames(summary.parents, state.graph.peopleById, "Not linked"))}</dd>
1101                </div>
1102                <div>
1103                  <dt>Children</dt>
1104                  <dd>${escapeHtml(relationshipNames(summary.children, state.graph.peopleById, "None linked"))}</dd>
1105                </div>
1106              </dl>
1107            </article>
1108          `;
1109        }).join("")}
1110      </div>
1111    </section>
1112  `).join("");
1113
1114  return `
1115    <aside class="genealogy-family-sidebar${mobile ? " genealogy-family-sidebar-mobile" : ""}">
1116      <div class="genealogy-family-sidebar-header">
1117        <div class="genealogy-family-sidebar-eyebrow" style="--family-color:${escapeHtml(model.color)}">
1118          <span class="genealogy-legend-dot"></span>
1119          Focused Family
1120        </div>
1121        <button class="genealogy-family-sidebar-toggle" type="button" data-action="toggle-family-sidebar" aria-expanded="true">
1122          <span class="material-symbols-outlined">${mobile ? "expand_less" : "right_panel_close"}</span>
1123        </button>
1124      </div>
1125      <div class="genealogy-family-sidebar-hero">
1126      <div class="genealogy-family-sidebar-photo">${photoMarkup(model.representative, "w-full h-full object-cover")}</div>
1127      <div>
1128        <h3>${escapeHtml(model.family)}</h3>
1129        <p>${escapeHtml(pluralize(model.count, "person"))} in the current family focus</p>
1130      </div>
1131    </div>
1132      <div class="genealogy-family-sidebar-body">
1133        <section class="genealogy-family-sidebar-section">
1134          <div class="genealogy-family-sidebar-section-head">
1135            <h4>Focused Family Tree</h4>
1136            <p>Parents and children for the selected branch.</p>
1137          </div>
1138          ${generationsMarkup}
1139        </section>
1140        <section class="genealogy-family-sidebar-section">
1141          <div class="genealogy-family-sidebar-section-head">
1142            <h4>Family Timeline</h4>
1143            <p>Named events, places, and related people for this branch.</p>
1144          </div>
1145          <ol class="genealogy-family-timeline">${eventMarkup}</ol>
1146        </section>
1147        <section class="genealogy-family-sidebar-section">
1148          <div class="genealogy-family-sidebar-section-head">
1149            <h4>Key Records</h4>
1150            <p>Documents, images, and notes linked to this branch.</p>
1151          </div>
1152          <div class="genealogy-record-grid">${recordMarkup}</div>
1153        </section>
1154      </div>
1155    </aside>
1156  `;
1157}
1158
1159function renderTreeWorkspace(mobile = false) {
1160  const familyModel = buildSelectedFamilyModel();
1161  const sidebarMarkup = familyModel ? renderSelectedFamilyPanel(familyModel, mobile) : "";
1162  return `
1163    <div class="genealogy-tree-workspace${familyModel && !state.familySidebarCollapsed && !mobile ? " has-sidebar" : ""}${mobile ? " is-mobile" : ""}">
1164      <div class="genealogy-tree-main">${renderEnhancedTreeBoard()}</div>
1165      ${sidebarMarkup}
1166    </div>
1167  `;
1168}
1169
1170function renderPrimaryNav() {
1171  const navItems = [
1172    { view: "tree", label: "Family Tree" },
1173    { view: "timeline", label: "Timeline" },
1174    { view: "people", label: "People" },
1175    { view: "sources", label: "Sources" }
1176  ];
1177  return navItems.map((item) => {
1178    const isActive = state.activeView === item.view;
1179    const classes = isActive
1180      ? "rounded-full bg-primary text-white shadow-sm"
1181      : "rounded-full bg-white/88 text-on-surface hover:bg-primary/8";
1182    return `
1183      <a
1184        href="#"
1185        data-view="${escapeHtml(item.view)}"
1186        class="inline-flex items-center justify-center px-4 py-2 text-sm font-semibold tracking-[0.02em] transition-colors ${classes}"
1187      >${escapeHtml(item.label)}</a>
1188    `;
1189  }).join("");
1190}
1191
1192function renderPrimarySearchBox(placeholder = "Search lineage...") {
1193  return `
1194    <div class="genealogy-search-shell">
1195      <label class="flex items-center gap-3 rounded-[1.35rem] border border-outline-variant/25 bg-white/96 px-4 py-3 shadow-sm">
1196        <span class="material-symbols-outlined text-outline">search</span>
1197        <input
1198          type="search"
1199          data-role="search"
1200          placeholder="${escapeHtml(placeholder)}"
1201          class="w-full border-0 bg-transparent p-0 text-sm text-on-surface outline-none ring-0 placeholder:text-outline focus:ring-0"
1202        />
1203      </label>
1204    </div>
1205  `;
1206}
1207
1208function renderArchiveSnapshotCard() {
1209  const familyCount = actualFamilyEntries().length;
1210  const speculativeCount = speculativeFamilyMembers().length;
1211  const attachmentCount = collectAllAttachments().length;
1212  return `
1213    <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]">
1214      <p class="mb-4 text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">Archive Snapshot</p>
1215      <div class="grid grid-cols-3 gap-3">
1216        <div class="rounded-2xl bg-surface-container-low p-4">
1217          <p class="text-[10px] font-bold uppercase tracking-[0.16em] text-outline">Families</p>
1218          <p class="mt-2 text-3xl font-headline font-black text-on-surface">${escapeHtml(String(familyCount))}</p>
1219        </div>
1220        <div class="rounded-2xl bg-surface-container-low p-4">
1221          <p class="text-[10px] font-bold uppercase tracking-[0.16em] text-outline">Speculative Members</p>
1222          <p class="mt-2 text-3xl font-headline font-black text-on-surface">${escapeHtml(String(speculativeCount))}</p>
1223        </div>
1224        <div class="rounded-2xl bg-surface-container-low p-4">
1225          <p class="text-[10px] font-bold uppercase tracking-[0.16em] text-outline">Records</p>
1226          <p class="mt-2 text-3xl font-headline font-black text-on-surface">${escapeHtml(String(attachmentCount))}</p>
1227        </div>
1228      </div>
1229    </section>
1230  `;
1231}
1232
1233function renderPrimaryShell({ view, eyebrow, titleHtml, summaryText, contentMarkup, heroAsideMarkup = "", includeFilters = true }) {
1234  const filtersMarkup = state.graph && includeFilters
1235    ? `<div class="mt-6 genealogy-hero-filters">${renderHeroFamilyFilters(view === "tree" ? 6 : 4)}</div>`
1236    : "";
1237  root.innerHTML = `
1238    <div class="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(182,196,255,0.2),_rgba(249,249,249,0)_38%),linear-gradient(180deg,_#fbfbfd_0%,_#f2f4f8_100%)]">
1239      <header class="sticky top-0 z-40 border-b border-outline-variant/20 bg-white/86 backdrop-blur">
1240        <div class="mx-auto flex max-w-[1800px] flex-col gap-4 px-4 py-4 md:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8">
1241          <div class="flex items-center gap-3">
1242            <div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-sm">
1243              <span class="material-symbols-outlined">family_history</span>
1244            </div>
1245            <div>
1246              <p class="text-[0.7rem] font-black uppercase tracking-[0.28em] text-primary/80">Our Family Tree</p>
1247              <p class="text-sm text-outline">A growing family archive mapped by Amir Mansaray</p>
1248            </div>
1249          </div>
1250          <nav class="flex flex-wrap items-center gap-2">
1251            ${renderPrimaryNav()}
1252          </nav>
1253        </div>
1254      </header>
1255      <main class="mx-auto flex max-w-[1800px] flex-col gap-6 px-4 py-6 md:px-6 lg:px-8 lg:py-8">
1256        <section class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,360px)] xl:items-end">
1257          <div class="rounded-[2rem] border border-outline-variant/20 bg-white/95 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.32)] md:p-8">
1258            ${eyebrow ? `<p class="text-[10px] font-black uppercase tracking-[0.3em] text-primary/80">${escapeHtml(eyebrow)}</p>` : ""}
1259            <h1 class="mt-3 font-headline text-4xl font-black leading-[0.95] text-on-surface md:text-5xl lg:text-6xl">${titleHtml}</h1>
1260            <p class="mt-4 max-w-3xl text-sm leading-6 text-on-surface-variant md:text-base" data-search-summary="${escapeHtml(view)}">${escapeHtml(summaryText)}</p>
1261            ${filtersMarkup}
1262          </div>
1263          <div class="space-y-4">
1264            ${renderPrimarySearchBox()}
1265            ${heroAsideMarkup}
1266          </div>
1267        </section>
1268        ${contentMarkup}
1269      </main>
1270    </div>
1271  `;
1272}
1273
1274function dedupeRelationships(relationships) {
1275  const confidenceOrder = ["low", "medium", "high"];
1276  const statusOrder = ["unresolved", "candidate", "working", "confirmed"];
1277  const map = new Map();
1278  for (const relation of relationships) {
1279    const key = relation.type === "parent"
1280      ? `${relation.type}:${relation.from}:${relation.to}`
1281      : `${relation.type}:${[relation.from, relation.to].sort().join(":")}`;
1282    if (!map.has(key)) {
1283      map.set(key, { ...relation, id: key });
1284      continue;
1285    }
1286    const current = map.get(key);
1287    map.set(key, {
1288      ...current,
1289      ...relation,
1290      id: key,
1291      role: current.type === "parent" && ["father", "mother"].includes(relation.role) ? relation.role : current.role || relation.role,
1292      confidence: strongest(confidenceOrder, current.confidence, relation.confidence),
1293      status: strongest(statusOrder, current.status, relation.status),
1294      notes: unique([...(current.notes || []), ...(relation.notes || [])]),
1295      evidence: unique([...(current.evidence || []), ...(relation.evidence || [])])
1296    });
1297  }
1298  return [...map.values()];
1299}
1300
1301function ownerMatchesPerson(ownerLabel, person) {
1302  const ownerKey = ownerLabel.toLowerCase();
1303  return String(person?.displayName || "").toLowerCase().startsWith(ownerKey);
1304}
1305
1306function buildConnectedComponents(people, relationships) {
1307  const adjacency = new Map(people.map((person) => [person.id, new Set()]));
1308  for (const relation of relationships) {
1309    if (relation.type === "possible_link") {
1310      continue;
1311    }
1312    if (!adjacency.has(relation.from) || !adjacency.has(relation.to)) {
1313      continue;
1314    }
1315    adjacency.get(relation.from).add(relation.to);
1316    adjacency.get(relation.to).add(relation.from);
1317  }
1318
1319  const pending = new Set(people.map((person) => person.id));
1320  const components = [];
1321
1322  while (pending.size) {
1323    const start = pending.values().next().value;
1324    pending.delete(start);
1325    const stack = [start];
1326    const component = [];
1327    while (stack.length) {
1328      const current = stack.pop();
1329      component.push(current);
1330      for (const next of adjacency.get(current) || []) {
1331        if (!pending.has(next)) {
1332          continue;
1333        }
1334        pending.delete(next);
1335        stack.push(next);
1336      }
1337    }
1338    components.push(component);
1339  }
1340
1341  return components;
1342}
1343
1344function computeGenerationMap(people, relationships, components) {
1345  const years = people.map(getBirthYear).filter((year) => Number.isFinite(year));
1346  const baseYear = years.length ? Math.min(...years) : 1900;
1347  const yearBuckets = new Map(
1348    people.map((person) => {
1349      const birthYear = getBirthYear(person);
1350      return [person.id, birthYear == null ? null : Math.max(0, Math.floor((birthYear - baseYear) / 26))];
1351    })
1352  );
1353  const generations = new Map(people.map((person) => [person.id, null]));
1354  const parentRelationships = relationships.filter((relation) => relation.type === "parent");
1355  const linkedGenerationRelationships = relationships.filter((relation) => relation.type === "spouse" || relation.type === "sibling");
1356  const childIds = new Set(parentRelationships.map((relation) => relation.to));
1357  const seeds = people.filter((person) => !childIds.has(person.id)).sort(personSort);
1358  if (!seeds.length && people.length) {
1359    seeds.push([...people].sort(personSort)[0]);
1360  }
1361
1362  for (const person of seeds) {
1363    generations.set(person.id, yearBuckets.get(person.id) ?? 0);
1364  }
1365
1366  for (let step = 0; step < 24; step += 1) {
1367    let changed = false;
1368
1369    for (const relation of parentRelationships) {
1370      const parentGeneration = generations.get(relation.from);
1371      const childGeneration = generations.get(relation.to);
1372
1373      if (parentGeneration != null) {
1374        const nextChildGeneration = Math.max(parentGeneration + 1, childGeneration ?? -Infinity, yearBuckets.get(relation.to) ?? -Infinity);
1375        if (childGeneration == null || nextChildGeneration !== childGeneration) {
1376          generations.set(relation.to, nextChildGeneration);
1377          changed = true;
1378        }
1379      }
1380
1381      if (childGeneration != null && parentGeneration == null) {
1382        generations.set(relation.from, Math.max(0, childGeneration - 1));
1383        changed = true;
1384      }
1385    }
1386
1387    for (const relation of linkedGenerationRelationships) {
1388      const leftGeneration = generations.get(relation.from);
1389      const rightGeneration = generations.get(relation.to);
1390      if (leftGeneration != null && rightGeneration == null) {
1391        generations.set(relation.to, leftGeneration);
1392        changed = true;
1393      } else if (rightGeneration != null && leftGeneration == null) {
1394        generations.set(relation.from, rightGeneration);
1395        changed = true;
1396      }
1397    }
1398
1399    for (const person of people) {
1400      if (generations.get(person.id) == null && yearBuckets.get(person.id) != null) {
1401        generations.set(person.id, yearBuckets.get(person.id));
1402        changed = true;
1403      }
1404    }
1405
1406    if (!changed) {
1407      break;
1408    }
1409  }
1410
1411  for (const component of components) {
1412    const knownGenerations = component.map((id) => generations.get(id)).filter((value) => value != null);
1413    const fallbackGeneration = knownGenerations.length ? Math.round(average(knownGenerations)) : 0;
1414    for (const personId of component) {
1415      if (generations.get(personId) == null) {
1416        generations.set(personId, fallbackGeneration);
1417      }
1418    }
1419  }
1420
1421  for (let step = 0; step < 12; step += 1) {
1422    let changed = false;
1423    for (const relation of parentRelationships) {
1424      const parentGeneration = generations.get(relation.from);
1425      const childGeneration = generations.get(relation.to);
1426      if (parentGeneration != null && childGeneration != null && childGeneration <= parentGeneration) {
1427        generations.set(relation.to, parentGeneration + 1);
1428        changed = true;
1429      }
1430    }
1431    if (!changed) {
1432      break;
1433    }
1434  }
1435
1436  const orderedValues = [...new Set([...generations.values()].sort((left, right) => left - right))];
1437  const compressed = new Map(orderedValues.map((value, index) => [value, index]));
1438  for (const person of people) {
1439    generations.set(person.id, compressed.get(generations.get(person.id)) ?? 0);
1440  }
1441
1442  return generations;
1443}
1444
1445function buildHouseholds(people, peopleById, summaryById, generations) {
1446  const consumed = new Set();
1447  const households = [];
1448  const householdByPersonId = new Map();
1449  const orderedPeople = [...people].sort((left, right) => (generations.get(left.id) ?? 0) - (generations.get(right.id) ?? 0) || personSort(left, right));
1450
1451  for (const person of orderedPeople) {
1452    if (consumed.has(person.id)) {
1453      continue;
1454    }
1455    const summary = summaryById.get(person.id);
1456    const spouseId = (summary?.spouses || []).find((candidateId) => {
1457      if (consumed.has(candidateId)) {
1458        return false;
1459      }
1460      return Math.abs((generations.get(candidateId) ?? 0) - (generations.get(person.id) ?? 0)) <= 1;
1461    });
1462    const members = spouseId ? [person.id, spouseId] : [person.id];
1463    members.sort((left, right) => personSort(peopleById.get(left), peopleById.get(right)));
1464    const householdId = `household:${members.join(":")}`;
1465    households.push({
1466      id: householdId,
1467      members,
1468      generation: Math.min(...members.map((memberId) => generations.get(memberId) ?? 0)),
1469      width: members.length * NODE_WIDTH + Math.max(0, members.length - 1) * SPOUSE_GAP
1470    });
1471    for (const memberId of members) {
1472      householdByPersonId.set(memberId, householdId);
1473      consumed.add(memberId);
1474    }
1475  }
1476
1477  const householdById = new Map(households.map((household) => [household.id, household]));
1478  for (const household of households) {
1479    household.parents = unique(
1480      household.members.flatMap((memberId) =>
1481        (summaryById.get(memberId)?.parents || [])
1482          .map((parentId) => householdByPersonId.get(parentId))
1483          .filter((householdId) => householdId && householdId !== household.id)
1484      )
1485    );
1486    household.children = unique(
1487      household.members.flatMap((memberId) =>
1488        (summaryById.get(memberId)?.children || [])
1489          .map((childId) => householdByPersonId.get(childId))
1490          .filter((householdId) => householdId && householdId !== household.id)
1491      )
1492    );
1493  }
1494
1495  return { households, householdById, householdByPersonId };
1496}
1497
1498function buildGraph(data) {
1499  return buildDeterministicGenealogyGraph(data, {
1500    viewportWidth: window.innerWidth || 1600,
1501    viewportHeight: window.innerHeight || 900
1502  });
1503}
1504
1505function base64ToBytes(value) {
1506  const binary = window.atob(value);
1507  return Uint8Array.from(binary, (character) => character.charCodeAt(0));
1508}
1509
1510async function decryptPayload(payload, password) {
1511  const encoder = new TextEncoder();
1512  const keyMaterial = await window.crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]);
1513  const key = await window.crypto.subtle.deriveKey(
1514    { name: "PBKDF2", salt: base64ToBytes(payload.salt), iterations: payload.iterations, hash: payload.digest },
1515    keyMaterial,
1516    { name: "AES-GCM", length: 256 },
1517    false,
1518    ["decrypt"]
1519  );
1520  const encryptedBytes = base64ToBytes(payload.ciphertext);
1521  const combined = new Uint8Array(encryptedBytes.length + 16);
1522  combined.set(encryptedBytes);
1523  combined.set(base64ToBytes(payload.authTag), encryptedBytes.length);
1524  const decrypted = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: base64ToBytes(payload.iv) }, key, combined);
1525  return JSON.parse(new TextDecoder().decode(decrypted));
1526}
1527
1528async function loadTemplates() {
1529  const entries = await Promise.all(
1530    Object.entries(TEMPLATE_URLS).map(async ([key, url]) => {
1531      const response = await fetch(url, { cache: "no-store" });
1532      const html = await response.text();
1533      const documentNode = new DOMParser().parseFromString(html, "text/html");
1534      return [key, documentNode.body.innerHTML];
1535    })
1536  );
1537  state.templates = Object.fromEntries(entries);
1538}
1539
1540function markNav(rootNode, activeView) {
1541  rootNode.querySelectorAll("a, button").forEach((element) => {
1542    const text = element.textContent.replace(/\s+/g, " ").trim();
1543    if (text === "Timeline" || text === "History") {
1544      element.dataset.view = "timeline";
1545    } else if (text === "Family Tree" || text === "Tree" || text === "Lineage") {
1546      element.dataset.view = "tree";
1547    } else if (text === "People" || text === "Profiles") {
1548      element.dataset.view = "people";
1549    } else if (text === "Sources" || text === "Records") {
1550      element.dataset.view = "sources";
1551    } else if (text === "Profile" || text === "account_circle") {
1552      element.dataset.view = "profile";
1553    }
1554    if (element.dataset.view === activeView) {
1555      element.setAttribute("aria-current", "page");
1556    }
1557  });
1558}
1559
1560function selectedPerson() {
1561  return state.graph?.peopleById.get(state.selectedPersonId) || state.graph?.people[0] || null;
1562}
1563
1564function personMatchesSearch(person, rawQuery = state.searchQuery) {
1565  const query = normalizeSearchText(rawQuery);
1566  if (!query) {
1567    return true;
1568  }
1569  const haystack = normalizeSearchText([
1570    person.displayName,
1571    person.family,
1572    ...(person.tags || []),
1573    person.life?.birth?.place || "",
1574    person.life?.death?.place || "",
1575    ...(person.sources || []),
1576    ...(person.attachments || []).flatMap((attachment) => [
1577      attachmentTitle(attachment),
1578      attachment?.summary || "",
1579      attachment?.relevance || "",
1580      ...(attachment?.sourceNoteTitles || [])
1581    ])
1582  ].join(" "));
1583  if (haystack.includes(query)) {
1584    return true;
1585  }
1586
1587  const candidateTokens = unique([
1588    ...searchTokens(person.displayName),
1589    ...searchTokens(person.family),
1590    ...(person.tags || []).flatMap((tag) => searchTokens(tag)),
1591    ...searchTokens(person.life?.birth?.place || ""),
1592    ...searchTokens(person.life?.death?.place || ""),
1593    ...(person.sources || []).flatMap((source) => searchTokens(source)),
1594    ...(person.attachments || []).flatMap((attachment) => searchTokens([
1595      attachmentTitle(attachment),
1596      attachment?.summary || "",
1597      attachment?.relevance || ""
1598    ].join(" ")))
1599  ]);
1600
1601  return searchTokens(query).every((queryToken) =>
1602    candidateTokens.some((candidateToken) =>
1603      candidateToken === queryToken
1604      || candidateToken.startsWith(queryToken)
1605      || candidateToken.includes(queryToken)
1606    )
1607  );
1608}
1609
1610function matchesSearch(person) {
1611  return personMatchesSearch(person, state.searchQuery);
1612}
1613
1614function scoreSearchToken(queryToken, candidateToken) {
1615  if (!queryToken || !candidateToken) {
1616    return 0;
1617  }
1618  if (candidateToken === queryToken) {
1619    return 140;
1620  }
1621  if (candidateToken.startsWith(queryToken)) {
1622    return 112;
1623  }
1624  if (candidateToken.includes(queryToken)) {
1625    return 88;
1626  }
1627  if (queryToken.length >= 3 && subsequenceMatch(queryToken, candidateToken)) {
1628    return 44;
1629  }
1630  if (queryToken.length >= 4) {
1631    const distance = levenshteinDistance(queryToken, candidateToken, queryToken.length >= 7 ? 2 : 1);
1632    if (distance <= 1) {
1633      return 58;
1634    }
1635    if (distance <= 2) {
1636      return 34;
1637    }
1638  }
1639  return 0;
1640}
1641
1642function scorePersonForQuery(person, rawQuery) {
1643  const query = normalizeSearchText(rawQuery);
1644  if (!query) {
1645    return 0;
1646  }
1647
1648  const name = normalizeSearchText(person.displayName);
1649  const family = normalizeSearchText(person.family);
1650  const tokens = unique([
1651    ...searchTokens(person.displayName),
1652    ...searchTokens(person.family),
1653    ...(person.tags || []).flatMap((tag) => searchTokens(tag)),
1654    ...searchTokens(person.life?.birth?.place || ""),
1655    ...searchTokens(person.life?.death?.place || "")
1656  ]);
1657  const queryParts = searchTokens(query);
1658  let score = 0;
1659
1660  if (name === query) {
1661    score += 420;
1662  } else if (name.startsWith(query)) {
1663    score += 320;
1664  } else if (name.includes(query)) {
1665    score += 250;
1666  }
1667
1668  if (family && (family === query || family.startsWith(query))) {
1669    score += 210;
1670  }
1671
1672  for (const queryToken of queryParts) {
1673    const tokenScore = Math.max(0, ...tokens.map((candidateToken) => scoreSearchToken(queryToken, candidateToken)));
1674    if (tokenScore === 0) {
1675      return 0;
1676    }
1677    score += tokenScore;
1678  }
1679
1680  return score;
1681}
1682
1683function getSearchMatches(query = state.searchQuery) {
1684  const normalizedQuery = normalizeSearchText(query);
1685  if (!normalizedQuery || !state.graph) {
1686    return [];
1687  }
1688
1689  return state.graph.people
1690    .map((person) => ({ person, score: scorePersonForQuery(person, normalizedQuery) }))
1691    .filter((entry) => entry.score > 0)
1692    .sort((left, right) =>
1693      right.score - left.score
1694      || personSort(left.person, right.person)
1695    );
1696}
1697
1698function treeSummaryText(matchesCount) {
1699  return `${state.graph.rows.length} generations | ${actualFamilyEntries().length} families | ${state.graph.people.length} people | ${matchesCount} matches`;
1700}
1701
1702function renderSearchSuggestions(matches) {
1703  if (!matches.length || !state.searchQuery) {
1704    return "";
1705  }
1706  return matches.slice(0, 8).map(({ person }) => `
1707    <button
1708      class="genealogy-search-suggestion"
1709      type="button"
1710      data-search-suggestion="${escapeHtml(person.id)}"
1711    >
1712      <span class="genealogy-search-suggestion-photo">${photoMarkup(person, "w-full h-full object-cover")}</span>
1713      <span class="genealogy-search-suggestion-body">
1714        <span class="genealogy-search-suggestion-name">${escapeHtml(person.displayName)}</span>
1715        <span class="genealogy-search-suggestion-meta">${escapeHtml(familyLabel(person))} | ${escapeHtml(formatLifeSpan(person))}</span>
1716      </span>
1717    </button>
1718  `).join("");
1719}
1720
1721function updateTreeSearchUI() {
1722  if (state.activeView !== "tree" || !state.graph) {
1723    return false;
1724  }
1725
1726  const search = root.querySelector("[data-role='search']");
1727  if (!search) {
1728    return false;
1729  }
1730
1731  const matches = getSearchMatches();
1732  const strictMatchIds = new Set(
1733    state.graph.people
1734      .filter((person) => personMatchesSearch(person, state.searchQuery))
1735      .map((person) => person.id)
1736  );
1737  const effectiveMatches = new Set(
1738    state.graph.people
1739      .filter((person) => !state.searchQuery || strictMatchIds.has(person.id))
1740      .filter((person) => !state.activeFamilyFilter || familyLabel(person) === state.activeFamilyFilter)
1741      .map((person) => person.id)
1742  );
1743
1744  root.querySelectorAll(".genealogy-tree-node").forEach((node) => {
1745    const personId = node.dataset.treeNode;
1746    const person = state.graph.peopleById.get(personId);
1747    const dimmed = (state.searchQuery || state.activeFamilyFilter)
1748      && person
1749      && !effectiveMatches.has(personId)
1750      && personId !== state.selectedPersonId;
1751    node.classList.toggle("is-search-dimmed", Boolean(dimmed));
1752    node.classList.toggle("is-search-hit", Boolean(state.searchQuery && strictMatchIds.has(personId)));
1753  });
1754
1755  const summary = root.querySelector("[data-search-summary]");
1756  if (summary) {
1757    summary.textContent = treeSummaryText(strictMatchIds.size);
1758  }
1759
1760  const suggestionHost = root.querySelector("[data-search-suggestions]");
1761  if (suggestionHost) {
1762    suggestionHost.innerHTML = renderSearchSuggestions(matches);
1763    suggestionHost.hidden = !state.searchQuery || !matches.length;
1764  }
1765
1766  return true;
1767}
1768
1769function applySearchSuggestion(personId) {
1770  const person = state.graph?.peopleById.get(personId);
1771  if (!person) {
1772    return;
1773  }
1774  state.searchQuery = normalizeSearchText(person.displayName);
1775  state.searchDisplay = person.displayName;
1776  state.selectedPersonId = person.id;
1777  const search = root.querySelector("[data-role='search']");
1778  if (search) {
1779    search.value = person.displayName;
1780  }
1781  if (state.activeView !== "tree") {
1782    render();
1783    return;
1784  }
1785  updateTreeSearchUI();
1786  const payload = datasetHighlightPayload(
1787    root.querySelector(`[data-tree-node="${person.id}"]`)?.dataset || {},
1788    `person:${person.id}`
1789  );
1790  if (payload.peopleIds.length || payload.edgeIds.length) {
1791    state.lockedTreeHighlight = payload;
1792    applyTreeHighlight(payload.peopleIds, payload.edgeIds);
1793  }
1794  focusTreeOnPeople([person.id], { fit: false });
1795}
1796
1797function bindSearchInput() {
1798  const search = root.querySelector("[data-role='search']");
1799  if (!search || search.dataset.bound === "true") {
1800    return;
1801  }
1802
1803  search.dataset.bound = "true";
1804  const shell = search.parentElement;
1805  if (shell) {
1806    shell.classList.add("genealogy-search-shell");
1807    let suggestionHost = shell.querySelector("[data-search-suggestions]");
1808    if (!suggestionHost) {
1809      suggestionHost = document.createElement("div");
1810      suggestionHost.className = "genealogy-search-suggestions";
1811      suggestionHost.dataset.searchSuggestions = "true";
1812      suggestionHost.hidden = true;
1813      shell.appendChild(suggestionHost);
1814    }
1815  }
1816
1817  search.setAttribute("autocomplete", "off");
1818  search.addEventListener("keydown", (event) => {
1819    if (event.key === "Enter") {
1820      const firstSuggestion = root.querySelector("[data-search-suggestion]");
1821      if (firstSuggestion) {
1822        event.preventDefault();
1823        applySearchSuggestion(firstSuggestion.dataset.searchSuggestion);
1824      }
1825    }
1826    if (event.key === "Escape") {
1827      state.searchQuery = "";
1828      state.searchDisplay = "";
1829      search.value = "";
1830      updateTreeSearchUI();
1831    }
1832  });
1833}
1834
1835function disablePlannedExportButtons() {
1836  root.querySelectorAll("button").forEach((button) => {
1837    const text = String(button.textContent || "").replace(/\s+/g, " ").trim();
1838    if (/Download Records/i.test(text) || /Export Full Records/i.test(text)) {
1839      button.classList.add("genealogy-disabled-button", "genealogy-disabled-export");
1840      button.disabled = true;
1841      button.setAttribute("aria-disabled", "true");
1842      button.title = "Downloads will be added later for Markdown and Excel exports.";
1843      if (/Download Records/i.test(text)) {
1844        button.textContent = "Download Records (Coming Later)";
1845      } else {
1846        button.innerHTML = `
1847          <span class="material-symbols-outlined">file_download</span>
1848          Export Full Records (Coming Later)
1849        `;
1850      }
1851    }
1852  });
1853}
1854
1855function connectorLaneY(startY, endY, index = 0, count = 1) {
1856  const minLaneY = startY + 34;
1857  const maxLaneY = Math.max(minLaneY, endY - 38);
1858  if (count <= 1) {
1859    return Math.min(maxLaneY, startY + Math.max(42, Math.min(112, (endY - startY) * 0.36)));
1860  }
1861  const laneStep = Math.min(26, Math.max(12, (maxLaneY - minLaneY) / Math.max(count - 1, 1)));
1862  return Math.min(maxLaneY, minLaneY + index * laneStep);
1863}
1864
1865function edgePath(edge, treeGraph = state.renderedTree || state.graph) {
1866  if (edge.type === "family") {
1867    const parentPositions = edge.parents.map((id) => treeGraph.positions.get(id)).filter(Boolean).sort((left, right) => left.x - right.x);
1868    const childPosition = treeGraph.positions.get(edge.child);
1869    if (!parentPositions.length || !childPosition) {
1870      return "";
1871    }
1872    const unionAnchor = edge.unionId ? treeGraph.unionAnchors?.get(edge.unionId) : null;
1873    const startX = unionAnchor?.x ?? average(parentPositions.map((position) => position.x + NODE_WIDTH / 2));
1874    const startY = unionAnchor?.y ?? Math.max(...parentPositions.map((position) => position.y + NODE_HEIGHT)) + 36;
1875    const endX = childPosition.x + NODE_WIDTH / 2;
1876    const endY = childPosition.y;
1877    const laneY = connectorLaneY(startY, endY, edge.childIndex || 0, edge.childCount || 1);
1878    const parentStemPath = edge.showParentStems
1879      ? parentPositions.map((position) => {
1880        const parentX = position.x + NODE_WIDTH / 2;
1881        const parentY = position.y + NODE_HEIGHT;
1882        return `M ${parentX} ${parentY} L ${parentX} ${startY} L ${startX} ${startY}`;
1883      }).join(" ")
1884      : "";
1885    return `${parentStemPath} M ${startX} ${startY} L ${startX} ${laneY} L ${endX} ${laneY} L ${endX} ${endY}`.trim();
1886  }
1887
1888  const from = treeGraph.positions.get(edge.from);
1889  const to = treeGraph.positions.get(edge.to);
1890  if (!from || !to) {
1891    return "";
1892  }
1893
1894  if (edge.type === "spouse") {
1895    const sorted = [from, to].sort((left, right) => left.x - right.x);
1896    const startX = sorted[0].x + NODE_WIDTH;
1897    const endX = sorted[1].x;
1898    const unionAnchor = edge.unionId ? treeGraph.unionAnchors?.get(edge.unionId) : null;
1899    if (unionAnchor) {
1900      const startBottomX = sorted[0].x + NODE_WIDTH / 2;
1901      const endBottomX = sorted[1].x + NODE_WIDTH / 2;
1902      const startBottomY = sorted[0].y + NODE_HEIGHT;
1903      const endBottomY = sorted[1].y + NODE_HEIGHT;
1904      const jointY = Math.max(unionAnchor.y, startBottomY + 28, endBottomY + 28);
1905      return `M ${startBottomX} ${startBottomY} L ${startBottomX} ${jointY} L ${unionAnchor.x} ${jointY} L ${endBottomX} ${jointY} L ${endBottomX} ${endBottomY}`;
1906    }
1907    const y = Math.max(sorted[0].y, sorted[1].y) + NODE_HEIGHT + 28;
1908    return `M ${startX} ${y} C ${startX + 18} ${y}, ${endX - 18} ${y}, ${endX} ${y}`;
1909  }
1910
1911  if (edge.type === "possible_link") {
1912    const startX = from.x + NODE_WIDTH / 2;
1913    const startY = from.y + NODE_HEIGHT / 2;
1914    const endX = to.x + NODE_WIDTH / 2;
1915    const endY = to.y + NODE_HEIGHT / 2;
1916    const controlX = average([startX, endX]);
1917    const controlY = Math.min(startY, endY) - Math.max(36, Math.min(140, Math.abs(endX - startX) * 0.16));
1918    return `M ${startX} ${startY} C ${controlX} ${controlY}, ${controlX} ${controlY}, ${endX} ${endY}`;
1919  }
1920
1921  const unionAnchor = edge.unionId ? treeGraph.unionAnchors?.get(edge.unionId) : null;
1922  const parentBottomX = from.x + NODE_WIDTH / 2;
1923  const parentBottomY = from.y + NODE_HEIGHT;
1924  const startX = unionAnchor?.x ?? parentBottomX;
1925  const startY = unionAnchor?.y ?? parentBottomY;
1926  const endX = to.x + NODE_WIDTH / 2;
1927  const endY = to.y;
1928  const laneY = connectorLaneY(startY, endY);
1929  const parentStemPath = unionAnchor
1930    ? `M ${parentBottomX} ${parentBottomY} L ${startX} ${startY}`
1931    : `M ${startX} ${startY}`;
1932  return `${parentStemPath} L ${startX} ${laneY} L ${endX} ${laneY} L ${endX} ${endY}`;
1933}
1934
1935function renderEnhancedTreeBoard() {
1936  const treeGraph = buildVisibleTreeGraph();
1937  state.renderedTree = treeGraph;
1938  const stage = currentStageDimensions();
1939  const scale = state.treeViewport.scale || 1;
1940  const queryMatchIds = new Set(
1941    treeGraph.people
1942      .filter((person) => personMatchesSearch(person, state.searchQuery))
1943      .map((person) => person.id)
1944  );
1945  const matchIds = state.activeFamilyFilter
1946    ? new Set(treeGraph.people.map((person) => person.id))
1947    : new Set(treeGraph.people.filter(matchesActiveFilters).map((person) => person.id));
1948  const legendMarkup = state.graph.familyLegend.map((entry) => `
1949    <button
1950      class="genealogy-legend-chip${state.activeFamilyFilter === entry.family ? " is-active" : ""}"
1951      type="button"
1952      data-family-filter="${escapeHtml(entry.family)}"
1953      aria-pressed="${state.activeFamilyFilter === entry.family ? "true" : "false"}"
1954      style="--family-color:${escapeHtml(entry.color)}"
1955    >
1956      <span class="genealogy-legend-dot"></span>
1957      ${escapeHtml(entry.family)} (${entry.count})
1958    </button>
1959  `).join("");
1960  const generationMarkup = treeGraph.rows.map((row) => `
1961    <div class="genealogy-generation-marker">Gen ${row.generation + 1} | ${escapeHtml(row.label)}</div>
1962  `).join("");
1963  const bandMarkup = treeGraph.rows.map((row) => `
1964    <div class="genealogy-generation-band" style="top:${(row.top ?? (BOARD_PAD_Y + row.generation * ROW_HEIGHT)) - 26}px;">
1965      <span class="genealogy-generation-pill">Generation ${row.generation + 1} | ${escapeHtml(row.label)}</span>
1966    </div>
1967  `).join("");
1968  const edgeMarkup = treeGraph.displayEdges.map((edge) => {
1969    const path = edgePath(edge, treeGraph);
1970    if (!path) {
1971      return "";
1972    }
1973    const highlightEdgeIds = unique([
1974      edge.id,
1975      ...(edge.people || []).flatMap((personId) => state.graph.connectedEdgeIdsByPerson.get(personId) || [])
1976    ]);
1977    const edgeColor = edge.type === "possible_link" ? "#8b919d" : edge.color || "#0041c8";
1978    const klass = edge.type === "spouse"
1979      ? "spouse"
1980      : edge.type === "possible_link" || edge.status === "unresolved" || edge.confidence === "low"
1981        ? "speculative"
1982        : edge.type === "family"
1983          ? "family"
1984          : "parent";
1985    return `
1986      <path
1987        class="genealogy-edge ${klass}"
1988        d="${path}"
1989        data-edge-id="${escapeHtml(edge.id)}"
1990        data-highlight-people="${escapeHtml((edge.people || []).join(","))}"
1991        data-highlight-edges="${escapeHtml(highlightEdgeIds.join(","))}"
1992        style="--edge-color:${escapeHtml(edgeColor)}; --edge-color-soft:${escapeHtml(hexToRgba(edgeColor, 0.18))};"
1993      ></path>
1994    `;
1995  }).join("");
1996  const nodeMarkup = treeGraph.people.map((person) => {
1997    const position = treeGraph.positions.get(person.id);
1998    const summary = state.graph.summaryById.get(person.id);
1999    const dimmed = (state.searchQuery || state.activeFamilyFilter) && !matchIds.has(person.id) && person.id !== state.selectedPersonId
2000      ? " is-search-dimmed"
2001      : "";
2002    const hit = state.searchQuery && queryMatchIds.has(person.id) ? " is-search-hit" : "";
2003    const speculative = person.status === "candidate" || person.status === "unresolved";
2004    const familyColor = person.familyColor || "#0041c8";
2005    const parentNames = relationshipNames(summary.parents, state.graph.peopleById, "Not linked yet");
2006    const spouseNames = relationshipNames(summary.spouses, state.graph.peopleById, "No spouse linked");
2007    const highlightPeople = state.graph.highlightPeopleByPerson.get(person.id) || [person.id];
2008    const highlightEdges = state.graph.connectedEdgeIdsByPerson.get(person.id) || [];
2009    const keyFactsMarkup = renderPersonKeyFacts(person, summary);
2010    const summaryMarkup = emphasizeInfoText(truncate(person.summary || "No narrative summary recorded yet.", 112));
2011    const hoverSummaryMarkup = emphasizeInfoText(truncate(person.summary || "This branch is still being assembled from the archive.", 130));
2012    return `
2013      <div
2014        class="genealogy-tree-node${dimmed}${hit}"
2015        style="left:${position.x}px; top:${position.y}px; --family-color:${escapeHtml(familyColor)}; --family-color-soft:${escapeHtml(hexToRgba(familyColor, 0.14))}; --family-color-strong:${escapeHtml(hexToRgba(familyColor, 0.3))};"
2016        data-tree-node="${escapeHtml(person.id)}"
2017        data-highlight-people="${escapeHtml(highlightPeople.join(","))}"
2018        data-highlight-edges="${escapeHtml(highlightEdges.join(","))}"
2019      >
2020        <div
2021          class="genealogy-tree-card bg-surface-container-lowest editorial-shadow rounded-xl p-5 text-left border flex flex-col ${speculative ? "border-dashed border-zinc-400" : "border-outline-variant/10"} ${person.id === state.selectedPersonId ? "ring-2 ring-primary" : ""}"
2022          data-tree-select="true"
2023          data-tree-person-id="${escapeHtml(person.id)}"
2024          tabindex="0"
2025          role="button"
2026          aria-pressed="${state.lockedTreeHighlight?.key === `person:${person.id}` ? "true" : "false"}"
2027        >
2028          <div class="flex items-start justify-between gap-3 mb-4">
2029            <div class="w-16 h-16 rounded-xl overflow-hidden bg-surface-container shadow-sm">
2030              ${photoMarkup(person, "w-full h-full object-cover")}
2031            </div>
2032            <div class="flex flex-col items-end gap-2">
2033              <span class="genealogy-family-chip">${escapeHtml(familyLabel(person))}</span>
2034              ${speculative ? `<span class="genealogy-status-chip">${escapeHtml(person.status)}</span>` : ""}
2035            </div>
2036          </div>
2037          <h3 class="genealogy-tree-name font-headline text-[1.7rem] font-bold leading-tight mb-2">${escapeHtml(person.displayName)}</h3>
2038          <p class="text-on-surface-variant text-[11px] font-semibold uppercase tracking-[0.18em]">${emphasizeInfoText(formatLifeSpan(person))}</p>
2039          ${keyFactsMarkup ? `<div class="genealogy-key-facts mt-3">${keyFactsMarkup}</div>` : ""}
2040          <p class="genealogy-tree-summary text-sm text-on-surface-variant mt-3 leading-relaxed">${summaryMarkup}</p>
2041          <div class="genealogy-tree-footer mt-auto pt-4 border-t border-outline-variant/10 flex items-center justify-between text-[10px] uppercase tracking-[0.22em] font-bold text-outline">
2042            <span class="genealogy-tree-counts">${escapeHtml(pluralize(summary.parents.length, "parent"))} | ${escapeHtml(pluralize(summary.children.length, "child", "children"))} | ${escapeHtml(pluralize(personRecordCount(person), "record"))}</span>
2043            <button class="genealogy-open-button" type="button" data-person-id="${escapeHtml(person.id)}" data-open-profile="true" aria-label="Open profile for ${escapeHtml(person.displayName)}">
2044              <span class="material-symbols-outlined text-primary">open_in_new</span>
2045            </button>
2046          </div>
2047        </div>
2048        <div class="genealogy-hover glass-card rounded-xl p-4 border border-outline-variant/20 shadow-xl">
2049          <div class="flex items-start gap-3">
2050            <div class="w-16 h-20 rounded-lg overflow-hidden bg-surface-container">${photoMarkup(person, "w-full h-full object-cover")}</div>
2051            <div class="min-w-0">
2052              <p class="text-[10px] font-bold uppercase tracking-[0.22em] text-primary">${escapeHtml(familyLabel(person))}</p>
2053              <h4 class="font-headline text-lg font-bold leading-tight mt-1">${escapeHtml(person.displayName)}</h4>
2054              <p class="text-xs text-secondary mt-1">${emphasizeInfoText(formatLifeSpan(person))}</p>
2055              <p class="text-[11px] text-secondary mt-3 leading-relaxed">${hoverSummaryMarkup}</p>
2056              <div class="mt-3 text-[10px] uppercase tracking-[0.18em] text-outline space-y-1">
2057                <p>Parents: ${emphasizeInfoText(parentNames)}</p>
2058                <p>Spouse: ${emphasizeInfoText(spouseNames)}</p>
2059              </div>
2060            </div>
2061          </div>
2062        </div>
2063      </div>
2064    `;
2065  }).join("");
2066  return `
2067    <div class="genealogy-tree-toolbar" style="--legend-offset:${stage.left - 4}px;">
2068      <div class="genealogy-tree-legend">${legendMarkup}</div>
2069      <div class="genealogy-tree-zoom-controls">
2070        <button class="genealogy-zoom-button" type="button" data-zoom-action="out" aria-label="Zoom out">
2071          <span class="material-symbols-outlined">remove</span>
2072        </button>
2073        <button class="genealogy-zoom-button" type="button" data-zoom-action="fit" aria-label="Fit whole tree">
2074          <span class="material-symbols-outlined">fit_screen</span>
2075        </button>
2076        <button class="genealogy-zoom-button" type="button" data-zoom-action="in" aria-label="Zoom in">
2077          <span class="material-symbols-outlined">add</span>
2078        </button>
2079        <label class="genealogy-zoom-readout genealogy-zoom-input-shell" aria-label="Zoom percentage">
2080          <input
2081            class="genealogy-zoom-input"
2082            data-tree-zoom-input
2083            type="number"
2084            min="0"
2085            max="${Math.round(ZOOM_MAX * 100)}"
2086            step="1"
2087            value="${state.treeViewport.displayPercent ?? Math.round(scale * 100)}"
2088            inputmode="numeric"
2089          >
2090          <span class="genealogy-zoom-suffix">%</span>
2091        </label>
2092      </div>
2093    </div>
2094    <div class="genealogy-tree-scroll no-scrollbar" data-tree-scroll>
2095      <div class="genealogy-tree-stage" data-tree-stage data-base-width="${stage.width}" data-base-height="${stage.height}" style="width:${Math.round(stage.width * scale)}px; height:${Math.round(stage.height * scale)}px;">
2096        <div class="genealogy-tree-panzoom" data-tree-panzoom style="width:${stage.width}px; height:${stage.height}px; transform:scale(${scale});">
2097          <div class="genealogy-generation-rail" style="top:${stage.top}px; bottom:${stage.bottom}px; width:${stage.railWidth}px;">${generationMarkup}</div>
2098          <div class="genealogy-tree-board" style="left:${stage.left}px; top:${stage.top}px; width:${treeGraph.boardWidth}px; height:${treeGraph.boardHeight}px;">
2099            <div class="genealogy-generation-bands">${bandMarkup}</div>
2100            <svg viewBox="0 0 ${treeGraph.boardWidth} ${treeGraph.boardHeight}" preserveAspectRatio="xMinYMin meet">${edgeMarkup}</svg>
2101            <div class="genealogy-tree-layer">
2101${nodeMarkup}</div>
2102          </div>
2103        </div>
2104      </div>
2105    </div>
2106  `;
2107}
2108
2109function applyTreeViewport() {
2110  const stage = root.querySelector("[data-tree-stage]");
2111  const panzoom = root.querySelector("[data-tree-panzoom]");
2112  const readout = root.querySelector("[data-tree-zoom-value]");
2113  const input = root.querySelector("[data-tree-zoom-input]");
2114  if (!stage || !panzoom) {
2115    return;
2116  }
2117
2118  const baseWidth = Number(stage.dataset.baseWidth || 0);
2119  const baseHeight = Number(stage.dataset.baseHeight || 0);
2120  const scale = state.treeViewport.scale || 1;
2121  stage.style.width = `${Math.round(baseWidth * scale)}px`;
2122  stage.style.height = `${Math.round(baseHeight * scale)}px`;
2123  panzoom.style.transform = `scale(${scale})`;
2124  const displayPercent = state.treeViewport.displayPercent ?? Math.round(scale * 100);
2125  if (readout) {
2126    readout.textContent = `${displayPercent}%`;
2127  }
2128  if (input) {
2129    input.value = String(displayPercent);
2130  }
2131}
2132
2133function snapshotTreeViewport() {
2134  const scroller = root.querySelector("[data-tree-scroll]");
2135  return {
2136    scale: state.treeViewport.scale,
2137    displayPercent: state.treeViewport.displayPercent,
2138    scrollLeft: scroller?.scrollLeft || 0,
2139    scrollTop: scroller?.scrollTop || 0
2140  };
2141}
2142
2143function restoreTreeViewportSnapshot(snapshot) {
2144  if (!snapshot) {
2145    return;
2146  }
2147  const scroller = root.querySelector("[data-tree-scroll]");
2148  state.treeViewport.scale = snapshot.scale || 1;
2149  state.treeViewport.displayPercent = snapshot.displayPercent ?? null;
2150  applyTreeViewport();
2151  if (scroller) {
2152    scroller.scrollLeft = snapshot.scrollLeft || 0;
2153    scroller.scrollTop = snapshot.scrollTop || 0;
2154  }
2155}
2156
2157function fitTreeToViewport(options = {}) {
2158  const scroller = root.querySelector("[data-tree-scroll]");
2159  const stage = root.querySelector("[data-tree-stage]");
2160  if (!scroller || !stage) {
2161    return;
2162  }
2163
2164  const baseWidth = Number(stage.dataset.baseWidth || 0);
2165  const baseHeight = Number(stage.dataset.baseHeight || 0);
2166  if (!baseWidth || !baseHeight) {
2167    return;
2168  }
2169
2170  const usableWidth = Math.max(64, scroller.clientWidth - FIT_VIEWPORT_PADDING * 2);
2171  const usableHeight = Math.max(64, scroller.clientHeight - FIT_VIEWPORT_PADDING * 2);
2172  const fitScale = clamp(
2173    Math.min(usableWidth / baseWidth, usableHeight / baseHeight),
2174    ZOOM_MIN,
2175    1
2176  );
2177  state.treeViewport.scale = fitScale;
2178  state.treeViewport.displayPercent = options.displayPercent ?? null;
2179  applyTreeViewport();
2180
2181  const scaledWidth = baseWidth * fitScale;
2182  const scaledHeight = baseHeight * fitScale;
2183  scroller.scrollLeft = Math.max(0, (scaledWidth - scroller.clientWidth) / 2);
2184  scroller.scrollTop = Math.max(0, (scaledHeight - scroller.clientHeight) / 2);
2185}
2186
2187function updateTreeScale(nextScale, anchor) {
2188  const scroller = root.querySelector("[data-tree-scroll]");
2189  if (!scroller) {
2190    return;
2191  }
2192
2193  const currentScale = state.treeViewport.scale || 1;
2194  const scale = clamp(Number(nextScale) || 1, ZOOM_MIN, ZOOM_MAX);
2195  if (Math.abs(scale - currentScale) < 0.001) {
2196    return;
2197  }
2198
2199  const scrollerRect = scroller.getBoundingClientRect();
2200  const anchorClientX = anchor?.clientX ?? scrollerRect.left + scroller.clientWidth / 2;
2201  const anchorClientY = anchor?.clientY ?? scrollerRect.top + scroller.clientHeight / 2;
2202  const relativeX = anchorClientX - scrollerRect.left;
2203  const relativeY = anchorClientY - scrollerRect.top;
2204  const contentX = (scroller.scrollLeft + relativeX) / currentScale;
2205  const contentY = (scroller.scrollTop + relativeY) / currentScale;
2206
2207  state.treeViewport.scale = scale;
2208  state.treeViewport.displayPercent = null;
2209  applyTreeViewport();
2210
2211  scroller.scrollLeft = contentX * scale - relativeX;
2212  scroller.scrollTop = contentY * scale - relativeY;
2213}
2214
2215function bindTreeViewport() {
2216  const scroller = root.querySelector("[data-tree-scroll]");
2217  const board = root.querySelector(".genealogy-tree-board");
2218  const zoomInput = root.querySelector("[data-tree-zoom-input]");
2219  if (!scroller || !board) {
2220    return;
2221  }
2222
2223  applyTreeViewport();
2224
2225  root.querySelectorAll("[data-zoom-action]").forEach((button) => {
2226    button.addEventListener("click", () => {
2227      if (button.dataset.zoomAction === "in") {
2228        updateTreeScale(state.treeViewport.scale + ZOOM_STEP);
2229        return;
2230      }
2231      if (button.dataset.zoomAction === "out") {
2232        updateTreeScale(state.treeViewport.scale - ZOOM_STEP);
2233        return;
2234      }
2235      fitTreeToViewport();
2236    });
2237  });
2238
2239  if (zoomInput) {
2240    const applyZoomInput = () => {
2241      const parsed = Number.parseFloat(String(zoomInput.value || "").replace("%", "").trim());
2242      if (!Number.isFinite(parsed)) {
2243        applyTreeViewport();
2244        return;
2245      }
2246      if (parsed <= 0) {
2247        fitTreeToViewport();
2248        return;
2249      }
2250      updateTreeScale(parsed / 100);
2251    };
2252
2253    zoomInput.addEventListener("keydown", (event) => {
2254      if (event.key === "Enter") {
2255        event.preventDefault();
2256        applyZoomInput();
2257        zoomInput.blur();
2258      }
2259    });
2260    zoomInput.addEventListener("blur", applyZoomInput);
2261  }
2262
2263  scroller.addEventListener("wheel", (event) => {
2264    if (!event.ctrlKey && !event.metaKey) {
2265      return;
2266    }
2267    event.preventDefault();
2268    updateTreeScale(state.treeViewport.scale + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), {
2269      clientX: event.clientX,
2270      clientY: event.clientY
2271    });
2272  }, { passive: false });
2273
2274  let drag = null;
2275  const stopDrag = () => {
2276    drag = null;
2277    scroller.classList.remove("is-pointer-down", "is-dragging");
2278  };
2279
2280  scroller.addEventListener("pointerdown", (event) => {
2281    if (event.pointerType === "mouse" && event.button !== 0) {
2282      return;
2283    }
2284    if (event.target.closest("[data-tree-node], [data-person-id], [data-open-profile], button, a, input")) {
2285      return;
2286    }
2287    drag = {
2288      pointerId: event.pointerId,
2289      clientX: event.clientX,
2290      clientY: event.clientY,
2291      scrollLeft: scroller.scrollLeft,
2292      scrollTop: scroller.scrollTop
2293    };
2294    scroller.classList.add("is-pointer-down");
2295    board.setPointerCapture?.(event.pointerId);
2296  });
2297
2298  scroller.addEventListener("pointermove", (event) => {
2299    if (!drag || drag.pointerId !== event.pointerId) {
2300      return;
2301    }
2302    const deltaX = event.clientX - drag.clientX;
2303    const deltaY = event.clientY - drag.clientY;
2304    if (Math.abs(deltaX) + Math.abs(deltaY) > 5) {
2305      scroller.classList.add("is-dragging");
2306    }
2307    scroller.scrollLeft = drag.scrollLeft - deltaX;
2308    scroller.scrollTop = drag.scrollTop - deltaY;
2309  });
2310
2311  scroller.addEventListener("pointerup", stopDrag);
2312  scroller.addEventListener("pointercancel", stopDrag);
2313  scroller.addEventListener("pointerleave", (event) => {
2314    if (!drag || event.buttons === 0) {
2315      stopDrag();
2316    }
2317  });
2318
2319  let pinch = null;
2320  const touchDistance = (touchA, touchB) => Math.hypot(touchA.clientX - touchB.clientX, touchA.clientY - touchB.clientY);
2321  const touchMidpoint = (touchA, touchB) => ({
2322    clientX: (touchA.clientX + touchB.clientX) / 2,
2323    clientY: (touchA.clientY + touchB.clientY) / 2
2324  });
2325
2326  scroller.addEventListener("touchstart", (event) => {
2327    if (event.touches.length !== 2) {
2328      return;
2329    }
2330    pinch = {
2331      distance: touchDistance(event.touches[0], event.touches[1]),
2332      scale: state.treeViewport.scale
2333    };
2334  }, { passive: true });
2335
2336  scroller.addEventListener("touchmove", (event) => {
2337    if (event.touches.length !== 2 || !pinch) {
2338      return;
2339    }
2340    event.preventDefault();
2341    const distance = touchDistance(event.touches[0], event.touches[1]);
2342    const midpoint = touchMidpoint(event.touches[0], event.touches[1]);
2343    updateTreeScale(pinch.scale * (distance / pinch.distance), midpoint);
2344  }, { passive: false });
2345
2346  scroller.addEventListener("touchend", (event) => {
2347    if (event.touches.length < 2) {
2348      pinch = null;
2349    }
2350  }, { passive: true });
2351
2352  scroller.addEventListener("touchcancel", (event) => {
2353    if (event.touches.length < 2) {
2354      pinch = null;
2355    }
2356  });
2357}
2358
2359function clearTreeHighlight() {
2360  if (state.lockedTreeHighlight) {
2361    applyTreeHighlight(state.lockedTreeHighlight.peopleIds, state.lockedTreeHighlight.edgeIds);
2362    return;
2363  }
2364  root.querySelectorAll(".genealogy-tree-node").forEach((node) => {
2365    node.classList.remove("is-highlighted", "is-muted");
2366  });
2367  root.querySelectorAll(".genealogy-edge").forEach((edge) => {
2368    edge.classList.remove("is-highlighted", "is-muted");
2369  });
2370}
2371
2372function applyTreeHighlight(peopleIds, edgeIds) {
2373  const peopleSet = new Set((peopleIds || []).filter(Boolean));
2374  const edgeSet = new Set((edgeIds || []).filter(Boolean));
2375
2376  root.querySelectorAll(".genealogy-tree-node").forEach((node) => {
2377    const nodeId = node.dataset.treeNode;
2378    node.classList.toggle("is-highlighted", peopleSet.has(nodeId));
2379    node.classList.toggle("is-muted", !peopleSet.has(nodeId));
2380  });
2381
2382  root.querySelectorAll(".genealogy-edge").forEach((edge) => {
2383    const edgeId = edge.dataset.edgeId;
2384    edge.classList.toggle("is-highlighted", edgeSet.has(edgeId));
2385    edge.classList.toggle("is-muted", !edgeSet.has(edgeId));
2386  });
2387}
2388
2389function activateBranchPreview(personId) {
2390  if (!personId || state.hoverBranchPreview?.personId === personId) {
2391    return;
2392  }
2393  const payload = branchPreviewPayload(personId);
2394  if (payload.peopleIds.length <= 1) {
2395    return;
2396  }
2397  if (!state.hoverBranchPreview) {
2398    state.hoverBranchPreview = {
2399      personId,
2400      snapshot: snapshotTreeViewport()
2401    };
2402  } else {
2403    state.hoverBranchPreview.personId = personId;
2404  }
2405  root.querySelector(".genealogy-tree-board")?.classList.add("is-branch-preview");
2406  applyTreeHighlight(payload.peopleIds, payload.edgeIds);
2407  focusTreeOnPeople(payload.peopleIds, { minScale: 0.4 });
2408}
2409
2410function clearBranchPreview() {
2411  if (!state.hoverBranchPreview) {
2412    clearTreeHighlight();
2413    return;
2414  }
2415  const { snapshot } = state.hoverBranchPreview;
2416  state.hoverBranchPreview = null;
2417  root.querySelector(".genealogy-tree-board")?.classList.remove("is-branch-preview");
2418  restoreTreeViewportSnapshot(snapshot);
2419  clearTreeHighlight();
2420}
2421
2422function bindTreeInteractions() {
2423  const board = root.querySelector(".genealogy-tree-board");
2424  const scroller = root.querySelector("[data-tree-scroll]");
2425  if (!board) {
2426    return;
2427  }
2428  scroller?.addEventListener("mouseleave", clearBranchPreview);
2429
2430  const activateFromDataset = (dataset) => {
2431    const { peopleIds, edgeIds } = datasetHighlightPayload(dataset);
2432    if (!peopleIds.length && !edgeIds.length) {
2433      clearTreeHighlight();
2434      return;
2435    }
2436    applyTreeHighlight(peopleIds, edgeIds);
2437  };
2438
2439  const toggleLockedHighlight = (payload) => {
2440    if (!payload.peopleIds.length && !payload.edgeIds.length) {
2441      state.lockedTreeHighlight = null;
2442      clearTreeHighlight();
2443      return;
2444    }
2445    if (state.lockedTreeHighlight?.key === payload.key) {
2446      state.lockedTreeHighlight = null;
2447      clearTreeHighlight();
2448      return;
2449    }
2450    state.lockedTreeHighlight = payload;
2451    applyTreeHighlight(payload.peopleIds, payload.edgeIds);
2452  };
2453
2454  board.querySelectorAll("[data-tree-node]").forEach((node) => {
2455    node.addEventListener("mouseenter", () => activateBranchPreview(node.dataset.treeNode));
2456    node.addEventListener("focusin", () => activateFromDataset(node.dataset));
2457    node.addEventListener("focusout", (event) => {
2458      if (!node.contains(event.relatedTarget)) {
2459        clearTreeHighlight();
2460      }
2461    });
2462    const selectTarget = node.querySelector("[data-tree-select]");
2463    if (selectTarget) {
2464      selectTarget.addEventListener("click", () => {
2465        state.selectedPersonId = selectTarget.dataset.treePersonId || state.selectedPersonId;
2466        toggleLockedHighlight(datasetHighlightPayload(node.dataset, `person:${state.selectedPersonId}`));
2467        focusTreeOnPeople([state.selectedPersonId], { fit: false });
2468      });
2469    }
2470  });
2471
2472  board.querySelectorAll("[data-edge-id]").forEach((edge) => {
2473    edge.addEventListener("mouseenter", () => activateFromDataset(edge.dataset));
2474    edge.addEventListener("mouseleave", clearTreeHighlight);
2475    edge.addEventListener("click", () => {
2476      toggleLockedHighlight(datasetHighlightPayload(edge.dataset, `edge:${edge.dataset.edgeId}`));
2477    });
2478  });
2479}
2480
2481function focusTreeOnSelectedPerson() {
2482  focusTreeOnPeople([state.selectedPersonId], { minScale: 0.75 });
2483}
2484
2485function queueTreeFocus(peopleIds = []) {
2486  state.treeViewport.focusPeopleIds = unique(peopleIds);
2487  state.treeViewport.restorePending = false;
2488  state.treeViewport.fitPending = false;
2489  state.treeViewport.autoFocusPending = false;
2490}
2491
2492function applyShellBranding() {
2493  document.title = "Our Family Tree";
2494
2495  const normalizeNodeText = (value) => String(value || "").replace(/\s+/g, " ").trim();
2496  const leafTextElements = (selector) => [...root.querySelectorAll(selector)].filter((element) => {
2497    if (element.childElementCount > 0) {
2498      return false;
2499    }
2500    return Boolean(normalizeNodeText(element.textContent));
2501  });
2502
2503  leafTextElements("div, span, p, h1, h2, h3, h4, h5, a, button").forEach((element) => {
2504    const text = normalizeNodeText(element.textContent);
2505    if (!text) {
2506      return;
2507    }
2508    if (text === "The Digital Archivist" || text === "Digital Archivist") {
2509      element.textContent = "Our Family Tree";
2510      return;
2511    }
2512    if (text.includes("The Digital Archivist")) {
2513      element.textContent = text.replaceAll("The Digital Archivist", "Our Family Tree");
2514      return;
2515    }
2516    if (text === "Family Repository") {
2517      element.textContent = "Our Family Tree";
2518      return;
2519    }
2520    if (text === "Verified Records") {
2521      element.textContent = "Private Archive";
2522    }
2523  });
2524
2525  root.querySelectorAll("button").forEach((button) => {
2526    const text = normalizeNodeText(button.textContent);
2527    if (/Add Record/i.test(text)) {
2528      button.remove();
2529    }
2530  });
2531
2532  const nav = root.querySelector("header nav");
2533  if (nav) {
2534    const links = [...nav.querySelectorAll("a")];
2535    if (links[0]) {
2536      links[0].textContent = "Family Tree";
2537      links[0].dataset.view = "tree";
2538      links[0].href = "#";
2539    }
2540    if (links[1]) {
2541      links[1].textContent = "Timeline";
2542      links[1].dataset.view = "timeline";
2543      links[1].href = "#";
2544    }
2545    if (links[2]) {
2546      links[2].textContent = "People";
2547      links[2].dataset.view = "people";
2548      links[2].href = "#";
2549    }
2550    if (!nav.querySelector("[data-view='sources']")) {
2551      const sourceLink = document.createElement("a");
2552      sourceLink.className = links[0]?.className || "text-zinc-500 hover:text-zinc-900 transition-colors";
2553      sourceLink.href = "#";
2554      sourceLink.dataset.view = "sources";
2555      sourceLink.textContent = "Sources";
2556      nav.appendChild(sourceLink);
2557    }
2558  }
2559
2560  root.querySelectorAll("a").forEach((link) => {
2561    const text = normalizeNodeText(link.textContent);
2562    if (["Privacy", "Terms", "Legal Documentation", "Need the password?"].includes(text)) {
2563      link.removeAttribute("href");
2564      link.classList.add("genealogy-muted-link");
2565    }
2566  });
2567
2568  root.querySelectorAll("label").forEach((label) => {
2569    const text = normalizeNodeText(label.textContent);
2570    if (text.includes("Keep me signed in")) {
2571      label.classList.add("genealogy-hidden");
2572    }
2573  });
2574
2575  leafTextElements("div, p, span").forEach((element) => {
2576    const text = normalizeNodeText(element.textContent);
2577    if (text.includes("Verified Records")) {
2578      element.textContent = "Family Archive";
2579    }
2580    if (text.includes("branches ready to explore") && state.encryptedPayload?.summary) {
2581      element.textContent = `${state.encryptedPayload.summary.personCount} people, ${state.encryptedPayload.summary.eventCount} events, and ${state.encryptedPayload.summary.familyCount || state.encryptedPayload.summary.branchCount} families ready to explore.`;
2582    }
2583  });
2584}
2585
2586function renderTreeBoard() {
2587  return renderEnhancedTreeBoard();
2588
2589  const matchIds = new Set(state.graph.people.filter(matchesSearch).map((person) => person.id));
2590  const generationMarkup = state.graph.rows.map((row) => {
2591    const years = row.ids.map((id) => getBirthYear(state.graph.peopleById.get(id))).filter((year) => Number.isFinite(year));
2592    const label = years.length ? `${Math.min(...years)}${Math.max(...years) !== Math.min(...years) ? `-${Math.max(...years)}` : ""}` : "Unknown";
2593    return `<div class="genealogy-generation-marker">Gen ${row.generation + 1} · ${escapeHtml(label)}</div>`;
2594  }).join("");
2595  const edgeMarkup = state.graph.relationships.map((relation) => {
2596    if (!["parent", "spouse", "possible_link"].includes(relation.type)) {
2597      return "";
2598    }
2599    const from = state.graph.positions.get(relation.from);
2600    const to = state.graph.positions.get(relation.to);
2601    if (!from || !to) {
2602      return "";
2603    }
2604    const klass = relation.type === "spouse" ? "spouse" : relation.type === "possible_link" || relation.status === "unresolved" || relation.confidence === "low" ? "speculative" : "parent";
2605    return `<path class="genealogy-edge ${klass}" d="${edgePath(from, to, relation.type)}"></path>`;
2606  }).join("");
2607  const nodeMarkup = state.graph.people.map((person) => {
2608    const position = state.graph.positions.get(person.id);
2609    const summary = state.graph.summaryById.get(person.id);
2610    const dimmed = state.searchQuery && !matchIds.has(person.id) && person.id !== state.selectedPersonId ? " is-dimmed" : "";
2611    const speculative = person.status === "candidate" || person.status === "unresolved";
2612    return `
2613      <div class="genealogy-tree-node${dimmed}" style="left:${position.x}px; top:${position.y}px;">
2614        <button class="bg-surface-container-lowest editorial-shadow rounded-xl p-5 text-left border ${speculative ? "border-dashed border-zinc-400" : "border-outline-variant/10"} ${person.id === state.selectedPersonId ? "ring-2 ring-primary" : ""} hover:-translate-y-1 transition-all" data-person-id="${escapeHtml(person.id)}" data-open-profile="true">
2615          <div class="flex items-start justify-between mb-4">
2616            <div class="w-14 h-14 rounded-lg overflow-hidden bg-surface-container">
2617              ${photoMarkup(person, "w-full h-full object-cover")}
2618            </div>
2619            <span class="bg-primary/10 text-primary text-[10px] font-black px-2 py-1 rounded uppercase tracking-widest">${escapeHtml(speculative ? person.status : person.family || "Record")}</span>
2620          </div>
2621          <h3 class="font-headline text-xl font-bold mb-1">${escapeHtml(person.displayName)}</h3>
2622          <p class="text-on-surface-variant text-xs font-medium uppercase tracking-tighter">${escapeHtml(formatLifeSpan(person))}</p>
2623          <div class="mt-4 pt-4 border-t border-outline-variant/10 flex items-center justify-between">
2624            <span class="text-[10px] text-outline uppercase font-bold tracking-widest">${escapeHtml(`${summary.parents.length} parents · ${summary.children.length} children`)}</span>
2625            <span class="material-symbols-outlined text-primary">open_in_new</span>
2626          </div>
2627        </button>
2628        <div class="genealogy-hover glass-card rounded-xl p-4 border border-outline-variant/20 shadow-xl">
2629          <div class="flex items-start gap-3">
2630            <div class="w-16 h-20 rounded-lg overflow-hidden bg-surface-container">${photoMarkup(person, "w-full h-full object-cover")}</div>
2631            <div class="min-w-0">
2632              <p class="text-[10px] font-bold uppercase tracking-widest text-primary">${escapeHtml(person.family || "Archive record")}</p>
2633              <h4 class="font-headline text-lg font-bold leading-tight">${escapeHtml(person.displayName)}</h4>
2634              <p class="text-xs text-secondary mt-1">${escapeHtml(formatLifeSpan(person))}</p>
2635              <p class="text-sm text-secondary mt-3">${escapeHtml(truncate(person.summary || "No biography summary recorded yet.", 130))}</p>
2636            </div>
2637          </div>
2638        </div>
2639      </div>
2640    `;
2641  }).join("");
2642  return `
2643    <div class="genealogy-tree-scroll no-scrollbar">
2644      <div class="genealogy-tree-stage">
2645        <div class="genealogy-generation-rail">${generationMarkup}</div>
2646        <div class="genealogy-tree-board" style="width:${state.graph.boardWidth}px; height:${state.graph.boardHeight}px;">
2647          <svg viewBox="0 0 ${state.graph.boardWidth} ${state.graph.boardHeight}">${edgeMarkup}</svg>
2648          <div class="genealogy-tree-layer">${nodeMarkup}</div>
2649        </div>
2650      </div>
2651    </div>
2652  `;
2653}
2654
2655function renderLockedView() {
2656  root.innerHTML = `<div class="genealogy-template-root">${state.templates.locked}</div>`;
2657  const form = root.querySelector("form");
2658  const input = root.querySelector("#password");
2659  if (form) {
2660    form.dataset.role = "unlock-form";
2661  }
2662  if (input) {
2663    input.id = "archive-password";
2664    input.placeholder = "Enter family password";
2665    input.value = "";
2666    input.type = state.passwordVisible ? "text" : "password";
2667  }
2668  const intro = root.querySelector("p.text-secondary.text-sm");
2669  if (intro && state.encryptedPayload?.summary) {
2670    intro.textContent = `${state.encryptedPayload.summary.personCount} people, ${state.encryptedPayload.summary.eventCount} events, ${state.encryptedPayload.summary.familyCount || state.encryptedPayload.summary.branchCount} families. Enter the family password to continue.`;
2671  }
2672  const toggle = root.querySelector("[data-icon='visibility']");
2673  if (toggle) {
2674    toggle.dataset.action = "toggle-password";
2675  }
2676  const navLinks = root.querySelectorAll("nav a");
2677  navLinks.forEach((link) => {
2678    link.removeAttribute("href");
2679    link.classList.add("genealogy-muted-link");
2680  });
2681  root.querySelectorAll("footer a").forEach((link) => {
2682    link.removeAttribute("href");
2683    link.classList.add("genealogy-muted-link");
2684  });
2685}
2686
2687function unlockHelperElement() {
2688  return root.querySelector("p.text-secondary.text-sm") || root.querySelector("p.text-secondary");
2689}
2690
2691function setUnlockHelperMessage(message) {
2692  const helper = unlockHelperElement();
2693  if (helper) {
2694    helper.textContent = message;
2695  }
2696}
2697
2698function resetTreeViewportState() {
2699  state.treeViewport.fitPending = false;
2700  state.treeViewport.autoFocusPending = true;
2701  state.treeViewport.restorePending = false;
2702  state.treeViewport.focusPeopleIds = [];
2703}
2704
2705function renderUnlockRecoveryView(error) {
2706  const personCount = state.data?.summary?.personCount || state.encryptedPayload?.summary?.personCount || 0;
2707  const eventCount = state.data?.summary?.eventCount || state.encryptedPayload?.summary?.eventCount || 0;
2708  const familyCount = state.data?.summary?.familyCount
2709    || state.encryptedPayload?.summary?.familyCount
2710    || state.data?.summary?.branchCount
2711    || state.encryptedPayload?.summary?.branchCount
2712    || 0;
2713
2714  root.innerHTML = `
2715    <div class="genealogy-template-root">
2716      <section class="mx-auto max-w-3xl px-6 py-16 sm:px-10">
2717        <div class="rounded-[2rem] border border-outline-variant/20 bg-white/96 p-7 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.32)] sm:p-10">
2718          <p class="text-[10px] font-black uppercase tracking-[0.3em] text-primary/80">Archive Recovery</p>
2719          <h1 class="mt-4 font-headline text-4xl font-black leading-[0.98] text-on-surface">Archive unlocked, but the family tree view could not finish rendering.</h1>
2720          <p class="mt-4 text-sm leading-7 text-on-surface-variant">
2721            The family archive itself unlocked successfully. You can keep browsing the people directory or timeline while the tree view is recovered.
2722          </p>
2723          <div class="mt-6 flex flex-wrap gap-3 text-[11px] uppercase tracking-[0.16em] text-outline">
2724            <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(String(personCount))} people</span>
2725            <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(String(eventCount))} events</span>
2726            <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(String(familyCount))} families</span>
2727          </div>
2728          <div class="mt-8 flex flex-wrap gap-3">
2729            <button class="genealogy-directory-open" type="button" data-view="people">Open people directory</button>
2730            <button class="genealogy-record-link" type="button" data-view="timeline">Open timeline</button>
2731            <button class="genealogy-record-link is-secondary" type="button" data-view="tree">Retry family tree</button>
2732          </div>
2733          <p class="mt-6 text-xs leading-6 text-outline">A rendering issue was caught after decrypting the archive. Details are logged in the browser console for debugging.</p>
2734        </div>
2735      </section>
2736    </div>
2737  `;
2738  applyShellBranding();
2739  console.error("Genealogy archive unlocked but the primary tree render failed.", error);
2740}
2741
2742function renderEnhancedTreeView() {
2743  const mobile = window.matchMedia("(max-width: 767px)").matches;
2744  const matches = state.graph.people.filter(matchesSearch).length;
2745  renderPrimaryShell({
2746    view: "tree",
2747    eyebrow: "Lineage Visualization",
2748    titleHtml: `The <span class="italic text-primary">Family Tree</span>`,
2749    summaryText: treeSummaryText(matches),
2750    heroAsideMarkup: renderArchiveSnapshotCard(),
2751    contentMarkup: `
2752      <section class="space-y-6">
2753        ${renderTreeWorkspace(mobile)}
2754      </section>
2755    `
2756  });
2757
2758  const search = root.querySelector("input[placeholder='Search lineage...']");
2759  if (search) {
2760    search.value = state.searchDisplay || state.searchQuery;
2761    search.dataset.role = "search";
2762  }
2763  bindSearchInput();
2764
2765  bindTreeInteractions();
2766  bindTreeViewport();
2767  if (state.treeViewport.fitPending) {
2768    fitTreeToViewport();
2769    state.treeViewport.fitPending = false;
2770  } else if (state.treeViewport.focusPeopleIds.length) {
2771    focusTreeOnPeople(state.treeViewport.focusPeopleIds, { minScale: 0.55 });
2772    state.treeViewport.focusPeopleIds = [];
2773  } else if (state.treeViewport.restorePending) {
2774    restoreTreeViewport();
2775    state.treeViewport.restorePending = false;
2776  } else if (state.treeViewport.autoFocusPending) {
2777    focusTreeOnSelectedPerson();
2778    state.treeViewport.autoFocusPending = false;
2779  }
2780  clearTreeHighlight();
2781  updateTreeSearchUI();
2782}
2783
2784function renderTreeView() {
2785  try {
2786    return renderEnhancedTreeView();
2787  } catch (error) {
2788    renderUnlockRecoveryView(error);
2789    return;
2790  }
2791
2792  const mobile = window.matchMedia("(max-width: 767px)").matches;
2793  root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "treeMobile" : "treeDesktop"]}</div>`;
2794  markNav(root, "tree");
2795  const person = selectedPerson();
2796  const matches = state.graph.people.filter(matchesSearch).length;
2797  if (mobile) {
2798    root.querySelector("h2.noto-serif")?.replaceChildren(document.createTextNode(`${person.family || "Family"} Branch`));
2799    root.querySelector("p.text-secondary.mt-2")?.replaceChildren(document.createTextNode(`Viewing the full family map with ${state.graph.people.length} archived people. ${matches} currently match your search.`));
2800    const container = root.querySelector(".relative.flex.flex-col.gap-16");
2801    if (container) {
2802      container.innerHTML = `
2803        <div class="bg-surface-container-lowest rounded-xl p-6 shadow-sm border border-outline-variant/10">
2804          <div class="flex items-start gap-4">
2805            <div class="w-20 h-20 rounded-xl overflow-hidden bg-surface-container">${photoMarkup(person, "w-full h-full object-cover")}</div>
2806            <div>
2807              <h3 class="noto-serif text-2xl font-bold leading-tight">${escapeHtml(person.displayName)}</h3>
2808              <p class="text-xs uppercase tracking-widest text-secondary mt-1">${escapeHtml(formatLifeSpan(person))}</p>
2809              <p class="text-sm text-secondary mt-3">${escapeHtml(truncate(person.summary || "No biography summary recorded yet.", 140))}</p>
2810            </div>
2811          </div>
2812        </div>
2813        <div class="genealogy-mobile-board">${renderTreeBoard()}</div>
2814      `;
2815    }
2816  } else {
2817    root.querySelector("h1.font-headline")?.replaceChildren();
2818    const hero = root.querySelector("h1.font-headline");
2819    if (hero) {
2820      hero.innerHTML = `The <br><span class="italic text-primary">Family Tree</span>`;
2821    }
2822    const count = root.querySelector("p.text-on-surface-variant.font-medium.text-sm");
2823    if (count) {
2824      count.textContent = `${state.graph.rows.length} generations · ${state.graph.people.length} archived people · ${matches} matches`;
2825    }
2826    const labels = root.querySelector(".absolute.-left-12");
2827    if (labels) {
2828      labels.innerHTML = state.graph.rows.map((row) => {
2829        const first = state.graph.peopleById.get(row.ids[0]);
2830        return `<div class="rotate-180 [writing-mode:vertical-lr] font-label text-[10px] font-bold tracking-[0.3em] uppercase">Gen ${row.generation + 1} · ${escapeHtml(getBirthYear(first) || "Unknown")}</div>`;
2831      }).join("");
2832    }
2833    const boardContainer = root.querySelector(".flex.flex-col.gap-24.items-center.overflow-x-auto.pb-12");
2834    if (boardContainer) {
2835      boardContainer.outerHTML = renderTreeBoard();
2836    }
2837    const statCards = root.querySelectorAll(".fixed.right-8.bottom-32 .grid.grid-cols-2 > div p:last-child");
2838    if (statCards[0]) {
2839      statCards[0].textContent = String(state.data.summary?.openQuestionCount || 0);
2840    }
2841    if (statCards[1]) {
2842      statCards[1].textContent = String(state.data.summary?.unresolvedCount || 0);
2843    }
2844  }
2845  const search = root.querySelector("input[placeholder='Search lineage...']");
2846  if (search) {
2847    search.value = state.searchQuery;
2848    search.dataset.role = "search";
2849  }
2850}
2851
2852function renderEnhancedProfileViewLegacy() {
2853  state.renderedTree = null;
2854  const mobile = window.matchMedia("(max-width: 767px)").matches;
2855  root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "profileMobile" : "profileDesktop"]}</div>`;
2856  markNav(root, "profile");
2857  const person = selectedPerson();
2858  const personEvents = [...(state.graph.eventMap.get(person.id) || [])]
2859    .sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right)))
2860    .slice(0, 8);
2861  const sourceNotes = (person.sources || []).slice(0, 6);
2862  const attachments = (person.attachments || []).filter(
2863    (attachment) => attachment.fileUrl || attachment.previewUrl || (attachment.sourceNoteUrls || []).length
2864  );
2865  const discrepancies = (person.discrepancies || []).slice(0, 4);
2866  const relationshipFacts = [
2867    { field: "Parents", value: relationshipNames(person.directParents || [], state.graph.peopleById, "Not linked yet") },
2868    { field: "Spouses / Partners", value: relationshipNames(person.directSpouses || [], state.graph.peopleById, "No partner linked") },
2869    { field: "Children", value: relationshipNames(person.directChildren || [], state.graph.peopleById, "No children linked") },
2870    { field: "Birthplace", value: person.life?.birth?.place || "Not yet documented" },
2871    { field: "Death / Current place", value: person.life?.death?.place || "Not yet documented" }
2872  ];
2873  const vitals = [
2874    ...relationshipFacts,
2875    ...(person.vitals || []).filter((item) => item.value && !["born", "birthplace", "died", "death place"].includes(String(item.field || "").toLowerCase()))
2876  ].filter((item) => item.value).slice(0, 8);
2877  const biographyText = person.summary || sourceNotes[0] || "A life reconstructed from family memory, linked records, and current genealogy research.";
2878
2879  root.querySelectorAll("h1, h2").forEach((heading) => {
2880    if (heading.textContent.includes("Arthur") || heading.textContent.includes("Sterling") || heading.textContent.includes("Sinclair")) {
2881      heading.textContent = person.displayName;
2882    }
2883  });
2884
2885  const subtitle = root.querySelector("p.font-label, p.text-primary.font-bold, p.font-label.text-sm");
2886  if (subtitle) {
2887    subtitle.textContent = formatLifeSpan(person);
2888  }
2889
2890  const biography = root.querySelector(".font-headline.italic.text-2xl, .text-lg.leading-relaxed.font-headline.italic");
2891  if (biography) {
2892    biography.textContent = truncate(biographyText, 220);
2893    biography.classList.add("genealogy-profile-quote");
2894  }
2895
2896  const biographyBody = root.querySelector("p.text-base.text-secondary.font-manrope, p.text-base.text-secondary");
2897  if (biographyBody) {
2898    biographyBody.textContent = truncate(
2899      sourceNotes[0]
2900      || attachments[0]?.summary
2901      || attachments[0]?.relevance
2902      || person.life?.birth?.place
2903      || "This profile is built from linked family notes, records, and evidence in the vault.",
2904      220
2905    );
2906  }
2907
2908  root.querySelectorAll("img[data-alt]").forEach((image) => {
2909    image.outerHTML = photoMarkup(person, image.className || "w-full h-full object-cover");
2910  });
2911
2912  const historyStack = root.querySelector(".space-y-10, .flex.flex-col.gap-4.overflow-x-auto.pb-4.snap-x");
2913  if (historyStack && vitals.length) {
2914    historyStack.innerHTML = vitals.map((item, index) => `
2915      <div class="${mobile ? "min-w-[85vw] snap-center bg-surface-container-lowest p-6 rounded-xl shadow-sm border border-outline-variant/10" : "flex gap-8 group"}">
2916        ${mobile ? "" : `<div class="text-primary font-bold pt-1">${escapeHtml(String(index + 1).padStart(2, "0"))}</div>`}
2917        <div>
2918          <h3 class="${mobile ? "text-xl font-headline mb-2" : "font-bold text-xl mb-1 group-hover:text-primary transition-colors"}">${escapeHtml(item.field)}</h3>
2919          <p class="${mobile ? "text-sm text-secondary manrope" : "text-on-surface-variant"}">${escapeHtml(item.value)}</p>
2920          ${item.source ? `<p class="text-sm mt-2 opacity-70">${escapeHtml(truncate(item.source, 140))}</p>` : ""}
2921        </div>
2922      </div>
2923    `).join("");
2924  }
2925
2926  const triviaList = root.querySelector("ul.space-y-8");
2927  if (triviaList) {
2928    triviaList.innerHTML = (discrepancies.length ? discrepancies : vitals.slice(0, 3)).map((item) => `
2929      <li class="flex gap-4">
2930        <span class="material-symbols-outlined text-primary">${discrepancies.length ? "auto_awesome" : "info"}</span>
2931        <span class="text-surface-variant">${escapeHtml(truncate(item.resolution || item.value || item.field, 120))}</span>
2932      </li>
2933    `).join("");
2934  }
2935
2936  const mediaGrid = root.querySelector(".grid.grid-cols-1.md\\:grid-cols-2.lg\\:grid-cols-4.gap-6, .grid.grid-cols-1.gap-4");
2937  if (mediaGrid) {
2938    mediaGrid.innerHTML = attachments.map((attachment) => renderAttachmentCard(attachment, { variant: "profile", compact: true })).join("")
2939      || `<p class="text-secondary">No linked records or photos are available for this person yet.</p>`;
2940  }
2941
2942  const timeline = root.querySelector(".space-y-24, .space-y-4");
2943  if (timeline && personEvents.length) {
2944    timeline.innerHTML = personEvents.map((event, index) => `
2945      <div class="flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 relative">
2946        <div class="md:w-1/2 ${index % 2 ? "md:text-left" : "md:text-right"}">
2947          <span class="text-xs font-bold tracking-widest text-primary">${escapeHtml(eventDateLabel(event))}</span>
2948          <h4 class="font-headline text-2xl font-bold mt-2">${escapeHtml(eventTitleText(event))}</h4>
2949          <p class="text-on-surface-variant mt-2 max-w-sm ${index % 2 ? "" : "ml-auto"}">${escapeHtml(eventSummaryText(event))}</p>
2950          <p class="text-[11px] uppercase tracking-[0.18em] text-outline mt-3 ${index % 2 ? "" : "ml-auto"}">${escapeHtml(eventParticipantsLabel(event, state.graph.peopleById))}${event.placeLabel ? ` • ${escapeHtml(event.placeLabel)}` : ""}</p>
2951        </div>
2952        <div class="w-4 h-4 rounded-full bg-primary ring-8 ring-surface z-10 shrink-0"></div>
2953        <div class="md:w-1/2 ${index % 2 ? "flex justify-end" : ""}">
2954          <div class="w-32 h-20 bg-surface-container-low rounded-lg flex items-center justify-center text-primary">
2955            <span class="material-symbols-outlined">${escapeHtml(eventIcon(event.kind || event.event))}</span>
2956          </div>
2957        </div>
2958      </div>
2959    `).join("");
2960  }
2961
2962  if (!personEvents.length && timeline) {
2963    timeline.innerHTML = `
2964      <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/10">
2965        <h4 class="font-headline text-2xl font-bold mb-3">Timeline still in progress</h4>
2966        <p class="text-on-surface-variant">${escapeHtml(`No named life events are directly linked to ${person.displayName} yet. Use the family tree and linked records to continue tracing this branch.`)}</p>
2967      </div>
2968    `;
2969  }
2970
2971  disablePlannedExportButtons();
2972}
2973
2974function renderProfileView() {
2975  return renderEnhancedProfileView();
2976
2977  const mobile = window.matchMedia("(max-width: 767px)").matches;
2978  root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "profileMobile" : "profileDesktop"]}</div>`;
2979  markNav(root, "profile");
2980  const person = selectedPerson();
2981  const personEvents = [...(state.graph.eventMap.get(person.id) || [])].sort((left, right) => String(left.dateSort || "").localeCompare(String(right.dateSort || ""))).slice(0, 6);
2982  const vitals = (person.vitals || []).filter((item) => item.value).slice(0, 4);
2983  const discrepancies = (person.discrepancies || []).slice(0, 3);
2984  const sourceCards = (person.sources || []).slice(0, 4);
2985  const biographyText = person.summary || person.sources?.[0] || "A life reconstructed from family memory, documents, and working genealogy research.";
2986  root.querySelectorAll("h1, h2").forEach((heading) => {
2987    if (heading.textContent.includes("Arthur") || heading.textContent.includes("Sterling") || heading.textContent.includes("Sinclair")) {
2988      heading.textContent = person.displayName;
2989    }
2990  });
2991  const subtitle = root.querySelector("p.font-label, p.text-primary.font-bold, p.font-label.text-sm");
2992  if (subtitle) {
2993    subtitle.textContent = formatLifeSpan(person);
2994  }
2995  const biography = root.querySelector(".font-headline.italic.text-2xl, .text-lg.leading-relaxed.font-headline.italic");
2996  if (biography) {
2997    biography.textContent = truncate(biographyText, 220);
2998    biography.classList.add("genealogy-profile-quote");
2999  }
3000  const biographyBody = root.querySelector("p.text-base.text-secondary.font-manrope, p.text-base.text-secondary");
3001  if (biographyBody) {
3002    biographyBody.textContent = truncate(person.sources?.[0] || person.life?.birth?.place || "This archive entry is currently built from the best linked genealogy evidence in the vault.", 220);
3003  }
3004  root.querySelectorAll("img[data-alt]").forEach((image) => {
3005    image.outerHTML = photoMarkup(person, image.className || "w-full h-full object-cover");
3006  });
3007  const historyStack = root.querySelector(".space-y-10, .flex.flex-col.gap-4.overflow-x-auto.pb-4.snap-x");
3008  if (historyStack && vitals.length) {
3009    historyStack.innerHTML = vitals.map((item, index) => `
3010      <div class="${mobile ? "min-w-[85vw] snap-center bg-surface-container-lowest p-6 rounded-xl shadow-sm border border-outline-variant/10" : "flex gap-8 group"}">
3011        ${mobile ? "" : `<div class="text-primary font-bold pt-1">${escapeHtml(String(index + 1).padStart(2, "0"))}</div>`}
3012        <div>
3013          <h3 class="${mobile ? "text-xl font-headline mb-2" : "font-bold text-xl mb-1 group-hover:text-primary transition-colors"}">${escapeHtml(item.field)}</h3>
3014          <p class="${mobile ? "text-sm text-secondary manrope" : "text-on-surface-variant"}">${escapeHtml(item.value)}</p>
3015          ${item.source ? `<p class="text-sm mt-2 opacity-70">${escapeHtml(truncate(item.source, 140))}</p>` : ""}
3016        </div>
3017      </div>
3018    `).join("");
3019  }
3020  const triviaList = root.querySelector("ul.space-y-8");
3021  if (triviaList) {
3022    triviaList.innerHTML = (discrepancies.length ? discrepancies : vitals.slice(0, 3)).map((item) => `
3023      <li class="flex gap-4">
3024        <span class="material-symbols-outlined text-primary">${discrepancies.length ? "auto_awesome" : "info"}</span>
3025        <span class="text-surface-variant">${escapeHtml(truncate(item.resolution || item.value || item.field, 120))}</span>
3026      </li>
3027    `).join("");
3028  }
3029  const mediaGrid = root.querySelector(".grid.grid-cols-1.md\\:grid-cols-2.lg\\:grid-cols-4.gap-6, .grid.grid-cols-1.gap-4");
3030  if (mediaGrid) {
3031    mediaGrid.innerHTML = sourceCards.map((source, index) => `
3032      <div class="group glass-card rounded-xl p-6 transition-all duration-300 hover:-translate-y-2 hover:shadow-xl ring-1 ring-outline-variant/10">
3033        <span class="material-symbols-outlined text-primary mb-6 text-4xl">${index % 2 === 0 ? "description" : "link"}</span>
3034        <h5 class="font-bold text-lg mb-2 group-hover:text-primary">${escapeHtml(index === 0 ? "Primary Source" : `Source ${index + 1}`)}</h5>
3035        <p class="text-sm text-on-surface-variant">${escapeHtml(truncate(source, 120))}</p>
3036      </div>
3037    `).join("") || `<p class="text-secondary">No source cards are available for this person yet.</p>`;
3038  }
3039  const timeline = root.querySelector(".space-y-24, .space-y-4");
3040  if (timeline && personEvents.length) {
3041    timeline.innerHTML = personEvents.map((event, index) => `
3042      <div class="flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 relative">
3043        <div class="md:w-1/2 ${index % 2 ? "md:text-left" : "md:text-right"}">
3044          <span class="text-xs font-bold tracking-widest text-primary">${escapeHtml(event.dateLabel || event.dateSort || "Undated")}</span>
3045          <h4 class="font-headline text-2xl font-bold mt-2">${escapeHtml(event.title || event.category || "Family event")}</h4>
3046          <p class="text-on-surface-variant mt-2 max-w-sm ${index % 2 ? "" : "ml-auto"}">${escapeHtml(truncate(event.summary || event.description || event.title || "Recorded family event.", 140))}</p>
3047        </div>
3048        <div class="w-4 h-4 rounded-full bg-primary ring-8 ring-surface z-10 shrink-0"></div>
3049        <div class="md:w-1/2 ${index % 2 ? "flex justify-end" : ""}">
3050          <div class="w-32 h-20 bg-surface-container-low rounded-lg flex items-center justify-center text-primary">
3051            <span class="material-symbols-outlined">event</span>
3052          </div>
3053        </div>
3054      </div>
3055    `).join("");
3056  }
3057  if (!personEvents.length && timeline) {
3058    timeline.innerHTML = `
3059      <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/10">
3060        <h4 class="font-headline text-2xl font-bold mb-3">Timeline still in progress</h4>
3061        <p class="text-on-surface-variant">${escapeHtml(`No timeline events are directly linked to ${person.displayName} yet. Use the family tree and source cards to continue tracing this branch.`)}</p>
3062      </div>
3063    `;
3064  }
3065}
3066
3067function renderEnhancedTimelineViewLegacy() {
3068  state.renderedTree = null;
3069  root.innerHTML = `<div class="genealogy-template-root">${state.templates.timelineDesktop}</div>`;
3070  markNav(root, "timeline");
3071  const events = [...(state.data.events || [])].sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right)));
3072  const title = root.querySelector("h1.font-headline");
3073  if (title) {
3074    title.innerHTML = `The Chronology of <br><span class="italic text-primary">Your Family.</span>`;
3075  }
3076  const intro = root.querySelector("section.max-w-5xl p.text-xl");
3077  if (intro) {
3078    intro.textContent = `A chronological view of ${events.length} named family events, linked people, places, and supporting records across the family archive.`;
3079  }
3080  const container = root.querySelector(".space-y-32");
3081  if (container) {
3082    container.innerHTML = events.map((event, index) => {
3083      const participants = eventParticipantsLabel(event, state.graph.peopleById);
3084      const dateLabel = eventDateLabel(event);
3085      const yearLabel = String(event?.date?.sortValue || "").slice(0, 4) || dateLabel;
3086      return `
3087        <div class="relative flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 md:gap-0">
3088          <div class="md:w-1/2 ${index % 2 ? "md:pl-16 text-left" : "md:pr-16 text-right"} hidden md:block">
3089            <h3 class="text-4xl font-headline font-bold text-on-surface">${escapeHtml(eventTitleText(event))}</h3>
3090            <p class="text-primary font-bold mt-2">${escapeHtml(event.placeLabel || participants)}</p>
3091            <p class="text-xs uppercase tracking-[0.18em] text-outline mt-4">${escapeHtml(dateLabel)}</p>
3092          </div>
3093          <div class="z-10 w-4 h-4 rounded-full bg-primary ring-8 ring-white shadow-xl shadow-primary/20"></div>
3094          <div class="md:w-1/2 ${index % 2 ? "md:pr-16" : "md:pl-16"}">
3095            <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/5">
3096              <div class="flex items-center justify-between mb-4">
3097                <span class="text-4xl font-headline font-black text-outline-variant/20">${escapeHtml(yearLabel || "Date")}</span>
3098                <span class="material-symbols-outlined text-primary">${escapeHtml(eventIcon(event.kind || event.event))}</span>
3099              </div>
3100              <h3 class="text-2xl font-headline font-bold mb-4 md:hidden">${escapeHtml(eventTitleText(event))}</h3>
3101              <p class="text-on-surface font-body leading-relaxed">${escapeHtml(eventSummaryText(event))}</p>
3102              <div class="mt-6 flex flex-wrap gap-3 text-[11px] uppercase tracking-[0.16em] text-outline">
3103                <span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(dateLabel)}</span>
3104                <span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(participants)}</span>
3105                ${event.placeLabel ? `<span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(event.placeLabel)}</span>` : ""}
3106                ${eventSourceLabel(event) ? `<span class="px-3 py-1 rounded-full bg-surface-container-low">${escapeHtml(truncate(eventSourceLabel(event), 54))}</span>` : ""}
3107              </div>
3108            </div>
3109          </div>
3110        </div>
3111      `;
3112    }).join("");
3113  }
3114
3115  disablePlannedExportButtons();
3116}
3117
3118function renderTimelineView() {
3119  return renderEnhancedTimelineView();
3120
3121  root.innerHTML = `<div class="genealogy-template-root">${state.templates.timelineDesktop}</div>`;
3122  markNav(root, "timeline");
3123  const events = [...(state.data.events || [])].sort((left, right) => String(left.dateSort || "").localeCompare(String(right.dateSort || ""))).slice(0, 8);
3124  const title = root.querySelector("h1.font-headline");
3125  if (title) {
3126    title.innerHTML = `The Chronology of <br><span class="italic text-primary">Your Family.</span>`;
3127  }
3128  const container = root.querySelector(".space-y-32");
3129  if (container) {
3130    container.innerHTML = events.map((event, index) => `
3131      <div class="relative flex flex-col ${index % 2 ? "md:flex-row-reverse" : "md:flex-row"} items-center gap-8 md:gap-0">
3132        <div class="md:w-1/2 ${index % 2 ? "md:pl-16 text-left" : "md:pr-16 text-right"} hidden md:block">
3133          <h3 class="text-4xl font-headline font-bold text-on-surface">${escapeHtml(event.title || event.category || "Family Event")}</h3>
3134          <p class="text-primary font-bold mt-2">${escapeHtml(event.location || "Recorded in the archive")}</p>
3135        </div>
3136        <div class="z-10 w-4 h-4 rounded-full bg-primary ring-8 ring-white shadow-xl shadow-primary/20"></div>
3137        <div class="md:w-1/2 ${index % 2 ? "md:pr-16" : "md:pl-16"}">
3138          <div class="bg-surface-container-lowest p-8 rounded-xl shadow-sm border border-outline-variant/5">
3139            <div class="flex items-center justify-between mb-4">
3140              <span class="text-4xl font-headline font-black text-outline-variant/20">${escapeHtml(String(event.dateSort || "").slice(0, 4) || "Date")}</span>
3141              <span class="material-symbols-outlined text-primary">history_edu</span>
3142            </div>
3143            <h3 class="text-2xl font-headline font-bold mb-4 md:hidden">${escapeHtml(event.title || event.category || "Family Event")}</h3>
3144            <p class="text-on-surface font-body leading-relaxed">${escapeHtml(truncate(event.summary || event.description || event.title || "Recorded archive event.", 170))}</p>
3145          </div>
3146        </div>
3147      </div>
3148    `).join("");
3149  }
3150}
3151
3152function filteredPeopleForDirectory() {
3153  return [...(state.graph?.people || [])]
3154    .filter(matchesActiveFilters)
3155    .sort(personSort);
3156}
3157
3158function attachmentMatchesSearch(attachment) {
3159  const query = normalizeSearchText(state.searchQuery);
3160  if (!query) {
3161    return true;
3162  }
3163  const haystack = normalizeSearchText([
3164    attachmentTitle(attachment),
3165    attachment?.summary || "",
3166    attachment?.relevance || "",
3167    attachment?.transcript || "",
3168    attachment?.transcriptDisclaimer || "",
3169    attachment?.transcriptSourceTitle || "",
3170    attachment?.personName || "",
3171    ...(attachment?.personNames || []),
3172    ...attachmentEvidenceFiles(attachment).map((file) => file.title),
3173    ...(attachment?.sourceNoteTitles || [])
3174  ].join(" "));
3175  return haystack.includes(query);
3176}
3177
3178function filteredAttachmentsForDirectory() {
3179  return dedupeAttachmentRecords(collectAllAttachments()
3180    .filter((attachment) => {
3181      if (state.activeFamilyFilter && attachment.family !== state.activeFamilyFilter) {
3182        return false;
3183      }
3184      return attachmentMatchesSearch(attachment);
3185    })
3186    .sort((left, right) => {
3187      const leftDate = left?.date?.sortValue || "";
3188      const rightDate = right?.date?.sortValue || "";
3189      return rightDate.localeCompare(leftDate) || String(left.personName || "").localeCompare(String(right.personName || ""), "en-GB");
3190    }));
3191}
3192
3193function renderCollectionShellLegacy(view, titleHtml, summaryText, contentMarkup, mobileIntroMarkup = "") {
3194  const mobile = window.matchMedia("(max-width: 767px)").matches;
3195  root.innerHTML = `<div class="genealogy-template-root">${state.templates[mobile ? "treeMobile" : "treeDesktop"]}</div>`;
3196  markNav(root, view);
3197
3198  if (mobile) {
3199    root.querySelector("h2.noto-serif")?.replaceChildren(document.createTextNode(titleHtml.replace(/<[^>]+>/g, "")));
3200    root.querySelector("p.text-secondary.mt-2")?.replaceChildren(document.createTextNode(summaryText));
3201    const container = root.querySelector(".relative.flex.flex-col.gap-16");
3202    if (container) {
3203      container.innerHTML = `
3204        ${mobileIntroMarkup}
3205        <div class="genealogy-hero-filters">${renderHeroFamilyFilters(4)}</div>
3206        ${contentMarkup}
3207      `;
3208    }
3209  } else {
3210    const headerWrap = root.querySelector("header > div.max-w-screen-2xl");
3211    if (headerWrap) {
3212      headerWrap.classList.remove("max-w-screen-2xl", "mx-auto");
3213      headerWrap.classList.add("max-w-none");
3214    }
3215    const sidebar = root.querySelector("aside");
3216    if (sidebar) {
3217      sidebar.style.width = "16rem";
3218    }
3219    const main = root.querySelector("main");
3220    if (main) {
3221      main.classList.remove("max-w-screen-2xl", "mx-auto", "lg:px-16");
3222      main.classList.add("max-w-none", "w-full", "px-4", "md:px-6", "lg:px-8", "2xl:px-10");
3223      main.style.maxWidth = "none";
3224      main.style.width = "auto";
3225      main.style.marginLeft = "16rem";
3226      main.style.paddingTop = "1.75rem";
3227      main.style.paddingBottom = "4rem";
3228    }
3229    const heroBlock = root.querySelector("main > .mb-20");
3230    if (heroBlock) {
3231      heroBlock.classList.remove("mb-20");
3232      heroBlock.style.maxWidth = "none";
3233      heroBlock.style.marginBottom = "2.5rem";
3234    }
3235    const hero = root.querySelector("h1.font-headline");
3236    if (hero) {
3237      hero.innerHTML = titleHtml;
3238    }
3239    const count = root.querySelector("p.text-on-surface-variant.font-medium.text-sm");
3240    if (count) {
3241      count.textContent = summaryText;
3242      count.dataset.searchSummary = view;
3243    }
3244    const heroFilterStrip = root.querySelector(".flex.-space-x-3");
3245    if (heroFilterStrip) {
3246      heroFilterStrip.outerHTML = `<div class="genealogy-hero-filters">${renderHeroFamilyFilters(4)}</div>`;
3247    }
3248    root.querySelector(".absolute.-left-12")?.remove();
3249    const boardContainer = root.querySelector(".flex.flex-col.gap-24.items-center.overflow-x-auto.pb-12");
3250    if (boardContainer) {
3251      boardContainer.outerHTML = contentMarkup;
3252    }
3253    const statsPanel = root.querySelector(".fixed.right-8.bottom-32.w-80");
3254    if (statsPanel) {
3255      statsPanel.remove();
3256    }
3257  }
3258
3259  const search = root.querySelector("input[placeholder='Search lineage...']");
3260  if (search) {
3261    search.value = state.searchDisplay || state.searchQuery;
3262    search.dataset.role = "search";
3263  }
3264  bindSearchInput();
3265  disablePlannedExportButtons();
3266}
3267
3268function renderPeopleView() {
3269  state.renderedTree = null;
3270  const people = filteredPeopleForDirectory();
3271  const contentMarkup = `
3272    <section class="genealogy-directory-grid">
3273      ${people.map((person) => `
3274        <article class="genealogy-directory-card" style="--family-color:${escapeHtml(person.familyColor || "#0041c8")}">
3275          <div class="genealogy-directory-card-top">
3276            <div class="genealogy-directory-photo">${photoMarkup(person, "w-full h-full object-cover")}</div>
3277            <span class="genealogy-family-chip">${escapeHtml(familyLabel(person))}</span>
3278          </div>
3279          <h3>${escapeHtml(person.displayName)}</h3>
3280          <p class="genealogy-directory-life">${escapeHtml(formatLifeSpan(person))}</p>
3281          <p class="genealogy-directory-summary">${escapeHtml(truncate(person.summary || "No narrative summary recorded yet.", 160))}</p>
3282          <div class="genealogy-directory-facts">
3283            <span>${escapeHtml(relationshipNames(person.directParents || [], state.graph.peopleById, "Parents not linked"))}</span>
3284            <span>${escapeHtml(pluralize(personRecordCount(person), "record"))}</span>
3285          </div>
3286          <button class="genealogy-directory-open" type="button" data-person-id="${escapeHtml(person.id)}" data-open-profile="true">Open profile</button>
3287        </article>
3288      `).join("")}
3289    </section>
3290  `;
3291  const mobileIntro = `
3292    <div class="bg-surface-container-lowest rounded-xl p-6 shadow-sm border border-outline-variant/10">
3293      <h3 class="noto-serif text-2xl font-bold leading-tight">People in the family archive</h3>
3294      <p class="text-sm text-secondary mt-3">${escapeHtml(`Browse ${people.length} people, their linked records, and their place in the family story.`)}</p>
3295    </div>
3296  `;
3297  renderCollectionShell(
3298    "people",
3299    `The People in <br><span class="italic text-primary">Your Family.</span>`,
3300    `${people.length} people currently match your family and search filters.`,
3301    contentMarkup,
3302    mobileIntro
3303  );
3304}
3305
3306function renderSourcesView() {
3307  state.renderedTree = null;
3308  const attachments = filteredAttachmentsForDirectory();
3309  const contentMarkup = `
3310    <section class="genealogy-sources-grid">
3311      ${attachments.map((attachment) => renderAttachmentCard(attachment, { variant: "source" })).join("") || `<div class="genealogy-empty-state">No linked record previews match the current filters yet.</div>`}
3312    </section>
3313  `;
3314  const mobileIntro = `
3315    <div class="bg-surface-container-lowest rounded-xl p-6 shadow-sm border border-outline-variant/10">
3316      <h3 class="noto-serif text-2xl font-bold leading-tight">Sources and records</h3>
3317      <p class="text-sm text-secondary mt-3">${escapeHtml(`Browse linked wills, certificates, notes, and document previews across the family archive.`)}</p>
3318    </div>
3319  `;
3320  renderCollectionShell(
3321    "sources",
3322    `The Sources Behind <br><span class="italic text-primary">Your Family.</span>`,
3323    `${attachments.length} linked records currently match your family and search filters.`,
3324    contentMarkup,
3325    mobileIntro
3326  );
3327}
3328
3329function renderResearchViewLegacy() {
3330  state.renderedTree = null;
3331  root.innerHTML = `<div class="genealogy-template-root">${state.templates.researchDesktop}</div>`;
3332  markNav(root, "research");
3333  const speculativePeople = state.graph.people.filter((person) => person.status === "candidate" || person.status === "unresolved").slice(0, 8);
3334  const lead = speculativePeople[0] || selectedPerson();
3335  root.querySelector("h1.font-headline")?.replaceChildren(document.createTextNode("Unverified & Potential Connections"));
3336  const name = root.querySelector("h3.text-2xl.font-headline.font-bold");
3337  if (name) {
3338    name.textContent = lead.displayName;
3339  }
3340  const match = root.querySelector("span.px-3.py-1");
3341  if (match) {
3342    match.textContent = `${lead.confidence === "high" ? 92 : lead.confidence === "medium" ? 68 : 41}% Match`;
3343  }
3344  const cards = root.querySelector(".grid.grid-cols-1.sm\\:grid-cols-2.lg\\:grid-cols-4.gap-6");
3345  if (cards) {
3346    cards.innerHTML = speculativePeople.map((person) => `
3347      <div class="bg-surface-container-lowest p-6 rounded-xl hover:-translate-y-2 transition-transform duration-300 border-b-4 border-transparent hover:border-primary">
3348        <div class="flex justify-between items-start mb-4">
3349          <div class="w-10 h-10 bg-zinc-100 rounded-full flex items-center justify-center text-zinc-400">${photoMarkup(person, "w-full h-full object-cover rounded-full")}</div>
3350          <span class="text-[10px] font-bold text-zinc-400">${escapeHtml(person.confidence || "low")} confidence</span>
3351        </div>
3352        <h5 class="font-headline font-bold text-on-background">${escapeHtml(person.displayName)}</h5>
3353        <p class="text-[10px] text-zinc-500 mb-4 uppercase tracking-tighter">${escapeHtml(formatLifeSpan(person))}</p>
3354        <div class="flex gap-2">
3355          <button class="text-xs font-bold text-primary px-3 py-1 bg-primary/5 rounded" data-person-id="${escapeHtml(person.id)}" data-open-profile="true">Details</button>
3356        </div>
3357      </div>
3358    `).join("");
3359  }
3360
3361  root.querySelectorAll("h4").forEach((heading) => {
3362    const text = String(heading.textContent || "").trim();
3363    if (text === "Auto-Discovery") {
3364      const panel = heading.closest("div.bg-primary");
3365      if (panel) {
3366        panel.classList.add("genealogy-disabled-panel");
3367        panel.setAttribute("aria-disabled", "true");
3368      }
3369      heading.textContent = "Auto-Discovery Unavailable";
3370    }
3371  });
3372
3373  root.querySelectorAll("button").forEach((button) => {
3374    const text = String(button.textContent || "").replace(/\s+/g, " ").trim();
3375    if (/Launch Deep Scanner/i.test(text)) {
3376      button.classList.add("genealogy-disabled-button");
3377      button.disabled = true;
3378      button.setAttribute("aria-disabled", "true");
3379      button.title = "This scanner is not active yet.";
3380      const tooltip = button.querySelector("div");
3381      if (tooltip) {
3382        tooltip.textContent = "Scanner unavailable";
3383      }
3384    }
3385  });
3386
3387  root.querySelectorAll("p").forEach((paragraph) => {
3388    const text = String(paragraph.textContent || "").trim();
3389    if (text.includes("archivist is scanning")) {
3390      paragraph.textContent = "Automatic archive scanning is not active in this view yet.";
3391    }
3392    if (text.includes("% Complete")) {
3393      paragraph.textContent = "Inactive";
3394    }
3395  });
3396}
3397
3398function renderLegacy() {
3399  if (!state.graph) {
3400    renderLockedView();
3401    applyShellBranding();
3402    return;
3403  }
3404  if (state.activeView === "profile") {
3405    renderProfileView();
3406    applyShellBranding();
3407    return;
3408  }
3409  if (state.activeView === "timeline") {
3410    renderTimelineView();
3411    applyShellBranding();
3412    return;
3413  }
3414  if (state.activeView === "people") {
3415    renderPeopleView();
3416    applyShellBranding();
3417    return;
3418  }
3419  if (state.activeView === "sources") {
3420    renderSourcesView();
3421    applyShellBranding();
3422    return;
3423  }
3424  renderTreeView();
3425  applyShellBranding();
3426}
3427
3428function renderEnhancedProfileView() {
3429  state.renderedTree = null;
3430  const person = selectedPerson();
3431  const personEvents = [...(state.graph.eventMap.get(person.id) || [])]
3432    .sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right)))
3433    .slice(0, 8);
3434  const sourceNotes = (person.sources || []).slice(0, 6);
3435  const attachments = (person.attachments || []).filter(
3436    (attachment) => attachment.fileUrl || attachment.previewUrl || (attachment.sourceNoteUrls || []).length
3437  );
3438  const discrepancies = (person.discrepancies || []).slice(0, 4);
3439  const relationshipFacts = [
3440    { field: "Parents", value: relationshipNames(person.directParents || [], state.graph.peopleById, "Not linked yet") },
3441    { field: "Spouses / Partners", value: relationshipNames(person.directSpouses || [], state.graph.peopleById, "No partner linked") },
3442    { field: "Children", value: relationshipNames(person.directChildren || [], state.graph.peopleById, "No children linked") },
3443    { field: "Birthplace", value: person.life?.birth?.place || "Not yet documented" },
3444    { field: "Death / Current place", value: person.life?.death?.place || "Not yet documented" }
3445  ];
3446  const vitals = [
3447    ...relationshipFacts,
3448    ...(person.vitals || []).filter((item) => item.value && !["born", "birthplace", "died", "death place"].includes(String(item.field || "").toLowerCase()))
3449  ].filter((item) => item.value).slice(0, 8);
3450  const biographyText = person.summary || sourceNotes[0] || "A life reconstructed from family memory, linked records, and current genealogy research.";
3451  const summary = state.graph.summaryById.get(person.id) || { parents: [], children: [], spouses: [] };
3452  const attachmentMarkup = attachments.length
3453    ? attachments.map((attachment) => renderAttachmentCard(attachment, { variant: "profile", compact: true })).join("")
3454    : `<div class="genealogy-empty-state">No linked records or photos are available for this person yet.</div>`;
3455  const sourceMarkup = sourceNotes.length
3456    ? `<ul class="space-y-3">${sourceNotes.map((note) => `<li class="genealogy-profile-prose rounded-2xl bg-surface-container-low px-4 py-3 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(note, 220))}</li>`).join("")}</ul>`
3457    : `<div class="genealogy-empty-state">No source notes are linked to this person yet.</div>`;
3458  const discrepancyMarkup = discrepancies.length
3459    ? `
3460      <section class="rounded-[1.5rem] border border-outline-variant/20 bg-surface-container-low p-5">
3461        <h3 class="text-lg font-bold text-on-surface">Research notes and discrepancies</h3>
3462        <ul class="mt-4 space-y-3">
3463          ${discrepancies.map((item) => `
3464            <li class="flex gap-3 text-sm leading-6 text-on-surface-variant">
3465              <span class="material-symbols-outlined text-primary">rule</span>
3466              <span>${escapeHtml(item.resolution || item.value || item.field)}</span>
3467            </li>
3468          `).join("")}
3469        </ul>
3470      </section>
3471    `
3472    : "";
3473  const eventMarkup = personEvents.length
3474    ? personEvents.map((event) => `
3475        <article class="rounded-[1.5rem] border border-outline-variant/20 bg-white/96 p-5 shadow-[0_18px_42px_-34px_rgba(27,27,27,0.32)]">
3476          <div class="flex flex-wrap items-center justify-between gap-3">
3477            <div>
3478              <p class="text-[11px] font-black uppercase tracking-[0.18em] text-primary/80">${escapeHtml(eventDateLabel(event))}</p>
3479              <h3 class="mt-2 font-headline text-2xl font-bold text-on-surface">${escapeHtml(eventTitleText(event))}</h3>
3480            </div>
3481            <span class="inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
3482              <span class="material-symbols-outlined">${escapeHtml(eventIcon(event.kind || event.event))}</span>
3483            </span>
3484          </div>
3485          <p class="mt-4 text-sm leading-6 text-on-surface-variant">${escapeHtml(eventSummaryText(event))}</p>
3486          <p class="mt-4 text-[11px] uppercase tracking-[0.16em] text-outline">${escapeHtml(eventParticipantsLabel(event, state.graph.peopleById))}${event.placeLabel ? ` • ${escapeHtml(event.placeLabel)}` : ""}</p>
3487        </article>
3488      `).join("")
3489    : `<div class="genealogy-empty-state">No named life events are directly linked to ${escapeHtml(person.displayName)} yet.</div>`;
3490
3491  renderPrimaryShell({
3492    view: "profile",
3493    eyebrow: `${familyLabel(person)} Profile`,
3494    titleHtml: escapeHtml(person.displayName),
3495    summaryText: `${formatLifeSpan(person)} | ${pluralize(personEvents.length, "named event")} | ${pluralize(attachments.length, "linked record")}`,
3496    heroAsideMarkup: `
3497      <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]">
3498        <div class="flex items-start gap-4">
3499          <div class="h-24 w-24 overflow-hidden rounded-[1.4rem] bg-surface-container-low">${photoMarkup(person, "h-full w-full object-cover")}</div>
3500          <div class="min-w-0 flex-1">
3501            <span class="genealogy-family-chip">${escapeHtml(familyLabel(person))}</span>
3502            <p class="genealogy-profile-prose mt-3 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(biographyText, 180))}</p>
3503          </div>
3504        </div>
3505      </section>
3506    `,
3507    contentMarkup: `
3508      <section class="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
3509        <article class="min-w-0 rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.28)] md:p-8">
3510          <div class="flex flex-col gap-6 sm:flex-row sm:items-start">
3511            <div class="h-36 w-36 overflow-hidden rounded-[1.6rem] bg-surface-container-low shadow-sm">${photoMarkup(person, "h-full w-full object-cover")}</div>
3512            <div class="min-w-0 flex-1">
3513              <div class="flex flex-wrap gap-2">${renderPersonKeyFacts(person, summary)}</div>
3514              <p class="genealogy-profile-prose mt-4 text-lg leading-8 text-on-surface">${escapeHtml(truncate(biographyText, 260))}</p>
3515              <p class="genealogy-profile-prose mt-4 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(sourceNotes[0] || attachments[0]?.relevance || "This profile is built from linked family notes, records, and evidence in the vault.", 240))}</p>
3516            </div>
3517          </div>
3518          <div class="mt-8 grid gap-3 md:grid-cols-2">
3519            ${vitals.map((item) => `
3520              <div class="rounded-2xl border border-outline-variant/15 bg-surface-container-low p-4">
3521                <p class="text-[10px] font-black uppercase tracking-[0.18em] text-outline">${escapeHtml(item.field)}</p>
3522                <p class="mt-2 text-sm leading-6 text-on-surface">${escapeHtml(item.value)}</p>
3523              </div>
3524            `).join("") || `<div class="genealogy-empty-state md:col-span-2">No structured life facts are linked to this person yet.</div>`}
3525          </div>
3526          ${discrepancyMarkup}
3527        </article>
3528        <div class="min-w-0 space-y-6">
3529          <section class="min-w-0 rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)]">
3530            <div class="flex items-center justify-between gap-3">
3531              <div>
3532                <p class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/80">Evidence Gallery</p>
3533                <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">Photos, records, and documents</h2>
3534              </div>
3535              <span class="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold uppercase tracking-[0.16em] text-primary">${escapeHtml(pluralize(attachments.length, "record"))}</span>
3536            </div>
3537            <div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">${attachmentMarkup}</div>
3538          </section>
3539          <section class="min-w-0 rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)]">
3540            <p class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/80">Notes and Source Context</p>
3541            <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">Linked source notes</h2>
3542            <div class="mt-6">${sourceMarkup}</div>
3543          </section>
3544        </div>
3545      </section>
3546      <section class="rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)] md:p-8">
3547        <div class="flex flex-wrap items-end justify-between gap-4">
3548          <div>
3549            <p class="text-[10px] font-black uppercase tracking-[0.2em] text-primary/80">Person Timeline</p>
3550            <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">Named events tied to ${escapeHtml(person.displayName)}</h2>
3551          </div>
3552          <span class="rounded-full bg-surface-container-low px-3 py-1 text-xs font-bold uppercase tracking-[0.16em] text-outline">${escapeHtml(pluralize(personEvents.length, "event"))}</span>
3553        </div>
3554        <div class="mt-6 space-y-4">${eventMarkup}</div>
3555      </section>
3556    `
3557  });
3558
3559  disablePlannedExportButtons();
3560}
3561
3562function renderEnhancedTimelineView() {
3563  state.renderedTree = null;
3564  const events = [...(state.data.events || [])].sort((left, right) => eventSortValue(left).localeCompare(eventSortValue(right)));
3565  const eventMarkup = events.map((event) => {
3566    const participants = eventParticipantsLabel(event, state.graph.peopleById);
3567    const dateLabel = eventDateLabel(event);
3568    const yearLabel = String(event?.date?.sortValue || "").slice(0, 4) || dateLabel;
3569    return `
3570      <article class="rounded-[1.7rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_22px_56px_-38px_rgba(27,27,27,0.28)] md:p-8">
3571        <div class="flex flex-wrap items-start justify-between gap-4">
3572          <div>
3573            <p class="text-[11px] font-black uppercase tracking-[0.18em] text-primary/80">${escapeHtml(dateLabel)}</p>
3574            <h2 class="mt-2 font-headline text-3xl font-bold text-on-surface">${escapeHtml(eventTitleText(event))}</h2>
3575            <p class="mt-3 text-sm font-semibold uppercase tracking-[0.16em] text-outline">${escapeHtml(participants)}${event.placeLabel ? ` • ${escapeHtml(event.placeLabel)}` : ""}</p>
3576          </div>
3577          <div class="flex items-center gap-3">
3578            <span class="text-4xl font-headline font-black text-outline-variant/40">${escapeHtml(yearLabel)}</span>
3579            <span class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
3580              <span class="material-symbols-outlined">${escapeHtml(eventIcon(event.kind || event.event))}</span>
3581            </span>
3582          </div>
3583        </div>
3584        <p class="mt-5 max-w-4xl text-sm leading-7 text-on-surface-variant">${escapeHtml(eventSummaryText(event))}</p>
3585        <div class="mt-5 flex flex-wrap gap-3 text-[11px] uppercase tracking-[0.16em] text-outline">
3586          <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(dateLabel)}</span>
3587          <span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(participants)}</span>
3588          ${event.placeLabel ? `<span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(event.placeLabel)}</span>` : ""}
3589          ${eventSourceLabel(event) ? `<span class="rounded-full bg-surface-container-low px-3 py-1">${escapeHtml(truncate(eventSourceLabel(event), 54))}</span>` : ""}
3590        </div>
3591      </article>
3592    `;
3593  }).join("");
3594
3595  renderPrimaryShell({
3596    view: "timeline",
3597    eyebrow: "Chronology",
3598    titleHtml: `The Chronology of <span class="italic text-primary">Your Family</span>`,
3599    summaryText: `A chronological view of ${events.length}
3599 named family events, linked people, places, and supporting records across the archive.`,
3600    heroAsideMarkup: `
3601      <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]">
3602        <p class="text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">Timeline Overview</p>
3603        <p class="mt-3 text-sm leading-6 text-on-surface-variant">Every event is named with the people involved, the place where known, and the source context that supports it.</p>
3604      </section>
3605    `,
3606    contentMarkup: `
3607      <section class="space-y-5">
3608        ${eventMarkup || `<div class="genealogy-empty-state">No chronology events have been linked yet.</div>`}
3609      </section>
3610    `
3611  });
3612
3613  disablePlannedExportButtons();
3614}
3615
3616function renderCollectionShell(view, titleHtml, summaryText, contentMarkup, mobileIntroMarkup = "") {
3617  const eyebrow = view === "people"
3618    ? "People Directory"
3619    : view === "sources"
3620      ? "Sources and Records"
3621      : "Family Archive";
3622  renderPrimaryShell({
3623    view,
3624    eyebrow,
3625    titleHtml,
3626    summaryText,
3627    heroAsideMarkup: `
3628      <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]">
3629        <p class="text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">${escapeHtml(eyebrow)}</p>
3630        <p class="mt-3 text-sm leading-6 text-on-surface-variant">${escapeHtml(view === "people"
3631          ? "Browse the people in the archive, then open a profile to see their records, timeline, and linked relatives."
3632          : "Open wills, certificates, images, and linked document previews from across the family archive.")}</p>
3633      </section>
3634    `,
3635    contentMarkup: `
3636      <section class="space-y-6">
3637        ${mobileIntroMarkup || ""}
3638        ${contentMarkup}
3639      </section>
3640    `
3641  });
3642
3643  const search = root.querySelector("input[placeholder='Search lineage...']");
3644  if (search) {
3645    search.value = state.searchDisplay || state.searchQuery;
3646    search.dataset.role = "search";
3647  }
3648  bindSearchInput();
3649  disablePlannedExportButtons();
3650}
3651
3652function renderResearchView() {
3653  state.renderedTree = null;
3654  const speculativePeople = state.graph.people.filter((person) => person.status === "candidate" || person.status === "unresolved").slice(0, 8);
3655  renderPrimaryShell({
3656    view: "research",
3657    eyebrow: "Research Admin",
3658    titleHtml: "Research tools stay outside the family-facing archive.",
3659    summaryText: "Speculative review, exports, and deeper admin tooling are intentionally hidden from everyday family browsing.",
3660    includeFilters: false,
3661    heroAsideMarkup: `
3662      <section class="rounded-[1.75rem] border border-outline-variant/20 bg-white/95 p-5 shadow-[0_20px_48px_-34px_rgba(27,27,27,0.28)]">
3663        <p class="text-[10px] font-black uppercase tracking-[0.28em] text-primary/80">Admin Only</p>
3664        <p class="mt-3 text-sm leading-6 text-on-surface-variant">This route is kept for maintenance and review only. The family-facing navigation stays focused on the tree, timeline, people, and sources.</p>
3665      </section>
3666    `,
3667    contentMarkup: `
3668      <section class="rounded-[1.8rem] border border-outline-variant/20 bg-white/96 p-6 shadow-[0_24px_64px_-38px_rgba(27,27,27,0.24)] md:p-8">
3669        <h2 class="font-headline text-3xl font-bold text-on-surface">Speculative links held for review</h2>
3670        <p class="mt-3 max-w-3xl text-sm leading-6 text-on-surface-variant">These people remain visible in the data, but the research controls themselves are intentionally kept out of the normal family flow.</p>
3671        <div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
3672          ${speculativePeople.map((person) => `
3673            <article class="rounded-[1.4rem] border border-outline-variant/20 bg-surface-container-low p-5">
3674              <div class="flex items-start justify-between gap-3">
3675                <div class="h-11 w-11 overflow-hidden rounded-2xl bg-white">${photoMarkup(person, "h-full w-full object-cover")}</div>
3676                <span class="rounded-full bg-white px-3 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-outline">${escapeHtml(person.confidence || "low")} confidence</span>
3677              </div>
3678              <h3 class="mt-4 font-headline text-xl font-bold text-on-surface">${escapeHtml(person.displayName)}</h3>
3679              <p class="mt-2 text-sm leading-6 text-on-surface-variant">${escapeHtml(truncate(person.summary || "Candidate relationship held for review.", 140))}</p>
3680            </article>
3681          `).join("") || `<div class="genealogy-empty-state">No speculative people are currently flagged.</div>`}
3682        </div>
3683      </section>
3684    `
3685  });
3686}
3687
3688function render() {
3689  if (!state.graph) {
3690    renderLockedView();
3691    applyShellBranding();
3692    return;
3693  }
3694  if (state.activeView === "profile") {
3695    renderProfileView();
3696    return;
3697  }
3698  if (state.activeView === "timeline") {
3699    renderTimelineView();
3700    return;
3701  }
3702  if (state.activeView === "people") {
3703    renderPeopleView();
3704    return;
3705  }
3706  if (state.activeView === "sources") {
3707    renderSourcesView();
3708    return;
3709  }
3710  renderTreeView();
3711}
3712
3713async function unlockArchive(password) {
3714  const decrypted = await decryptPayload(state.encryptedPayload, password);
3715  state.data = decrypted;
3716  try {
3717    state.graph = buildGraph(decrypted);
3718    state.selectedPersonId = decrypted.meta.focusPersonId || state.graph.people[0]?.id || "";
3719    state.activeView = "tree";
3720    resetTreeViewportState();
3721    render();
3722  } catch (error) {
3723    renderUnlockRecoveryView(error);
3724  }
3725}
3726
3727root.addEventListener("submit", async (event) => {
3728  const form = event.target.closest("[data-role='unlock-form']");
3729  if (!form) {
3730    return;
3731  }
3732  event.preventDefault();
3733  const password = root.querySelector("#archive-password")?.value ?? "";
3734  setUnlockHelperMessage("Unlocking the family archive...");
3735  try {
3736    await unlockArchive(password);
3737  } catch (error) {
3738    console.error(error);
3739    setUnlockHelperMessage("That password did not unlock the archive.");
3740  }
3741});
3742
3743root.addEventListener("input", (event) => {
3744  if (event.target.dataset.role === "search") {
3745    state.searchDisplay = String(event.target.value || "");
3746    state.searchQuery = normalizeSearchText(event.target.value || "");
3747    if (!updateTreeSearchUI()) {
3748      render();
3749    }
3750  }
3751});
3752
3753root.addEventListener("click", (event) => {
3754  const viewTarget = event.target.closest("[data-view]");
3755  if (viewTarget && state.graph) {
3756    event.preventDefault();
3757    state.activeView = viewTarget.dataset.view;
3758    state.treeViewport.autoFocusPending = viewTarget.dataset.view === "tree";
3759    state.treeViewport.fitPending = false;
3760    state.treeViewport.focusPeopleIds = [];
3761    render();
3762    return;
3763  }
3764  const toggle = event.target.closest("[data-action='toggle-password']");
3765  if (toggle) {
3766    state.passwordVisible = !state.passwordVisible;
3767    const input = root.querySelector("#archive-password");
3768    if (input) {
3769      input.type = state.passwordVisible ? "text" : "password";
3770    }
3771    return;
3772  }
3773  const sidebarToggle = event.target.closest("[data-action='toggle-family-sidebar']");
3774  if (sidebarToggle && state.graph) {
3775    state.familySidebarCollapsed = !state.familySidebarCollapsed;
3776    render();
3777    return;
3778  }
3779  const suggestionTarget = event.target.closest("[data-search-suggestion]");
3780  if (suggestionTarget && state.graph) {
3781    applySearchSuggestion(suggestionTarget.dataset.searchSuggestion);
3782    return;
3783  }
3784  const familyFilterTarget = event.target.closest("[data-family-filter]");
3785  if (familyFilterTarget && state.graph) {
3786    const family = familyFilterTarget.dataset.familyFilter;
3787    state.activeFamilyFilter = state.activeFamilyFilter === family ? "" : family;
3788    state.lockedTreeHighlight = state.activeFamilyFilter ? familyHighlightPayload(state.activeFamilyFilter) : null;
3789    const selectedFamilyModel = state.activeFamilyFilter ? buildSelectedFamilyModel() : null;
3790    const focusIds = selectedFamilyModel?.relatedIds || state.lockedTreeHighlight?.peopleIds || [];
3791    const representative = state.activeFamilyFilter ? representativeForFamily(state.activeFamilyFilter) : null;
3792    state.familySidebarCollapsed = false;
3793    if (representative) {
3794      state.selectedPersonId = representative.id;
3795    }
3796    if (focusIds.length) {
3797      queueTreeFocus(focusIds);
3798    } else {
3799      rememberTreeViewport();
3800      state.treeViewport.restorePending = true;
3801      state.treeViewport.autoFocusPending = false;
3802      state.treeViewport.focusPeopleIds = [];
3803    }
3804    render();
3805    return;
3806  }
3807  const profileTarget = event.target.closest("[data-open-profile='true']");
3808  if (profileTarget && state.graph) {
3809    state.selectedPersonId = profileTarget.dataset.personId;
3810    state.activeView = "profile";
3811    state.treeViewport.focusPeopleIds = [];
3812    render();
3813    return;
3814  }
3815  const personTarget = event.target.closest("[data-person-id]");
3816  if (personTarget && state.graph) {
3817    state.selectedPersonId = personTarget.dataset.personId;
3818    state.treeViewport.focusPeopleIds = [];
3819    render();
3820  }
3821});
3822
3823window.addEventListener("resize", () => {
3824  if (state.graph) {
3825    state.graph = buildGraph(state.data);
3826    state.treeViewport.fitPending = false;
3827    state.treeViewport.restorePending = false;
3828    state.treeViewport.autoFocusPending = state.activeView === "tree";
3829    state.treeViewport.focusPeopleIds = [];
3830    render();
3831  }
3832});
3833
3834async function init() {
3835  const [payloadResponse] = await Promise.all([
3836    fetch(`${DATA_URL}?ts=${Date.now()}`, { cache: "no-store" }),
3837    loadTemplates()
3838  ]);
3839  state.encryptedPayload = await payloadResponse.json();
3840  render();
3841}
3842
3843init().catch((error) => {
3844  console.error(error);
3845  root.innerHTML = `<div class="min-h-screen flex items-center justify-center p-8"><div class="bg-white rounded-xl shadow-xl p-8 max-w-lg w-full"><h1 class="font-headline text-3xl font-bold mb-3">Genealogy archive unavailable</h1><p class="text-secondary">The Stitch-powered genealogy interface could not be loaded.</p></div></div>`;
3846});

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.