PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/App.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:25:54 UTC 71,967 bytes, 1,684 lines download raw bytes

1
2import { Component, Suspense, lazy, useEffect, useState, useMemo, useRef, useCallback } from 'react';
3import { html } from './html.js?v=3.8.36';
4import { Newspaper, SlidersHorizontal, LayoutGrid, Folder, BookOpen, BookMarked, Compass, HelpCircle, MoreHorizontal, AlertCircle, ChevronUp, BarChart3, Users, Info, Bug, Github, Search, XCircle } from 'lucide-react';
5import { fetchCoreData, fetchRecordDetails, preloadDetails, loadSearchIndex } from './services/archiveService.js?v=3.8.36';
6import { perfMark, perfMeasure } from './utils/perfMark.js?v=3.8.36';
7import { withViewTransition } from './utils/viewTransition.js?v=3.8.36';
8import { CONTENT_TYPE_OPTIONS, ITEMS_PER_PAGE, REPORT_CONFIG } from './constants.js?v=3.8.36';
9import { ROUTES, getCurrentRoute, getDesktopAppIdFromUrl, getEntityIdFromUrl, navigateTo, navigateToDesktop, getRecordIdFromUrl, migrateLegacyUrl } from './services/router.js?v=3.8.36';
10import { parseViewState, viewStateToUrl } from './services/viewState.js?v=3.8.36';
11import { ABOUT_PRIVACY_HASH, getPrivacyDetailsHref, resolvePrivacyRoute } from './services/privacyRoute.js?v=3.8.36';
12import { canonicalRecordUrl, setRecordParam } from './utils/recordDeepLink.js?v=3.8.36';
13import {
14  BROKEN_RECORD_SKIP_MS,
15  DISSERTATION_YEAR_NOTE,
16  NOWHERE_RECORD_ID,
17  brokenRecordLine,
18  isDissertationYearQuery,
19  summarizeBrokenRecord,
20} from './utils/easterEggs.js?v=3.8.36';
21import { readReportDeepLink } from './utils/reportDeepLink.js?v=3.8.36';
22import { resolveSitePath } from './utils/pathResolver.js?v=3.8.36';
23import { recordNeedsReview } from './utils/needsReview.js?v=3.8.36';
24import {
25  buildSearchText,
26  matchesParsedSearchText,
27  normalizeForSearch,
28  parseSearchQuery,
29  searchLoadedIndexes,
30} from './utils/searchNormalize.js?v=3.8.36';
31import { sortRecords } from './utils/recordSort.js?v=3.8.36';
32import {
33  DEFAULT_SORT,
34  RELEVANCE_SORT,
35  buildSearchRanking,
36  orderByFusedRank,
37  sortForQueryChange,
38  sortForSemanticToggle,
39} from './utils/searchRanking.js?v=3.8.36';
40import {
41  requestSemanticSearch,
42  terminateSemanticSearch,
43  warmupSemanticSearch,
44} from './services/semanticSearch.js?v=3.8.36';
45import { deriveFacetsForRecords, intersectByRecordIds } from './services/queryComposition.js?v=3.8.36';
46import { formatReleaseDate, loadReleaseMetadata } from './services/releaseMetadata.js?v=3.8.36';
47import Sidebar from './components/Sidebar.js?v=3.8.36';
48import WelcomeModal from './components/WelcomeModal.js?v=3.8.36';
49import RecordView from './components/RecordView.js?v=3.8.36';
50import FeaturedSection from './components/FeaturedSection.js?v=3.8.36';
51import DissertationPage from './components/DissertationPage.js?v=3.8.36';
52import ToolsModal from './components/ToolsModal.js?v=3.8.36';
53import BugReportModal from './components/BugReportModal.js?v=3.8.36';
54import WorkInProgressBanner from './components/WorkInProgressBanner.js?v=3.8.36';
55import AnalyticsDashboard from './components/AnalyticsDashboard.js?v=3.8.36';
56import EntityBrowser from './components/EntityBrowser.js?v=3.8.36';
57import Timeline from './components/Timeline.js?v=3.8.36';
58import AboutPage from './components/AboutPage.js?v=3.8.36';
59import WikiPage from './components/WikiPage.js?v=3.8.36';
60import StartHerePage from './components/StartHerePage.js?v=3.8.36';
61import ArchiveResults from './components/ArchiveResults.js?v=3.8.36';
62import EasterEggNote from './components/EasterEggNote.js?v=3.8.36';
63import NowherePage from './components/NowherePage.js?v=3.8.36';
64import SemanticSearchToggle, { semanticStatusMessage } from './components/SemanticSearchToggle.js?v=3.8.36';
65
66const DesktopShell = lazy(() => import('./desktop/DesktopShell.js?v=3.8.36'));
67
68const NON_RECORD_ROUTES = new Set([
69  ROUTES.analytics,
70  ROUTES.wiki,
71  ROUTES.desktop,
72  // The hidden route is one line of text. It never needs the core data.
73  ROUTES.nowhere,
74]);
75
76const DESKTOP_RECORD_APPS = new Set(['archive', 'folders', 'entities', 'start', 'findings']);
77const DESKTOP_DETAILS_PRELOAD_APPS = new Set(['archive', 'folders']);
78const DESKTOP_GUIDED_SHELL_DESTINATIONS = new Set([
79  'archive',
80  'folders',
81  'entities',
82  'dissertation',
83  'analytics',
84]);
85
86const ROUTE_ENTRY_FOCUS_SELECTOR = '[data-route-entry-focus], #main-content';
87const MAX_MINI_INDEX_RETRIES = 3;
88
89// RELEVANCE_SORT (imported from utils/searchRanking.js) is deliberately NOT in
90// RECORD_SORTS: that list is the site-tools contract, and every key in it must
91// have a comparator in recordSort.js. Relevance has no comparator because it
92// orders by fused search rank, so it is offered only while a search term is
93// active and is handled before sortRecords runs.
94//
95// Wait this long after the last keystroke before encoding a query. One encode
96// per typed word instead of one per letter.
97const SEMANTIC_QUERY_DEBOUNCE_MS = 350;
98
99// One frozen empty result, so a cleared query does not create a new object and
100// re-run the filter memo on every render.
101const EMPTY_SEMANTIC_HITS = Object.freeze({ query: '', ids: [] });
102const getCurrentRouteWithPrivacyFallback = () =>
103  resolvePrivacyRoute(getCurrentRoute(), window.location.hash);
104
105class DesktopRouteErrorBoundary extends Component {
106  constructor(props) {
107    super(props);
108    this.state = { failed: false };
109    this.failureHeading = null;
110    this.focusFailureFrame = null;
111  }
112
113  static getDerivedStateFromError() {
114    return { failed: true };
115  }
116
117  componentDidCatch(error) {
118    console.error('Archive desktop failed to load:', error);
119    this.focusFailureFrame = requestAnimationFrame(() => {
120      this.focusFailureFrame = null;
121      this.failureHeading?.focus({ preventScroll: true });
122    });
123  }
124
125  componentWillUnmount() {
126    if (this.focusFailureFrame !== null) cancelAnimationFrame(this.focusFailureFrame);
127  }
128
129  render() {
130    if (!this.state.failed) return this.props.children;
131
132    return html`
133      <main id="main-content" className="min-h-screen px-4 py-16" style=${{ backgroundColor: '#fdfbf7' }}>
134        <div className="mx-auto max-w-xl border-2 border-stone-800 bg-white p-8 shadow-lg">
135          <p className="mb-2 font-body text-xs font-bold uppercase tracking-wider text-stone-500">Archive desktop unavailable</p>
136          <h1
137            ref=${(element) => { this.failureHeading = element; }}
138            tabIndex="-1"
139            className="font-display text-3xl font-bold text-stone-900 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
140          >The standard archive is still ready.</h1>
141          <p className="mt-4 font-body text-sm leading-relaxed text-stone-600">
142            The optional desktop view could not load. No archive data or standard navigation is affected.
143          </p>
144          <button
145            type="button"
146            onClick=${this.props.onExit}
147            className="mt-6 inline-flex items-center justify-center bg-stone-900 px-5 py-3 font-display text-sm font-bold text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
148          >
149            Open the standard archive
150          </button>
151        </div>
152      </main>
153    `;
154  }
155}
156
157// One source of truth for the empty filter state. The initial state and both
158// "clear filters" paths spread this, so adding a filter field can't leave a
159// reset path out of sync.
160const DEFAULT_FILTERS = {
161  search: '',
162  categories: [],
163  era: null,
164  year: null,
165  publication: [],
166  type: null,
167  includeReplies: false,
168  recordIds: null,
169};
170
171const App = () => {
172  const [records, setRecords] = useState([]);
173  const [facets, setFacets] = useState({ categories: [], eras: [], publications: [] });
174  const [autocompleteIndex, setAutocompleteIndex] = useState([]);
175  const [loading, setLoading] = useState(true);
176  const [error, setError] = useState(null);
177  const [sidebarOpen, setSidebarOpen] = useState(false);
178  const [currentRoute, setCurrentRoute] = useState(() => getCurrentRouteWithPrivacyFallback());
179  const [desktopAppId, setDesktopAppId] = useState(() => getDesktopAppIdFromUrl());
180  const [desktopOpenAppIds, setDesktopOpenAppIds] = useState([]);
181  const [currentPage, setCurrentPage] = useState(1);
182  const [sortBy, setSortBy] = useState('date-desc');
183  // Opt-in semantic search (#279). Off on every visit: turning it on downloads
184  // a model, so the choice is never made for the reader by a stored setting.
185  const [semanticEnabled, setSemanticEnabled] = useState(false);
186  const [semanticStatus, setSemanticStatus] = useState('idle');
187  const [semanticCoverage, setSemanticCoverage] = useState(null);
188  const [semanticHits, setSemanticHits] = useState(EMPTY_SEMANTIC_HITS);
189  const [isScrolled, setIsScrolled] = useState(false);
190  // Initialise from ?record= so a deep-linked load survives mount. The URL-sync
191  // effect below runs on mount with this value already set, so it preserves the
192  // param instead of deleting it; the [records] effect then validates the id
193  // once the archive data finishes loading (clearing it if no record matches).
194  const [selectedRecordId, setSelectedRecordId] = useState(() => getRecordIdFromUrl());
195  const [selectedEntityId, setSelectedEntityId] = useState(() => getEntityIdFromUrl());
196  const [toolsModalOpen, setToolsModalOpen] = useState(false);
197  const [bugReportOpen, setBugReportOpen] = useState(false);
198  const [bugReportIntent, setBugReportIntent] = useState('problem');
199  const [bugReportInitialFields, setBugReportInitialFields] = useState({});
200  const [showBackToTop, setShowBackToTop] = useState(false);
201  const [announcedResultCount, setAnnouncedResultCount] = useState('');
202  const [currentHash, setCurrentHash] = useState(() => window.location.hash);
203  const [releaseMetadata, setReleaseMetadata] = useState(null);
204  const [updateAvailable, setUpdateAvailable] = useState(
205    () => Boolean(window.__jrdaUpdateReady),
206  );
207  // Hidden extras (#754). Each one waits for a deliberate action, shows one
208  // dismissible line, and leaves reading, search, and navigation untouched.
209  const [brokenRecord, setBrokenRecord] = useState(null);
210  const [recordsSkipping, setRecordsSkipping] = useState(false);
211  const [yearNoteDismissed, setYearNoteDismissed] = useState(false);
212
213  const [filters, setFilters] = useState(() => ({
214    ...DEFAULT_FILTERS,
215    ...parseViewState(window.location.href).filters,
216  }));
217  const desktopActiveNeedsRecords = currentRoute === ROUTES.desktop
218    && DESKTOP_RECORD_APPS.has(desktopAppId);
219  const desktopDisallowsRecordContext = currentRoute === ROUTES.desktop
220    && !DESKTOP_RECORD_APPS.has(desktopAppId);
221  const selectedRecordIsLoaded = useMemo(
222    () => Boolean(selectedRecordId) && records.some(({ id }) => id === selectedRecordId),
223    [records, selectedRecordId],
224  );
225  const desktopRecordOverlayOpen = desktopActiveNeedsRecords && selectedRecordIsLoaded;
226  const desktopNeedsRecords = currentRoute === ROUTES.desktop
227    && (
228      desktopActiveNeedsRecords
229      || desktopOpenAppIds.some((appId) => DESKTOP_RECORD_APPS.has(appId))
230    );
231  const desktopNeedsDetailsPreload = currentRoute === ROUTES.desktop
232    && (
233      DESKTOP_DETAILS_PRELOAD_APPS.has(desktopAppId)
234      || desktopOpenAppIds.some((appId) => DESKTOP_DETAILS_PRELOAD_APPS.has(appId))
235    );
236
237  // Ref for scrolling to results
238  const resultsRef = useRef(null);
239  const recordsRef = useRef(null);
240  const filterTriggerRef = useRef(null);
241  const scopeStatusRef = useRef(null);
242  const scopeTokenRefs = useRef(new Map());
243  const pendingScopeFocusKey = useRef(null);
244  const reportEntryHandled = useRef(false);
245  const previouslyRenderedRoute = useRef(currentRoute);
246
247  const openBugReport = useCallback((intent = 'problem', initialFields = {}) => {
248    setBugReportIntent(intent);
249    setBugReportInitialFields(initialFields);
250    setBugReportOpen(true);
251  }, []);
252
253  const handleRecordProblemReport = useCallback(() => {
254    // The selected record id already stays in the page URL captured with the
255    // report. Keep the required description empty so the reader must describe
256    // the actual problem instead of submitting an auto-filled prefix alone.
257    openBugReport('problem');
258  }, [openBugReport]);
259
260  // Hash navigation replaces parts of the React tree without giving the
261  // browser a document-navigation focus reset. When the invoking control is
262  // unmounted, focus otherwise falls through to <body> and a keyboard or
263  // screen-reader user receives no indication that a new page appeared.
264  //
265  // Child views with a more precise destination (record dialogs, selected
266  // entities, Start here, desktop windows) claim focus in their own effects.
267  // Re-check at paint time and yield to any connected non-body target so this
268  // fallback cannot overwrite those richer focus contracts.
269  useEffect(() => {
270    const handleUpdateReady = () => setUpdateAvailable(true);
271    window.addEventListener('jrda:update-ready', handleUpdateReady);
272    return () => window.removeEventListener('jrda:update-ready', handleUpdateReady);
273  }, []);
274
275  useEffect(() => {
276    const previousRoute = previouslyRenderedRoute.current;
277    previouslyRenderedRoute.current = currentRoute;
278    if (previousRoute === currentRoute) return undefined;
279
280    const frame = requestAnimationFrame(() => {
281      const activeElement = document.activeElement;
282      if (
283        activeElement instanceof HTMLElement
284        && activeElement !== document.body
285        && activeElement.isConnected
286      ) return;
287
288      const focusTarget = document.querySelector(ROUTE_ENTRY_FOCUS_SELECTOR);
289      if (!(focusTarget instanceof HTMLElement)) return;
290      focusTarget.focus({ preventScroll: true });
291    });
292    return () => cancelAnimationFrame(frame);
293  }, [currentRoute]);
294
295  // Privacy entry points have a more precise destination than the About page
296  // heading. The hashchange route sync makes this work both across routes and
297  // when the reader is already on About, while the post-render effect ensures
298  // the disclosure exists before it is scrolled into view and focused.
299  useEffect(() => {
300    if (currentRoute !== ROUTES.about || currentHash !== `#${ABOUT_PRIVACY_HASH}`) return undefined;
301
302    const frame = requestAnimationFrame(() => {
303      const privacyTarget = document.getElementById('privacy-and-browser-storage');
304      if (!(privacyTarget instanceof HTMLElement)) return;
305      privacyTarget.scrollIntoView({ block: 'start' });
306      privacyTarget.focus({ preventScroll: true });
307    });
308    return () => cancelAnimationFrame(frame);
309  }, [currentHash, currentRoute]);
310
311  useEffect(() => {
312    if (reportEntryHandled.current) return;
313    reportEntryHandled.current = true;
314    const entry = readReportDeepLink(window.location.href);
315    if (!entry.intent) return;
316    setBugReportIntent(entry.intent);
317    setBugReportInitialFields({});
318    setBugReportOpen(true);
319    window.history.replaceState({}, '', entry.cleanHref);
320  }, []);
321
322  // Migrate legacy ?view= URLs on mount, then sync hash state
323  useEffect(() => {
324    migrateLegacyUrl();
325
326    const syncRoute = (event) => {
327      const route = getCurrentRouteWithPrivacyFallback();
328      setCurrentRoute(route);
329      setCurrentHash(window.location.hash);
330      setDesktopAppId(getDesktopAppIdFromUrl());
331      setSelectedEntityId(getEntityIdFromUrl());
332
333      // Desktop filters replace the current history entry as their readable
334      // query string changes. Back/Forward must therefore restore those
335      // historical query-backed filters alongside the active window. Ordinary
336      // hash navigation keeps the live in-memory filters (standard archive
337      // filters are not URL-synchronised), while transient analytics recordIds
338      // intentionally clear because they are not part of the URL contract.
339      if (event?.type === 'popstate') {
340        const historicalState = parseViewState(window.location.href);
341        setFilters({
342          ...DEFAULT_FILTERS,
343          ...historicalState.filters,
344        });
345      }
346
347      const recordId = getRecordIdFromUrl();
348      if (recordId && records.length > 0) {
349        const target = records.find(r => r.id === recordId);
350        setSelectedRecordId(target ? recordId : null);
351      } else if (!recordId) {
352        setSelectedRecordId(null);
353      }
354    };
355
356    syncRoute();
357    window.addEventListener('hashchange', syncRoute);
358    window.addEventListener('popstate', syncRoute);
359    return () => {
360      window.removeEventListener('hashchange', syncRoute);
361      window.removeEventListener('popstate', syncRoute);
362    };
363  }, [records]);
364
365  // Update URL when record selected (without changing route)
366  useEffect(() => {
367    if (desktopActiveNeedsRecords) return;
368    const url = new URL(window.location.href);
369    setRecordParam(
370      url.searchParams,
371      desktopDisallowsRecordContext ? null : selectedRecordId,
372    );
373    if (desktopDisallowsRecordContext) {
374      url.searchParams.delete('entity');
375      if (selectedRecordId !== null) setSelectedRecordId(null);
376    }
377    try {
378      window.history.replaceState({}, '', url);
379    } catch(e) { console.warn("History update blocked"); }
380  }, [selectedRecordId, desktopActiveNeedsRecords, desktopDisallowsRecordContext]);
381
382  // Desktop archive links preserve the canonical filter query plus the shared
383  // ?record= deep link. The hash only identifies the active shell app; no
384  // second desktop-only filter or record URL format is introduced.
385  useEffect(() => {
386    if (!desktopActiveNeedsRecords) return;
387    const nextUrl = viewStateToUrl({
388      route: ROUTES.desktop,
389      routeParams: {
390        desktopAppId,
391        ...(desktopAppId === 'entities' && selectedEntityId ? { entityId: selectedEntityId } : {}),
392      },
393      filters,
394      selectedRecord: selectedRecordId,
395    }, window.location.href);
396    try {
397      window.history.replaceState({}, '', nextUrl);
398    } catch {
399      console.warn('Desktop view-state update blocked');
400    }
401  }, [desktopActiveNeedsRecords, desktopAppId, filters, selectedRecordId, selectedEntityId]);
402
403  useEffect(() => {
404    if (currentRoute !== ROUTES.entities) return;
405    const currentState = parseViewState(window.location.href);
406    const nextUrl = viewStateToUrl({
407      route: ROUTES.entities,
408      routeParams: selectedEntityId ? { entityId: selectedEntityId } : {},
409      filters: currentState.filters,
410      selectedRecord: selectedRecordId,
411    }, window.location.href);
412    try {
413      window.history.replaceState({}, '', nextUrl);
414    } catch {
415      console.warn('Entity view-state update blocked');
416    }
417  }, [currentRoute, selectedEntityId, selectedRecordId]);
418
419  // Navigation helpers
420  const goTo = useCallback((route) => {
421    navigateTo(route);
422  }, []);
423
424  const goToDesktop = useCallback((appId = null) => {
425    navigateToDesktop(appId);
426  }, []);
427
428  const handleDesktopGuideNavigate = useCallback((destination) => {
429    if (destination === 'desktop') {
430      navigateToDesktop();
431    } else if (DESKTOP_GUIDED_SHELL_DESTINATIONS.has(destination)) {
432      navigateToDesktop(destination);
433    } else {
434      navigateTo(destination);
435    }
436  }, []);
437
438  // Open, close, or switch the record modal with a View Transition cross-fade
439  // (#281). Used by every deliberate record selection -- grid card, entity
440  // browser, in-modal related links, and close -- so they animate the same way;
441  // browsers without the API just set state directly (see withViewTransition).
442  // Two paths stay plain on purpose: prev/next arrow paging (handleModalNav),
443  // where a cross-fade on every press would lag, and history-driven sync (back/
444  // forward, deep links), which should not animate on navigation.
445  const selectRecord = useCallback((id) => {
446    withViewTransition(() => setSelectedRecordId(id));
447  }, []);
448
449  // Tag click handlers (from RecordModal) — go to archive and filter
450  const handleFilterCategory = useCallback((cat) => {
451    setFilters(prev => ({ ...prev, categories: [cat] }));
452    if (currentRoute === ROUTES.desktop) navigateToDesktop('archive');
453    else navigateTo(ROUTES.archive);
454  }, [currentRoute]);
455
456  const handleFilterSearch = useCallback((term) => {
457    setFilters(prev => ({ ...prev, search: term }));
458    if (currentRoute === ROUTES.desktop) navigateToDesktop('archive');
459    else navigateTo(ROUTES.archive);
460  }, [currentRoute]);
461
462  const handleRecordEntitySelect = useCallback((entityId) => {
463    setSelectedEntityId(entityId);
464    if (currentRoute === ROUTES.desktop) navigateToDesktop('entities', entityId);
465    else navigateTo(ROUTES.entities, null, entityId);
466  }, [currentRoute]);
467
468  const handleQueryResults = useCallback((recordIds) => {
469    setFilters(prev => ({ ...prev, recordIds }));
470    navigateTo(ROUTES.archive);
471  }, []);
472
473  const handleDesktopQueryResults = useCallback((recordIds) => {
474    setFilters(prev => ({ ...prev, recordIds }));
475    navigateToDesktop('archive');
476  }, []);
477
478  const clearQueryFilter = useCallback(() => {
479    setFilters(prev => ({ ...prev, recordIds: null }));
480  }, []);
481
482  // Broken record (#754): five quick clicks on one category filter. The list
483  // skips like a stuck record, and a note says how long that category has been
484  // repeating itself. A category with fewer than two records shows nothing.
485  const handleCategoryStreak = useCallback((category) => {
486    const summary = summarizeBrokenRecord(category, records);
487    if (!summary) return;
488    setBrokenRecord(summary);
489    setRecordsSkipping(true);
490  }, [records]);
491
492  // The hidden route links to the essay behind its one line.
493  const openNowhereEssay = useCallback(() => {
494    navigateTo(ROUTES.archive, NOWHERE_RECORD_ID);
495    selectRecord(NOWHERE_RECORD_ID);
496  }, [selectRecord]);
497
498  // Tool selection handler
499  const handleToolSelect = useCallback((action) => {
500    if (action === 'start') {
501      navigateTo(ROUTES.start);
502    } else if (action === 'mindmap') {
503      navigateTo(ROUTES.dissertation);
504    } else if (action === 'entities') {
505      navigateTo(ROUTES.entities);
506    } else if (action === 'wiki') {
507      navigateTo(ROUTES.wiki);
508    } else if (action === 'desktop') {
509      navigateToDesktop();
510    }
511  }, []);
512
513  // Load Data
514  // Gate the ~13MB core fetch so cold deep-links into non-record routes do
515  // not fetch and parse archive data those routes never render. The ref makes
516  // this fire once; depending on currentRoute means navigating back to a
517  // record-backed route back-fills the data.
518  const coreFetchStarted = useRef(false);
519  useEffect(() => {
520    let active = true;
521    loadReleaseMetadata()
522      .then((metadata) => {
523        if (active) setReleaseMetadata(metadata);
524      })
525      .catch(() => {});
526    return () => { active = false; };
527  }, []);
528
529  useEffect(() => {
530    if (coreFetchStarted.current) return;
531    if (NON_RECORD_ROUTES.has(currentRoute) && !desktopNeedsRecords) return;
532    coreFetchStarted.current = true;
533    perfMark('data:start');
534    fetchCoreData()
535      .then((data) => {
536        perfMark('data:end');
537        perfMeasure('data:load', 'data:start', 'data:end');
538        setRecords(data.records);
539        setFacets(data.facets);
540        setAutocompleteIndex(data.autocompleteIndex);
541        setLoading(false);
542      })
543      .catch(err => {
544        console.error(err);
545        setError('Failed to load archive data. Please refresh the page or try again later.');
546        setLoading(false);
547      });
548  }, [currentRoute, desktopAppId, desktopNeedsRecords]);
549
550  // Preserve the standard archive's background full-detail warmup, but keep
551  // the lighter guided and entity desktop surfaces on core data until a
552  // record is actually opened. Archive/Folders opt back in even when they are
553  // restored in a background window. Cancelling the timer when the route or
554  // visible window set changes avoids paying for a surface the visitor left.
555  useEffect(() => {
556    const detailsPreloadEnabled = currentRoute !== ROUTES.desktop
557      || desktopNeedsDetailsPreload;
558    if (!records.length || !detailsPreloadEnabled) return undefined;
559
560    const timer = setTimeout(() => preloadDetails(), 1000);
561    return () => clearTimeout(timer);
562  }, [records.length, currentRoute, desktopNeedsDetailsPreload]);
563
564  useEffect(() => {
565    const handleScroll = () => {
566      setIsScrolled(window.scrollY > 10);
567      setShowBackToTop(window.scrollY > 500);
568    };
569    handleScroll();
570    window.addEventListener('scroll', handleScroll);
571    return () => window.removeEventListener('scroll', handleScroll);
572  }, []);
573
574  // Precompute a normalized search blob per record once per data load (#456),
575  // so the per-keystroke filter normalizes only the term and runs one
576  // includes() per record instead of re-normalizing every field every keystroke.
577  const queryRecords = useMemo(
578    () => intersectByRecordIds(records, filters.recordIds),
579    [records, filters.recordIds]
580  );
581
582  const queryFacets = useMemo(
583    () => filters.recordIds === null ? facets : deriveFacetsForRecords(facets, queryRecords, filters),
584    [facets, queryRecords, filters.recordIds, filters.categories, filters.era]
585  );
586
587  const searchIndex = useMemo(() => queryRecords.map(buildSearchText), [queryRecords]);
588
589  // Lazy article and social full-text indexes (#276, #669), unioned with the
590  // substring blob above. Loaded on first non-empty search so browse-only visits
591  // never pay for MiniSearch or either artifact. Successful siblings remain in
592  // use while a missing artifact retries after a cooldown. miniRevision re-runs
593  // the filter whenever the available index set changes.
594  const miniRefs = useRef([]);
595  const miniLoading = useRef(false);
596  const miniMounted = useRef(true);
597  const miniRetryAfter = useRef(0);
598  const miniRetryAttempts = useRef(0);
599  const miniRetryExhausted = useRef(false);
600  const miniRetryTimer = useRef(null);
601  const [miniRevision, setMiniRevision] = useState(0);
602  const [miniComplete, setMiniComplete] = useState(false);
603  const [miniRetryTick, setMiniRetryTick] = useState(0);
604
605  useEffect(() => {
606    miniMounted.current = true;
607    return () => {
608      miniMounted.current = false;
609      if (miniRetryTimer.current !== null) clearTimeout(miniRetryTimer.current);
610    };
611  }, []);
612
613  useEffect(() => {
614    if (
615      !filters.search.trim()
616      || miniComplete
617      || miniLoading.current
618      || miniRetryExhausted.current
619    ) return;
620    // A persistently missing index (for example, during a staggered deploy)
621    // should not be refetched on every keystroke. The timer triggers a real
622    // retry after the cooldown even when the query itself has not changed.
623    if (performance.now() < miniRetryAfter.current) return;
624
625    const scheduleRetry = () => {
626      if (miniRetryAttempts.current >= MAX_MINI_INDEX_RETRIES) {
627        miniRetryExhausted.current = true;
628        return;
629      }
630      miniRetryAttempts.current += 1;
631      miniRetryAfter.current = performance.now() + 10000;
632      if (miniRetryTimer.current !== null) clearTimeout(miniRetryTimer.current);
633      miniRetryTimer.current = setTimeout(() => {
634        miniRetryTimer.current = null;
635        setMiniRetryTick(tick => tick + 1);
636      }, 10000);
637    };
638
639    miniLoading.current = true;
640    loadSearchIndex()
641      .then((result) => {
642        if (!miniMounted.current) return;
643        miniRefs.current = result.indexes;
644        setMiniRevision(revision => revision + 1);
645        setMiniComplete(result.complete);
646        if (result.complete) {
647          miniRetryAfter.current = 0;
648          miniRetryAttempts.current = 0;
649          miniRetryExhausted.current = false;
650          if (miniRetryTimer.current !== null) clearTimeout(miniRetryTimer.current);
651          miniRetryTimer.current = null;
652        } else {
653          scheduleRetry();
654        }
655      })
656      .catch((err) => {
657        if (!miniMounted.current) return;
658        scheduleRetry();
659        console.warn('[search] full-text index unavailable; substring search only:', err.message);
660      })
661      .finally(() => { miniLoading.current = false; });
662  }, [filters.search, miniComplete, miniRetryTick]);
663
664  // Turning the toggle on loads the vectors and the model before the reader
665  // types, and tells us how many articles the semantic leg covers. A failure
666  // here is not fatal: the toggle drops back to off with a "not available"
667  // line, and search stays lexical.
668  const handleSemanticToggle = useCallback((next) => {
669    // Relevance is only listed while a query is active, so the sort follows the
670    // toggle only when there is something to rank. Selecting it with an empty
671    // search box would leave the sort control blank.
672    const hasQuery = Boolean(filters.search.trim());
673    setSemanticEnabled(next);
674    setSemanticHits(EMPTY_SEMANTIC_HITS);
675    setSortBy(prev => sortForSemanticToggle(prev, { enabled: next, hasQuery }));
676    if (!next) {
677      setSemanticStatus('idle');
678      // Give back the model's memory. The browser keeps the download cached, so
679      // turning it on again reloads from disk rather than from the network.
680      terminateSemanticSearch();
681      return;
682    }
683    setSemanticStatus('loading');
684    warmupSemanticSearch()
685      .then((result) => {
686        setSemanticCoverage(result.count);
687        setSemanticStatus('ready');
688      })
689      .catch((err) => {
690        // Terminating the worker aborts this request. That is the reader
691        // switching the toggle off, not a failure to report.
692        if (err.name === 'AbortError') return;
693        console.warn('[search] semantic search unavailable; keyword search only:', err.message);
694        setSemanticEnabled(false);
695        setSemanticStatus('error');
696        setSortBy(prev => sortForSemanticToggle(prev, { enabled: false, hasQuery }));
697      });
698  }, [filters.search]);
699
700  // One encode per settled query. The request is dropped, not cancelled, when
701  // the reader types again: the worker keeps the model it already loaded.
702  useEffect(() => {
703    const query = filters.search.trim();
704    if (!semanticEnabled || !query) {
705      setSemanticHits(EMPTY_SEMANTIC_HITS);
706      return undefined;
707    }
708    const controller = new AbortController();
709    const timer = setTimeout(() => {
710      setSemanticStatus(prev => (prev === 'ready' ? 'searching' : prev));
711      requestSemanticSearch(query, { signal: controller.signal })
712        .then((result) => {
713          setSemanticHits({ query, ids: result.matches.map(match => match.id) });
714          setSemanticCoverage(result.count);
715          setSemanticStatus('ready');
716        })
717        .catch((err) => {
718          if (err.name === 'AbortError') return;
719          console.warn('[search] semantic query failed; keyword results only:', err.message);
720          setSemanticHits(EMPTY_SEMANTIC_HITS);
721          setSemanticStatus('error');
722          setSemanticEnabled(false);
723          // Same recovery as a failed warmup: the semantic leg is gone, so the
724          // sort it selected goes with it.
725          setSortBy(prev => sortForSemanticToggle(prev, { enabled: false, hasQuery: true }));
726        });
727    }, SEMANTIC_QUERY_DEBOUNCE_MS);
728    return () => {
729      clearTimeout(timer);
730      controller.abort();
731    };
732  }, [semanticEnabled, filters.search]);
733
734  // Fused hybrid ranking (#279). The lexical leg is the MiniSearch hit order,
735  // the semantic leg is the cosine order, and RRF merges them on rank alone
736  // because BM25 scores and cosine similarities are not on a shared scale.
737  // `signals` carries each hit's provenance chip: kw, sem, or kw·sem.
738  const { fusedRanks, searchSignals, semanticIds, lexicalIds } = useMemo(() => {
739    const rawTerm = filters.search.trim();
740    const lexicalOrder = rawTerm && miniRevision > 0 && miniRefs.current.length > 0
741      ? searchLoadedIndexes(miniRefs.current, rawTerm).map(hit => hit.id)
742      : [];
743    const semanticOrder = semanticEnabled && semanticHits.query === rawTerm
744      ? semanticHits.ids
745      : [];
746    return buildSearchRanking({ lexicalOrder, semanticOrder });
747  }, [filters.search, miniRevision, semanticEnabled, semanticHits]);
748
749  const filteredRecords = useMemo(() => {
750    const term = normalizeForSearch(filters.search);
751    // Union the substring blob with both MiniSearch full-text indexes: a record
752    // matches if any path matches. Additive by design, so records absent from a
753    // prebuilt index stay covered by substring, and nothing regresses while the
754    // indexes are still loading. combineWith AND + prefix = "every word present,
755    // last word may be a prefix", how a search box is expected to narrow.
756    const rawTerm = filters.search.trim();
757    const parsedQuery = parseSearchQuery(rawTerm);
758    // The semantic leg is a third additive path: it can surface a record whose
759    // words never match the query at all, which is the whole point of #279.
760    let res = queryRecords.filter((r, i) => {
761      const substringMatch = parsedQuery.phraseKeys.length > 0
762        ? matchesParsedSearchText(searchIndex[i], parsedQuery)
763        : searchIndex[i].includes(term);
764      if (term && !(
765        substringMatch
766        || (lexicalIds && lexicalIds.has(r.id))
767        || semanticIds.has(r.id)
768      )) return false;
769
770      if (filters.categories.length > 0) {
771        const hasAll = filters.categories.every(cat => r.categories.includes(cat));
772        if (!hasAll) return false;
773      }
774
775      if (filters.year) {
776        if (r.year !== filters.year) return false;
777      } else if (filters.era) {
778        if (r.era !== filters.era) return false;
779      }
780
781      if (filters.type === 'article') {
782         if (r.type === 'social') return false;
783      } else if (filters.type) {
784         if (r.type !== 'social') return false;
785         const platform = filters.type;
786         if (platform === 'twitter' && !r.pub.toLowerCase().includes('twitter') && !r.pub.toLowerCase().includes('x.com')) return false;
787         if (platform === 'bluesky' && !r.pub.toLowerCase().includes('bluesky')) return false;
788      }
789
790      // Note: Short reply posts and thread members are now filtered during data export.
791      // No need for title-based reply filtering here.
792
793      return true;
794    });
795
796    if (sortBy === RELEVANCE_SORT) {
797      // Fused rank first, then newest-first for everything the ranked legs did
798      // not reach (a substring-only match carries no rank to fuse).
799      return orderByFusedRank(sortRecords(res, DEFAULT_SORT), fusedRanks);
800    }
801
802    res = sortRecords(res, sortBy);
803
804    return res;
805  }, [queryRecords, searchIndex, filters, sortBy, lexicalIds, semanticIds, fusedRanks]);
806
807  useEffect(() => {
808    setCurrentPage(1);
809  }, [filters, sortBy]);
810
811  // Relevance needs a query to rank against, and the sort control only offers
812  // it while one is active. Clearing the search returns to newest-first so the
813  // control never shows a value it no longer lists. Starting a fresh query with
814  // semantic search on picks relevance back up, which is what the toggle is
815  // for; editing an active query leaves the reader's chosen sort alone.
816  const hadQueryRef = useRef(false);
817  useEffect(() => {
818    const hasQuery = Boolean(filters.search.trim());
819    setSortBy(prev => sortForQueryChange(prev, {
820      hasQuery,
821      hadQuery: hadQueryRef.current,
822      semanticEnabled,
823    }));
824    hadQueryRef.current = hasQuery;
825  }, [filters.search, semanticEnabled]);
826
827  // Derive viewMode from route for backward-compatible logic
828  const viewMode = currentRoute === ROUTES.folders
829    || (currentRoute === ROUTES.desktop && desktopAppId === 'folders')
830    ? 'folder'
831    : 'grid';
832
833  useEffect(() => {
834    if ((filters.search || filters.year) && viewMode === 'folder') {
835      if (currentRoute === ROUTES.desktop) navigateToDesktop('archive');
836      else navigateTo(ROUTES.archive);
837    }
838  }, [filters.search, filters.year, currentRoute, viewMode]);
839
840  const totalPages = Math.ceil(filteredRecords.length / ITEMS_PER_PAGE);
841  const paginatedRecords = filteredRecords.slice(
842    (currentPage - 1) * ITEMS_PER_PAGE,
843    currentPage * ITEMS_PER_PAGE
844  );
845
846  useEffect(() => {
847    const timer = window.setTimeout(() => {
848      setAnnouncedResultCount(loading
849        ? 'Loading archive'
850        : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}`);
851    }, 300);
852    return () => window.clearTimeout(timer);
853  }, [loading, filteredRecords.length]);
854
855  const folderGroups = useMemo(() => {
856    const counts = {};
857    filteredRecords.forEach(r => {
858      r.categories.forEach(c => counts[c] = (counts[c] || 0) + 1);
859    });
860    return Object.keys(counts)
861      .filter(cat => counts[cat] >= 10)
862      .sort((a, b) => counts[b] - counts[a])
863      .slice(0, 6)
864      .map(cat => ({ name: cat, count: counts[cat] }));
865  }, [filteredRecords]);
866
867  const handleFolderClick = (category) => {
868    setFilters(prev => ({ ...prev, categories: [category] }));
869    if (currentRoute === ROUTES.desktop) navigateToDesktop('archive');
870    else navigateTo(ROUTES.archive);
871  };
872
873  const handleModalNav = (direction) => {
874    if (!selectedRecordId) return;
875    const idx = filteredRecords.findIndex(r => r.id === selectedRecordId);
876    if (idx === -1) return;
877
878    if (direction === 'next' && idx < filteredRecords.length - 1) {
879      setSelectedRecordId(filteredRecords[idx + 1].id);
880    } else if (direction === 'prev' && idx > 0) {
881      setSelectedRecordId(filteredRecords[idx - 1].id);
882    }
883  };
884
885  const handlePageChange = (newPage) => {
886    setCurrentPage(newPage);
887    if (recordsRef.current) {
888        const offset = 100;
889        const top = recordsRef.current.getBoundingClientRect().top + window.scrollY - offset;
890        window.scrollTo({ top, behavior: 'smooth' });
891    }
892  };
893
894  const handleYearSelect = useCallback((year) => {
895    setFilters(prev => ({ ...prev, year, era: null }));
896  }, []);
897
898  const closeSidebar = useCallback(() => {
899    setSidebarOpen(false);
900    requestAnimationFrame(() => filterTriggerRef.current?.focus({ preventScroll: true }));
901  }, []);
902
903  const years = records.map(r => parseInt(r.year)).filter(y => !isNaN(y));
904  const minYear = years.length ? Math.min(...years) : 0;
905  const maxYear = years.length ? Math.max(...years) : 0;
906  const releaseDate = releaseMetadata ? formatReleaseDate(releaseMetadata.updated) : '';
907  const updateNotice = updateAvailable && html`
908    <div className="archive-update-notice" role="status">
909      <span>A new archive release is available.</span>
910      <button type="button" onClick=${() => window.location.reload()}>Reload archive</button>
911    </div>
912  `;
913
914  // One <RecordView> element for the default archive route. RecordView owns the
915  // selected-record lookup and prev/next nav math that App.js used to compute
916  // inline and pass to two identical <RecordModal> call sites. #134 Step B.
917  const recordView = html`
918    <${RecordView}
919      records=${records}
920      filteredRecords=${filteredRecords}
921      selectedRecordId=${selectedRecordId}
922      onClose=${() => selectRecord(null)}
923      onNext=${() => handleModalNav('next')}
924      onPrev=${() => handleModalNav('prev')}
925      onSelectRecord=${selectRecord}
926      onFilterCategory=${handleFilterCategory}
927      onFilterSearch=${handleFilterSearch}
928      onSelectEntity=${handleRecordEntitySelect}
929      onReportProblem=${handleRecordProblemReport}
930      nestedDialogOpen=${bugReportOpen}
931    />
932  `;
933
934  // One toggle state, two places to reach it: the filter sidebar search group
935  // and the compact search box above the results. Both copies can sit in the
936  // accessibility tree at once (the filter drawer over the mobile search box),
937  // so neither carries a live region: the results toolbar announces the state
938  // once for both.
939  const semanticSearchProps = {
940    enabled: semanticEnabled,
941    status: semanticStatus,
942    coverage: semanticCoverage,
943    onToggle: handleSemanticToggle,
944  };
945
946  const isEntityBrowser = currentRoute === ROUTES.entities;
947  const isWiki = currentRoute === ROUTES.wiki;
948  const isAnalytics = currentRoute === ROUTES.analytics;
949  const isArchiveGrid = currentRoute === ROUTES.archive || currentRoute === ROUTES.folders;
950
951  const activeFilterCount = (filters.search ? 1 : 0) +
952    filters.categories.length +
953    (filters.era ? 1 : 0) +
954    (filters.year ? 1 : 0) +
955    (filters.type ? 1 : 0) +
956    (filters.recordIds !== null ? 1 : 0);
957
958  const contentTypeLabel = CONTENT_TYPE_OPTIONS.find(option => option.value === filters.type)?.label;
959
960  // Class of 1986 (#754): searching the dissertation year, and nothing else,
961  // surfaces one note. Changing the search brings the note back for the next
962  // person who lands on that exact query.
963  const dissertationYearSearch = isDissertationYearQuery(filters.search);
964  useEffect(() => {
965    if (!dissertationYearSearch) setYearNoteDismissed(false);
966  }, [dissertationYearSearch]);
967
968  // The skip is short and ends itself, so nothing keeps moving on the page.
969  useEffect(() => {
970    if (!recordsSkipping) return undefined;
971    const timer = setTimeout(() => setRecordsSkipping(false), BROKEN_RECORD_SKIP_MS);
972    return () => clearTimeout(timer);
973  }, [recordsSkipping]);
974
975  const showYearNote = isArchiveGrid && dissertationYearSearch && !yearNoteDismissed;
976  // The broken-record note stands on its own until the visitor closes it. It
977  // used to go away as soon as the category left the filter, but five clicks on
978  // one chip leave that filter off again, so the note was cleared in the same
979  // commit that set it and the line was gone before anyone could read it. The
980  // line is a remark about the category, not a description of the current view,
981  // so it reads either way.
982  const showBrokenRecordNote = isArchiveGrid && Boolean(brokenRecord);
983
984  const activeScopeTokens = [
985    filters.search ? { key: 'search', kind: 'search', label: `Search: “${filters.search}”` } : null,
986    ...filters.categories.map(category => ({
987      key: `category-${category}`,
988      kind: 'category',
989      value: category,
990      label: category,
991    })),
992    filters.era ? { key: 'era', kind: 'era', label: filters.era } : null,
993    filters.year ? { key: 'year', kind: 'year', label: filters.year } : null,
994    filters.type ? { key: 'type', kind: 'type', label: contentTypeLabel || filters.type } : null,
995    filters.recordIds !== null ? { key: 'query', kind: 'recordIds', label: 'Query results' } : null,
996  ].filter(Boolean);
997  const activeScopeKey = activeScopeTokens.map(token => token.key).join('|');
998
999  const clearScopeToken = (token) => {
1000    const tokenIndex = activeScopeTokens.findIndex(candidate => candidate.key === token.key);
1001    pendingScopeFocusKey.current = activeScopeTokens[tokenIndex + 1]?.key
1002      || activeScopeTokens[tokenIndex - 1]?.key
1003      || 'status';
1004    setFilters(prev => {
1005      if (token.kind === 'category') {
1006        return { ...prev, categories: prev.categories.filter(category => category !== token.value) };
1007      }
1008      if (token.kind === 'search') return { ...prev, search: '' };
1009      if (token.kind === 'era') return { ...prev, era: null };
1010      if (token.kind === 'year') return { ...prev, year: null };
1011      if (token.kind === 'type') return { ...prev, type: null };
1012      if (token.kind === 'recordIds') return { ...prev, recordIds: null };
1013      return prev;
1014    });
1015  };
1016
1017  const clearAllScope = () => {
1018    pendingScopeFocusKey.current = 'status';
1019    setFilters({ ...DEFAULT_FILTERS });
1020  };
1021
1022  useEffect(() => {
1023    if (!pendingScopeFocusKey.current) return undefined;
1024    const frame = requestAnimationFrame(() => {
1025      const focusKey = pendingScopeFocusKey.current;
1026      pendingScopeFocusKey.current = null;
1027      const focusTarget = focusKey === 'status'
1028        ? scopeStatusRef.current
1029        : scopeTokenRefs.current.get(focusKey);
1030      focusTarget?.focus({ preventScroll: true });
1031    });
1032    return () => cancelAnimationFrame(frame);
1033  }, [activeScopeKey]);
1034
1035  const desktopArchiveView = {
1036    viewMode,
1037    loading,
1038    error,
1039    filters,
1040    setFilters,
1041    facets: queryFacets,
1042    autocompleteIndex,
1043    sortBy,
1044    setSortBy,
1045    filteredRecords,
1046    paginatedRecords,
1047    folderGroups,
1048    currentPage,
1049    totalPages,
1050    activeFilterCount,
1051    onSetViewMode: (mode) => navigateToDesktop(mode === 'folder' ? 'folders' : 'archive'),
1052    onSelectRecord: selectRecord,
1053    onOpenFolder: handleFolderClick,
1054    onPageChange: handlePageChange,
1055    onClearFilters: () => setFilters({ ...DEFAULT_FILTERS }),
1056    onOpenStandard: (mode = viewMode) => goTo(mode === 'folder' ? ROUTES.folders : ROUTES.archive),
1057  };
1058
1059  const desktopEntityView = {
1060    records: queryRecords,
1061    queryActive: filters.recordIds !== null,
1062    onClearQuery: clearQueryFilter,
1063    loading,
1064    error,
1065    onSelectRecord: selectRecord,
1066    selectedEntityId,
1067    onSelectEntity: setSelectedEntityId,
1068    autoFocusSelection: !desktopRecordOverlayOpen,
1069    onOpenStandard: () => goTo(ROUTES.entities),
1070  };
1071
1072  const desktopDissertationView = {
1073    onOpenStandard: () => goTo(ROUTES.dissertation),
1074  };
1075
1076  const desktopAnalyticsView = {
1077    onRecordResults: handleDesktopQueryResults,
1078    onOpenStandard: () => goTo(ROUTES.analytics),
1079  };
1080
1081  const desktopStartView = {
1082    records,
1083    loading,
1084    error,
1085    onNavigate: handleDesktopGuideNavigate,
1086    onSelectRecord: selectRecord,
1087    onOpenBugReport: () => openBugReport('problem'),
1088    onOpenStandard: () => goTo(ROUTES.start),
1089  };
1090
1091  // Keep global overlays mounted on full-page routes. Returning a page directly
1092  // here used to strand any full-page action that opened the bug report modal,
1093  // because the modal only existed in the archive shell below.
1094  const renderFullPage = (page, routeOverlay = null) => html`
1095    <div className="min-h-screen flex flex-col archive-canvas">
1096      ${updateNotice}
1097      ${page}
1098      ${routeOverlay}
1099      <${BugReportModal}
1100        isOpen=${bugReportOpen}
1101        onClose=${() => setBugReportOpen(false)}
1102        endpoint=${REPORT_CONFIG.endpoint}
1103        initialIntent=${bugReportIntent}
1104        initialFields=${bugReportInitialFields}
1105      />
1106    </div>
1107  `;
1108
1109  // Full-page routes: desktop, Start here, dissertation, about, analytics
1110  if (currentRoute === ROUTES.desktop) {
1111    const desktopFallback = html`
1112      <main id="main-content" className="min-h-screen px-4 py-16 text-white" style=${{ backgroundColor: '#0b6867' }}>
1113        <div className="mx-auto max-w-lg border-2 border-white/70 bg-black/20 p-6">
1114          <p className="font-display text-xl" role="status">Opening the archive desktop...</p>
1115          <button
1116            type="button"
1117            onClick=${() => goTo(ROUTES.archive)}
1118            className="mt-5 border-2 border-white px-4 py-3 font-body text-sm font-bold focus:outline-none focus:ring-2 focus:ring-amber-300"
1119          >
1120            Return to the standard archive
1121          </button>
1122        </div>
1123      </main>
1124    `;
1125
1126    return renderFullPage(html`
1127      <${DesktopRouteErrorBoundary} onExit=${() => goTo(ROUTES.archive)}>
1128        <${Suspense} fallback=${desktopFallback}>
1129          <${DesktopShell}
1130            activeAppId=${desktopAppId}
1131            autoFocusWindow=${!desktopRecordOverlayOpen}
1132            onSelectApp=${goToDesktop}
1133            onNavigate=${goTo}
1134            onOpenBugReport=${() => openBugReport('problem')}
1135            onExit=${() => goTo(ROUTES.archive)}
1136            onOpenAppsChange=${setDesktopOpenAppIds}
1137            archiveView=${desktopArchiveView}
1138            analyticsView=${desktopAnalyticsView}
1139            dissertationView=${desktopDissertationView}
1140            entityView=${desktopEntityView}
1141            startView=${desktopStartView}
1142          />
1143        <//>
1144      <//>
1145    `, desktopActiveNeedsRecords ? recordView : null);
1146  }
1147
1148  if (currentRoute === ROUTES.start) {
1149    const handleStartRecordSelect = (recordOrId) => {
1150      const id = typeof recordOrId === 'string' ? recordOrId : recordOrId?.id;
1151      if (!id || !records.some(record => record.id === id)) return;
1152      navigateTo(ROUTES.archive, id);
1153      selectRecord(id);
1154    };
1155
1156    return renderFullPage(html`
1157      <${StartHerePage}
1158        onBack=${() => goTo(ROUTES.archive)}
1159        records=${records}
1160        onNavigate=${goTo}
1161        onOpenBugReport=${() => openBugReport('problem')}
1162        onSelectRecord=${handleStartRecordSelect}
1163      />
1164    `);
1165  }
1166
1167  if (currentRoute === ROUTES.dissertation) {
1168    return renderFullPage(html`<${DissertationPage} onBack=${() => goTo(ROUTES.archive)} />`);
1169  }
1170
1171  if (currentRoute === ROUTES.about) {
1172    return renderFullPage(html`
1173      <${AboutPage}
1174        onBack=${() => goTo(ROUTES.archive)}
1175        onStart=${() => goTo(ROUTES.start)}
1176        onParticipate=${() => { window.location.href = resolveSitePath('features/participate/'); }}
1177        records=${records}
1178      />
1179    `);
1180  }
1181
1182  // Hidden route (#754). Nothing links here; it is reached only by typing the
1183  // hash. It renders a normal full page so anyone who finds it can read it,
1184  // focus it, and leave it.
1185  if (currentRoute === ROUTES.nowhere) {
1186    return renderFullPage(html`
1187      <${NowherePage}
1188        onBack=${() => goTo(ROUTES.archive)}
1189        onOpenEssay=${openNowhereEssay}
1190        essayHref=${canonicalRecordUrl(window.location.href, NOWHERE_RECORD_ID)}
1191      />
1192    `);
1193  }
1194
1195  if (isAnalytics) {
1196    return renderFullPage(html`
1197      <${AnalyticsDashboard}
1198        onBack=${() => goTo(ROUTES.archive)}
1199        onRecordResults=${handleQueryResults}
1200      />
1201    `);
1202  }
1203
1204  // Shared fail-loud panel for the record-backed routes. fetchCoreData throws
1205  // on a core-data outage (#290), so both the archive grid and the entity
1206  // browser depend on the same load; render the same error on each instead of
1207  // letting the entity route show an empty browser with no explanation (#369).
1208  const errorPanel = error && html`
1209    <div className="archive-error-state" role="alert">
1210        <${AlertCircle} className="archive-error-state__icon" aria-hidden="true" />
1211        <p className="archive-section-label">Archive status</p>
1212        <h3>Error loading archive</h3>
1213        <p>${error}</p>
1214        <button
1215            type="button"
1216            onClick=${() => window.location.reload()}
1217            className="archive-action archive-action--danger"
1218        >
1219            Reload page
1220        </button>
1221    </div>
1222  `;
1223
1224  return html`
1225    <div className="min-h-screen flex flex-col">
1226      <${WelcomeModal}
1227        onStart=${() => goTo(ROUTES.start)}
1228      />
1229
1230      <${ToolsModal}
1231        isOpen=${toolsModalOpen}
1232        onClose=${() => setToolsModalOpen(false)}
1233        onSelectTool=${handleToolSelect}
1234      />
1235
1236      <${BugReportModal}
1237        isOpen=${bugReportOpen}
1238        onClose=${() => setBugReportOpen(false)}
1239        endpoint=${REPORT_CONFIG.endpoint}
1240        initialIntent=${bugReportIntent}
1241        initialFields=${bugReportInitialFields}
1242      />
1243
1244      ${recordView}
1245
1246      <${WorkInProgressBanner} />
1247      ${updateNotice}
1248
1249      <header className=${`archive-site-header sticky top-0 z-50 w-full transition-all duration-300 ${
1250          isScrolled
1251            ? 'archive-site-header--scrolled'
1252            : ''
1253      }`}>
1254        <div className="container mx-auto px-4 h-16 flex items-center justify-between">
1255            <button
1256              onClick=${() => goTo(ROUTES.archive)}
1257              className="archive-site-header__brand"
1258              aria-label="Return to archive home"
1259            >
1260                <div className="archive-site-header__mark">
1261                    <${Newspaper} className="w-5 h-5" />
1262                </div>
1263                <h1 className="text-lg md:text-xl font-display font-bold text-stone-900 tracking-tight hidden sm:block">
1264                    Jay Rosen's Internet Archive
1265                </h1>
1266                <h1 className="text-lg font-display font-bold text-stone-900 sm:hidden">JRIA</h1>
1267            </button>
1268
1269            <div className="archive-site-header__stats hidden md:flex items-center gap-6 px-6 h-full">
1270                <div className="flex flex-col leading-tight">
1271                    <span className="font-bold text-stone-900">${records.length}</span>
1272                    <span>records</span>
1273                </div>
1274                <div className="h-6 w-px bg-stone-200"></div>
1275                <div className="flex flex-col leading-tight">
1276                    <span className="font-bold text-stone-900">${minYear}–${maxYear}</span>
1277                    <span>timeline</span>
1278                </div>
1279            </div>
1280
1281            <div className="flex items-center gap-4">
1282                <button
1283                  onClick=${() => setToolsModalOpen(true)}
1284                  className="archive-action archive-action--quiet archive-site-header__action archive-site-header__tools sm:hidden"
1285                  aria-label="Tools"
1286                >
1287                    <${Compass} className="w-5 h-5" />
1288                </button>
1289                <button
1290                  onClick=${() => goTo(ROUTES.about)}
1291                  className="archive-action archive-action--quiet archive-site-header__action archive-site-header__about hidden md:flex"
1292                  aria-label="About"
1293                >
1294                    <${Info} className="w-4 h-4" />
1295                    <span>About</span>
1296                </button>
1297                <button
1298                  onClick=${() => openBugReport('problem')}
1299                  className="archive-action archive-action--quiet archive-site-header__action"
1300                  aria-label="Report a bug"
1301                  title="Report a bug"
1302                >
1303                    <${Bug} className="w-4 h-4" />
1304                    <span className="hidden md:inline">Report a bug</span>
1305                </button>
1306                <a href="https://github.com/jamditis" target="_blank" rel="noreferrer" className="archive-site-header__credit">
1307                    Curated by Joe Amditis
1308                </a>
1309                ${isArchiveGrid && html`
1310                    <button
1311                    type="button"
1312                    ref=${filterTriggerRef}
1313                    onClick=${() => setSidebarOpen(true)}
1314                    className="archive-site-header__filter lg:hidden p-2 text-stone-800 hover:bg-stone-100 rounded-md border border-stone-300 relative"
1315                    aria-label="Open archive filters"
1316                    aria-expanded=${sidebarOpen}
1317                    aria-controls="archive-filters"
1318                    >
1319                        <${SlidersHorizontal} className="w-5 h-5" />
1320                        ${activeFilterCount > 0 && html`
1321                          <span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center font-bold">
1322                            ${activeFilterCount}
1323                          </span>
1324                        `}
1325                    </button>
1326                `}
1327            </div>
1328        </div>
1329      </header>
1330
1331      <div className=${`archive-discovery-layout flex-grow container mx-auto px-4 py-6 flex gap-8 ${isEntityBrowser || isWiki ? 'justify-center' : ''}`}>
1332
1333         ${isArchiveGrid && html`
1334             <${Sidebar}
1335                facets=${queryFacets}
1336                filters=${filters}
1337                setFilters=${setFilters}
1338                isOpen=${sidebarOpen}
1339                onClose=${closeSidebar}
1340                resetFilters=${() => setFilters({ ...DEFAULT_FILTERS })}
1341                autocompleteIndex=${autocompleteIndex}
1342                semanticSearch=${semanticSearchProps}
1343                onCategoryStreak=${handleCategoryStreak}
1344             />
1345         `}
1346
1347         <main
1348           id="main-content"
1349           data-route-entry-focus
1350           tabIndex="-1"
1351           className="flex-grow min-w-0 flex flex-col outline-none"
1352         >
1353
1354            ${isArchiveGrid && html`
1355                <div className="archive-discovery-intro">
1356                    ${activeFilterCount === 0 && html`
1357                        <section className="archive-tools-strip archive-density--compact mb-6" aria-labelledby="homepage-tools-title">
1358                            <h2 id="homepage-tools-title" className="archive-folder-tab archive-tools-strip__tab"><span>Tools</span></h2>
1359                            <div className="archive-tools-strip__items">
1360                                <button
1361                                    onClick=${() => goTo(ROUTES.start)}
1362                                    className="archive-action archive-action--quiet archive-tools-strip__wide"
1363                                >
1364                                    <${Compass} className="w-3.5 h-3.5" aria-hidden="true" />
1365                                    Start here
1366                                </button>
1367                                <button
1368                                    onClick=${() => goTo(ROUTES.dissertation)}
1369                                    className="archive-action archive-action--quiet"
1370                                >
1371                                    <${BookOpen} className="w-3.5 h-3.5" aria-hidden="true" />
1372                                    Mind map
1373                                </button>
1374                                <a
1375                                    href=${resolveSitePath('dissertation/reader/')}
1376                                    className="archive-action archive-action--quiet archive-tools-strip__wide"
1377                                >
1378                                    <${BookMarked} className="w-3.5 h-3.5" aria-hidden="true" />
1379                                    Dissertation reader
1380                                </a>
1381                                <button
1382                                    onClick=${() => goTo(ROUTES.entities)}
1383                                    className="archive-action archive-action--quiet"
1384                                >
1385                                    <${Users} className="w-3.5 h-3.5" aria-hidden="true" />
1386                                    Entities
1387                                </button>
1388                                <button
1389                                    onClick=${() => goTo(ROUTES.analytics)}
1390                                    className="archive-action archive-action--quiet"
1391                                >
1392                                    <${BarChart3} className="w-3.5 h-3.5" aria-hidden="true" />
1393                                    Analytics
1394                                </button>
1395                                <a
1396                                    href=${resolveSitePath('faq/')}
1397                                    className="archive-action archive-action--quiet archive-tools-strip__wide"
1398                                >
1399                                    <${HelpCircle} className="w-3.5 h-3.5" aria-hidden="true" />
1400                                    FAQ
1401                                </a>
1402                                <button
1403                                    onClick=${() => setToolsModalOpen(true)}
1404                                    className="archive-action archive-action--quiet"
1405                                    aria-label="More tools"
1406                                    aria-haspopup="dialog"
1407                                >
1408                                    <${MoreHorizontal} className="w-3.5 h-3.5" aria-hidden="true" />
1409                                    More
1410                                </button>
1411                            </div>
1412                        </section>
1413                    `}
1414                </div>
1415            `}
1416
1417            ${isWiki && html`
1418                <${WikiPage} />
1419            `}
1420
1421            ${isArchiveGrid && html`
1422            <div className="archive-mobile-search">
1423                <label htmlFor="archive-mobile-search">Search archive</label>
1424                <div className="archive-mobile-search__control">
1425                    <${Search} className="archive-mobile-search__icon" aria-hidden="true" />
1426                    <input
1427                      id="archive-mobile-search"
1428                      type="search"
1429                      value=${filters.search}
1430                      onChange=${event => setFilters(prev => ({ ...prev, search: event.target.value }))}
1431                      placeholder="Keywords or title"
1432                      className="archive-control"
1433                    />
1434                    ${filters.search && html`
1435                      <button
1436                        type="button"
1437                        onClick=${() => setFilters(prev => ({ ...prev, search: '' }))}
1438                        aria-label="Clear archive search"
1439                      >
1440                        <${XCircle} aria-hidden="true" />
1441                      </button>
1442                    `}
1443                </div>
1444                <${SemanticSearchToggle}
1445                  ...${semanticSearchProps}
1446                  inputId="archive-mobile-semantic-search"
1447                />
1448            </div>
1449
1450            ${showYearNote && html`
1451              <${EasterEggNote}
1452                text=${DISSERTATION_YEAR_NOTE}
1453                linkHref=${resolveSitePath('dissertation/reader/')}
1454                linkLabel="Read the dissertation"
1455                onDismiss=${() => setYearNoteDismissed(true)}
1456              />
1457            `}
1458
1459            ${showBrokenRecordNote && html`
1460              <${EasterEggNote}
1461                text=${brokenRecordLine(brokenRecord)}
1462                onDismiss=${() => setBrokenRecord(null)}
1463              />
1464            `}
1465
1466            <div className="archive-results-toolbar scroll-mt-24" ref=${recordsRef}>
1467                <div className="archive-results-summary">
1468                    <span
1469                      ref=${scopeStatusRef}
1470                      tabIndex="-1"
1471                      className="archive-results-count"
1472                    >
1473                      ${loading
1474                        ? 'Loading archive...'
1475                        : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}${filters.recordIds !== null ? ' in query results' : ''}`}
1476                    </span>
1477                    ${releaseMetadata && html`
1478                      <span className="archive-results__updated">
1479                        Site updated <time dateTime=${releaseMetadata.updated}>${releaseDate}</time>
1480                      </span>
1481                    `}
1482                    <span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
1483                      ${announcedResultCount}
1484                    </span>
1485                    <span
1486                      className="sr-only"
1487                      data-semantic-status
1488                      role="status"
1489                      aria-live="polite"
1490                      aria-atomic="true"
1491                    >
1492                      ${semanticStatusMessage(semanticSearchProps)}
1493                    </span>
1494                    ${activeScopeTokens.length > 0 && html`
1495                      <div className="archive-results-scope" aria-label="Active archive scope">
1496                        ${activeScopeTokens.map(token => html`
1497                          <button
1498                            type="button"
1499                            key=${token.key}
1500                            ref=${element => {
1501                              if (element) scopeTokenRefs.current.set(token.key, element);
1502                              else scopeTokenRefs.current.delete(token.key);
1503                            }}
1504                            data-scope-key=${token.key}
1505                            className="archive-scope-token"
1506                            onClick=${() => clearScopeToken(token)}
1507                            aria-label=${`Remove ${token.label} filter`}
1508                          >
1509                            <span>${token.label}</span>
1510                            <${XCircle} aria-hidden="true" />
1511                          </button>
1512                        `)}
1513                        ${activeScopeTokens.length > 1 && html`
1514                          <button
1515                            type="button"
1516                            className="archive-scope-clear"
1517                            onClick=${clearAllScope}
1518                          >
1519                            Clear all
1520                          </button>
1521                        `}
1522                      </div>
1523                    `}
1524                    ${viewMode === 'folder' && html`
1525                      <p className="archive-folder-note">Records may appear in more than one folder.</p>
1526                    `}
1527                </div>
1528                <div className="archive-results-controls">
1529                   <div className="archive-view-switch" role="group" aria-label="Archive view">
1530                        <button
1531                          type="button"
1532                          onClick=${() => goTo(ROUTES.archive)}
1533                          className=${`archive-view-switch__button ${currentRoute === ROUTES.archive ? 'is-active' : ''}`}
1534                          aria-pressed=${currentRoute === ROUTES.archive}
1535                          aria-label="Cards view"
1536                          title="Cards view"
1537                        >
1538                            <${LayoutGrid} className="w-3 h-3" /> <span className="hidden sm:inline">Cards</span>
1539                        </button>
1540                        <button
1541                          type="button"
1542                          onClick=${() => goTo(ROUTES.folders)}
1543                          className=${`archive-view-switch__button ${currentRoute === ROUTES.folders ? 'is-active' : ''}`}
1544                          aria-pressed=${currentRoute === ROUTES.folders}
1545                          aria-label="Folders view"
1546                          title="Folders view"
1547                        >
1548                            <${Folder} className="w-3 h-3" /> <span className="hidden sm:inline">Folders</span>
1549                        </button>
1550                    </div>
1551
1552                    ${viewMode === 'grid' && html`<label className="archive-sort-control" htmlFor="sort-select">
1553                        <span>Sort</span>
1554                        <select
1555                        id="sort-select"
1556                        aria-label="Sort archive records"
1557                        value=${sortBy}
1558                        onChange=${(e) => setSortBy(e.target.value)}
1559                        className="archive-control archive-sort-control__select"
1560                        >
1561                            ${Boolean(filters.search.trim()) && html`
1562                              <option value=${RELEVANCE_SORT}>Relevance</option>
1563                            `}
1564                            <option value="date-desc">Newest first</option>
1565                            <option value="date-asc">Oldest first</option>
1566                            <option value="title-asc">Title (A-Z)</option>
1567                        </select>
1568                    </label>`}
1569                </div>
1570            </div>
1571            `}
1572
1573            ${currentRoute === ROUTES.archive && !loading && !filters.search && !filters.era && filters.categories.length === 0 && html`
1574                <${Timeline}
1575                  records=${queryRecords}
1576                  selectedYear=${filters.year}
1577                  onSelectYear=${handleYearSelect}
1578                />
1579            `}
1580
1581            ${isEntityBrowser && html`
1582            <div className="flex flex-col sm:flex-row sm:items-center justify-between mb-6 gap-4 border-b border-stone-200 pb-4 scroll-mt-24">
1583                <div className="font-display text-stone-500 text-sm">
1584                    Browse entities and their connections
1585                </div>
1586            </div>
1587            `}
1588
1589            ${isEntityBrowser && errorPanel}
1590
1591            ${isEntityBrowser && !loading && !error && html`
1592                <${EntityBrowser}
1593                  records=${queryRecords}
1594                  queryActive=${filters.recordIds !== null}
1595                  onClearQuery=${clearQueryFilter}
1596                  onSelectRecord=${selectRecord}
1597                  selectedEntityId=${selectedEntityId}
1598                  onSelectEntity=${setSelectedEntityId}
1599                  autoFocusSelection=${!selectedRecordId}
1600                />
1601            `}
1602
1603            ${isArchiveGrid && html`
1604                <${ArchiveResults}
1605                  errorPanel=${errorPanel}
1606                  loading=${loading}
1607                  filteredRecords=${filteredRecords}
1608                  paginatedRecords=${paginatedRecords}
1609                  folderGroups=${folderGroups}
1610                  viewMode=${viewMode}
1611                  searchTerm=${filters.search}
1612                  searchSignals=${searchSignals}
1613                  currentPage=${currentPage}
1614                  totalPages=${totalPages}
1615                  onSelectRecord=${selectRecord}
1616                  onOpenFolder=${handleFolderClick}
1617                  onPageChange=${handlePageChange}
1618                  onClearFilters=${() => setFilters({ ...DEFAULT_FILTERS })}
1619                  skipping=${recordsSkipping}
1620                />
1621            `}
1622
1623            ${currentRoute === ROUTES.archive && !loading && activeFilterCount === 0 && html`
1624                <${FeaturedSection} />
1625            `}
1626         </main>
1627
1628      ${showBackToTop && html`
1629        <button
1630          onClick=${() => window.scrollTo({ top: 0, behavior: 'smooth' })}
1631          className="archive-back-to-top fixed bottom-6 right-6 z-40 p-3 bg-stone-800 text-white rounded-full shadow-lg hover:bg-stone-700 transition-all hover:scale-110"
1632          aria-label="Back to top"
1633        >
1634          <${ChevronUp} className="w-5 h-5" />
1635        </button>
1636      `}
1637      </div>
1638
1639      <footer className="archive-site-footer mt-auto">
1640        <div className="container mx-auto px-4 py-6">
1641          <div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8 text-sm text-stone-600">
1642            <div>
1643              <h4 className="archive-site-footer__heading">Jay Rosen's Internet Archive</h4>
1644              <p className="text-xs leading-relaxed">
1645                A curated public collection of the works, critiques, and teachings of Jay Rosen, professor of journalism at New York University.
1646              </p>
1647            </div>
1648            <div>
1649              <h4 className="archive-site-footer__heading">Sections</h4>
1650              <div className="archive-site-footer__links text-xs">
1651                <button onClick=${() => goTo(ROUTES.start)} className="archive-site-footer__link">Start here</button>
1652                <a href=${resolveSitePath('features/participate/')} className="archive-site-footer__link">Ways to participate</a>
1653                <button onClick=${() => goTo(ROUTES.archive)} className="archive-site-footer__link">Browse archive</button>
1654                <button onClick=${() => goTo(ROUTES.dissertation)} className="archive-site-footer__link">Dissertation mind map</button>
1655                <button onClick=${() => goTo(ROUTES.entities)} className="archive-site-footer__link">Entity browser</button>
1656                <button onClick=${() => goTo(ROUTES.analytics)} className="archive-site-footer__link">Analytics dashboard</button>
1657                <button onClick=${() => goTo(ROUTES.about)} className="archive-site-footer__link">About this archive</button>
1658                <a href=${getPrivacyDetailsHref()} className="archive-site-footer__link">Privacy and browser storage</a>
1659              </div>
1660            </div>
1661            <div>
1662              <h4 className="archive-site-footer__heading">Credits</h4>
1663              <p className="text-xs leading-relaxed mb-2">
1664                Curated by <a href="https://github.com/jamditis" target="_blank" rel="noreferrer" className="text-stone-900 font-bold hover:underline">Joe Amditis</a>
1665              </p>
1666              <p className="text-xs text-stone-600">
1667                ${records.length} records | ${minYear}–${maxYear}
1668              </p>
1669              ${releaseMetadata && html`
1670                <p className="archive-site-footer__release">
1671                  Last updated <time dateTime=${releaseMetadata.updated}>${releaseDate}</time>
1672                  <span aria-hidden="true"> · </span>
1673                  Version ${releaseMetadata.version}
1674                </p>
1675              `}
1676            </div>
1677          </div>
1678        </div>
1679      </footer>
1680    </div>
1681  `;
1682}
1683
1684export default App;

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.