Alchemical Hands in the Hypnerotomachia Poliphili

Marginalia, Scholarship & Reception

← All Scripts

Build Site

build_site.py — 4048 lines

Unified site generator: exports data.json, builds all HTML pages (scholars, dictionary, marginalia, bibliography, docs, code, about).

1"""Unified site builder: generates all static pages from SQLite.
2
3Replaces build_scholar_profiles.py and export_showcase_data.py.
4Generates:
5  - site/index.html           (marginalia gallery, updated nav)
6  - site/data.json             (gallery data with confidence flags)
7  - site/scholars.html         (scholars overview, DB-driven)
8  - site/scholar/*.html        (individual scholar pages)
9  - site/dictionary/index.html (dictionary landing)
10  - site/dictionary/*.html     (individual term pages)
11  - site/marginalia/*.html     (individual folio detail pages)
12  - site/about.html            (about page)
13"""
14
15import sqlite3
16import json
17import re
18import os
19from pathlib import Path
20from html import escape
21
22BASE_DIR = Path(__file__).resolve().parent.parent
23DB_PATH = BASE_DIR / "db" / "hp.db"
24SITE_DIR = BASE_DIR / "site"
25SUMMARIES_PATH = BASE_DIR / "scholars" / "summaries.json"
26
27# ============================================================
28# Shared HTML templates
29# ============================================================
30
31def slugify(text):
32    s = text.lower().strip()
33    s = re.sub(r'[^\w\s-]', '', s)
34    s = re.sub(r'[\s_]+', '-', s)
35    s = re.sub(r'-+', '-', s)
36    return s.strip('-')
37
38
39def nav_html(active='', prefix=''):
40    """Generate navigation bar. prefix is '' for root, '../' for subdirectories."""
41    links = [
42        (f'{prefix}index.html', 'Home', 'home'),
43        (f'{prefix}marginalia/index.html', 'Marginalia', 'marginalia'),
44        (f'{prefix}scholars.html', 'Scholars', 'scholars'),
45        (f'{prefix}bibliography.html', 'Bibliography', 'bibliography'),
46        (f'{prefix}dictionary/index.html', 'Dictionary', 'dictionary'),
47        (f'{prefix}docs/index.html', 'Docs', 'docs'),
48        (f'{prefix}code/index.html', 'Code', 'code'),
49        (f'{prefix}the-book.html', 'The Book', 'thebook'),
50        (f'{prefix}timeline.html', 'Timeline', 'timeline'),
51        (f'{prefix}woodcuts/index.html', 'Woodcuts', 'woodcuts'),
52        (f'{prefix}manuscripts/index.html', 'Manuscripts', 'manuscripts'),
53        (f'{prefix}digital-edition.html', 'Editions', 'edition'),
54        (f'{prefix}russell-alchemical-hands.html', 'Alchemical Hands', 'russell'),
55        (f'{prefix}concordance/index.html', 'Concordance', 'concordance'),
56        (f'{prefix}jonson/index.html', 'Ben Jonson', 'jonson'),
57        (f'{prefix}digby/index.html', 'Digby', 'digby'),
58        (f'{prefix}about.html', 'About', 'about'),
59    ]
60    items = []
61    for href, label, key in links:
62        cls = ' class="active"' if key == active else ''
63        items.append(f'<a href="{href}"{cls}>{label}</a>')
64    return f'<nav class="site-nav">{"".join(items)}</nav>'
65
66
67def review_badge_html(needs_review, source_method=None):
68    if not needs_review:
69        return ''
70    method = f' ({source_method})' if source_method else ''
71    return f'<span class="review-badge">Unreviewed{method}</span>'
72
73
74def confidence_badge_html(confidence):
75    if not confidence:
76        return ''
77    cls = f'confidence-{confidence.lower()}'
78    return f'<span class="confidence-badge {cls}">{confidence}</span>'
79
80
81def page_shell(title, body, active_nav='', extra_css='', extra_js='', depth=0):
82    """Generate full HTML page. depth=0 for site root, depth=1 for subdirectories."""
83    prefix = '../' * depth if depth > 0 else ''
84    return f"""<!DOCTYPE html>
85<html lang="en">
86<head>
87    <meta charset="UTF-8">
88    <meta name="viewport" content="width=device-width, initial-scale=1.0">
89    <title>{escape(title)} - Alchemical Hands in the Hypnerotomachia Poliphili</title>
90    <meta name="description" content="Alchemical hands in the marginalia of the Hypnerotomachia Poliphili (Venice, 1499). A digital scholarship platform based on James Russell's PhD thesis.">
91    <link rel="stylesheet" href="{prefix}style.css">
92    <link rel="stylesheet" href="{prefix}scholars.css">
93    <link rel="stylesheet" href="{prefix}components.css">
94    {extra_css}
95</head>
96<body>
97    <header>
98        <div class="header-content">
99            <h1><a href="{prefix}index.html" style="color:inherit;text-decoration:none">Alchemical Hands in the <em>Hypnerotomachia Poliphili</em></a></h1>
100            <p class="subtitle">Marginalia, Scholarship &amp; Reception</p>
101            {nav_html(active_nav, prefix)}
102        </div>
103    </header>
104    <main>
105{body}
106    </main>
107    <footer>
108        <div class="footer-content">
109            <div class="footer-section">
110                <h4>About This Project</h4>
111                <p>A digital scholarship platform exploring the alchemical
112                annotations and wider marginalia of the 1499 <em>Hypnerotomachia
113                Poliphili</em>, based on James Russell's PhD thesis (Durham, 2014).</p>
114            </div>
115            <div class="footer-section">
116                <h4>Data Provenance</h4>
117                <p>Content marked with <span class="review-badge" style="font-size:0.7rem">Unreviewed</span>
118                has been generated with LLM assistance and has not been
119                verified by a human expert.</p>
120            </div>
121        </div>
122    </footer>
123    {extra_js}
124</body>
125</html>"""
126
127
128# ============================================================
129# Topic badge helpers
130# ============================================================
131
132TOPIC_LABELS = {
133    'authorship': 'Authorship',
134    'architecture_gardens': 'Architecture & Gardens',
135    'text_image': 'Text & Image',
136    'reception': 'Reception',
137    'dream_religion': 'Dream & Religion',
138    'material_bibliographic': 'Material & Bibliographic',
139}
140
141
142def topic_badges_html(topics_str):
143    if not topics_str:
144        return ''
145    badges = []
146    for t in topics_str.split(','):
147        t = t.strip()
148        label = TOPIC_LABELS.get(t, t.replace('_', ' ').title())
149        badges.append(f'<span class="topic-badge topic-{t}">{label}</span>')
150    return ' '.join(badges)
151
152
153# ============================================================
154# Data export: data.json
155# ============================================================
156
157def export_data_json(conn):
158    """Export marginalia gallery data with confidence flags."""
159    cur = conn.cursor()
160    cur.execute("""
161        SELECT
162            a.id, a.thesis_page, a.signature_ref, m.shelfmark,
163            m.institution, m.city, dr.context_text, a.annotation_text,
164            a.thesis_chapter, i.filename, COALESCE(i.web_path, i.relative_path),
165            i.folio_number, i.side, mat.confidence,
166            sm.quire, sm.leaf_in_quire,
167            mat.needs_review as match_needs_review,
168            h.hand_label, h.attribution, h.is_alchemist,
169            a.annotation_type
170        FROM matches mat
171        JOIN annotations a ON mat.ref_id = a.id
172        JOIN images i ON mat.image_id = i.id
173        JOIN manuscripts m ON i.manuscript_id = m.id
174        LEFT JOIN dissertation_refs dr ON a.id = dr.id
175        LEFT JOIN signature_map sm ON LOWER(a.signature_ref) = LOWER(sm.signature)
176        LEFT JOIN annotator_hands h ON a.hand_id = h.id
177        WHERE i.page_type = 'PAGE'
178        GROUP BY a.signature_ref, i.filename
179        ORDER BY COALESCE(sm.folio_number, 999), a.thesis_page
180    """)
181
182    # Load folio descriptions for alchemist annotations
183    folio_descs = {}
184    try:
185        cur2 = conn.cursor()
186        cur2.execute("""
187            SELECT signature_ref, manuscript_shelfmark, hand_label,
188                   title, description, alchemical_element, alchemical_process,
189                   alchemical_framework, russell_page_ref
190            FROM folio_descriptions
191        """)
192        for fd in cur2.fetchall():
193            key = (fd[0], fd[1])
194            folio_descs[key] = {
195                'desc_title': fd[3], 'desc_text': fd[4],
196                'alch_element': fd[5], 'alch_process': fd[6],
197                'alch_framework': fd[7], 'russell_pages': fd[8],
198            }
199    except:
200        pass  # Table may not exist yet
201
202    entries = []
203    sigs = set()
204    for row in cur.fetchall():
205        sig = row[2]
206        ms = row[3]
207        entry = {
208            'ref_id': row[0], 'thesis_page': row[1],
209            'signature': sig, 'manuscript': ms,
210            'institution': row[4], 'city': row[5],
211            'context': (row[6] or '')[:600],
212            'marginal_text': row[7], 'chapter': row[8],
213            'image_file': row[9],
214            'image_path': row[10],
215            'folio_number': row[11], 'side': row[12],
216            'confidence': row[13] or 'PROVISIONAL',
217            'quire': row[14], 'leaf_in_quire': row[15],
218            'needs_review': bool(row[16]),
219            'hand_label': row[17], 'hand_attribution': row[18],
220            'is_alchemist': bool(row[19]) if row[19] is not None else False,
221            'annotation_type': row[20],
222        }
223        # Add folio description if available
224        fd = folio_descs.get((sig, ms)) or folio_descs.get((sig, None))
225        if fd:
226            entry['desc_title'] = fd['desc_title']
227            entry['desc_text'] = fd['desc_text']
228            entry['alch_element'] = fd['alch_element']
229
230        # Generate a brief description for every entry
231        parts = []
232        if entry.get('desc_title'):
233            parts.append(entry['desc_title'] + '.')
234        else:
235            # Build description from available data
236            ms_name = 'the BL copy' if ms == 'C.60.o.12' else 'the Siena copy'
237            if entry.get('hand_label') and entry.get('hand_attribution'):
238                hand_desc = f"Hand {entry['hand_label']}"
239                if entry['hand_attribution'] and entry['hand_attribution'] != 'Anonymous':
240                    hand_desc += f" ({entry['hand_attribution']})"
241                if entry.get('is_alchemist'):
242                    hand_desc += ', an alchemist'
243                parts.append(f"Annotated by {hand_desc} in {ms_name}.")
244            elif entry.get('hand_label'):
245                parts.append(f"Annotated by Hand {entry['hand_label']} in {ms_name}.")
246            else:
247                parts.append(f"A folio from {ms_name} referenced in Russell's thesis.")
248
249            if entry.get('marginal_text'):
250                mt = entry['marginal_text']
251                if len(mt) > 5:
252                    parts.append(f'Russell records the marginal note: "{mt[:80]}{"..." if len(mt) > 80 else ""}"')
253
254        entry['card_description'] = ' '.join(parts)
255        entries.append(entry)
256        sigs.add(sig)
257
258    data = {
259        'entries': entries,
260        'stats': {
261            'total_references': len(entries),
262            'unique_signatures': len(sigs),
263            'high_confidence_matches': sum(1 for e in entries if e['confidence'] == 'HIGH'),
264            'low_confidence_matches': sum(1 for e in entries if e['confidence'] == 'LOW'),
265            'needs_review': sum(1 for e in entries if e['needs_review']),
266        },
267        'provenance': {
268            'source': 'hp.db v2',
269            'note': 'BL C.60.o.12 matches are LOW confidence (1545 edition, unverified photo-folio mapping)',
270        },
271    }
272
273    out = SITE_DIR / 'data.json'
274    with open(out, 'w', encoding='utf-8') as f:
275        json.dump(data, f, indent=2, ensure_ascii=False)
276    print(f"  data.json: {len(entries)} entries")
277
278
279# ============================================================
280# Scholars pages (DB-driven)
281# ============================================================
282
283def build_scholars_pages(conn):
284    """Generate scholars.html and scholar/*.html from DB + summaries.json.
285
286    Uses scholar_overview, is_historical_subject, and scholar_works data
287    to build rich profiles per docs/SCHOLAR_SPEC.md.
288    """
289    # Load summaries for detailed content
290    summaries = []
291    if SUMMARIES_PATH.exists():
292        with open(SUMMARIES_PATH, encoding='utf-8') as f:
293            summaries = json.load(f)
294
295    # Index summaries by (author, title) for lookup
296    summary_lookup = {}
297    for s in summaries:
298        key = (s.get('author', ''), s.get('title', ''))
299        summary_lookup[key] = s
300
301    # Group summaries by author for backward compat
302    by_author_summaries = {}
303    for s in summaries:
304        author = s.get('author', 'Unknown')
305        by_author_summaries.setdefault(author, []).append(s)
306
307    cur = conn.cursor()
308
309    # Get all scholars with overview data
310    cur.execute("""
311        SELECT id, name, specialization, hp_focus, bio_notes,
312               scholar_overview, is_historical_subject,
313               needs_review, source_method, review_status
314        FROM scholars ORDER BY name
315    """)
316    all_scholars = cur.fetchall()
317
318    # Get scholar_works with bibliography data
319    cur.execute("""
320        SELECT sw.scholar_id, b.author, b.title, b.year, b.pub_type,
321               b.journal_or_publisher, sw.has_summary, sw.summary_source,
322               b.hp_relevance, b.in_collection
323        FROM scholar_works sw
324        JOIN bibliography b ON sw.bib_id = b.id
325        ORDER BY b.year DESC
326    """)
327    works_by_scholar = {}
328    for row in cur.fetchall():
329        works_by_scholar.setdefault(row[0], []).append(row)
330
331    scholar_cards = []
332    scholar_dir = SITE_DIR / 'scholar'
333    scholar_dir.mkdir(exist_ok=True)
334    pages_built = 0
335
336    for scholar in all_scholars:
337        (sid, name, specialization, hp_focus, bio_notes,
338         overview, is_hist, needs_rev, source_method, rev_status) = scholar
339
340        slug = slugify(name)
341        works = works_by_scholar.get(sid, [])
342        author_summaries = by_author_summaries.get(name, [])
343
344        # Collect topics from summaries
345        topics = set()
346        for p in author_summaries:
347            tc = p.get('topic_cluster', '')
348            if tc:
349                for t in tc.split(','):
350                    topics.add(t.strip())
351
352        badges = topic_badges_html(','.join(topics))
353        review_html = review_badge_html(needs_rev, source_method)
354
355        # Historical figure badge
356        hist_badge = ' <span class="review-status-badge review-badge-provisional">Historical Figure</span>' if is_hist else ''
357
358        # Overview preview for card (300 chars)
359        overview_text = overview or ''
360        overview_first_para = overview_text.split('\n\n')[0] if overview_text else ''
361        overview_preview = overview_first_para[:300]
362        if len(overview_first_para) > 300:
363            overview_preview = overview_preview.rsplit(' ', 1)[0] + '...'
364
365        # Work count
366        work_count = len(works) or len(author_summaries)
367        work_label = f"{work_count} work{'s' if work_count != 1 else ''}" if work_count else "No works catalogued"
368
369        # Card
370        overview_card_html = ''
371        if overview_preview:
372            overview_card_html = f"""
373                <div class="scholar-overview-preview">
374                    <p>{escape(overview_preview)}
375                    <a href="scholar/{slug}.html">Read more</a></p>
376                </div>"""
377
378        scholar_cards.append(f"""
379        <div class="scholar-card">
380            <h3><a href="scholar/{slug}.html">{escape(name)}</a>{hist_badge} {review_html}</h3>
381            <div class="scholar-meta">{work_label} {badges}</div>
382            {overview_card_html}
383        </div>""")
384
385        # === Detail page ===
386
387        # Full overview
388        overview_html = ''
389        if overview_text:
390            paras = overview_text.split('\n\n')
391            overview_html = ''.join(f'<p>{escape(p.strip())}</p>' for p in paras if p.strip())
392            overview_html = f'<div class="scholar-overview">{overview_html}</div>'
393
394        # Works in archive (with summaries)
395        archive_works_html = ''
396        works_with_summaries = [w for w in works if w[6]]  # has_summary = True
397        if not works_with_summaries and author_summaries:
398            # Fallback to summaries.json directly
399            for p in author_summaries:
400                tc = p.get('topic_cluster', '')
401                archive_works_html += f"""
402                <div class="paper-detail">
403                    <h4>{escape(p.get('title', ''))}</h4>
404                    <div class="paper-meta">{escape(p.get('journal', ''))} ({p.get('year', '?')})
405                        {topic_badges_html(tc)}</div>
406                    <div class="paper-summary-full"><p>{escape(p.get('summary', ''))}</p></div>
407                </div>"""
408        else:
409            for w in works_with_summaries:
410                _, bauthor, btitle, byear, bpubtype, bjournal, _, _, _, _ = w
411                # Find matching summary
412                summary_text = ''
413                for s in author_summaries:
414                    if s.get('title', '') == btitle:
415                        summary_text = s.get('summary', '')
416                        break
417                tc = ''
418                for s in author_summaries:
419                    if s.get('title', '') == btitle:
420                        tc = s.get('topic_cluster', '')
421                        break
422
423                archive_works_html += f"""
424                <div class="paper-detail">
425                    <h4>{escape(btitle or '')}</h4>
426                    <div class="paper-meta">{escape(bjournal or '')} ({byear or '?'}) [{bpubtype or ''}]
427                        {topic_badges_html(tc)}</div>
428                    <div class="paper-summary-full"><p>{escape(summary_text)}</p></div>
429                </div>"""
430
431        # Other known works (without summaries)
432        other_works_html = ''
433        works_without_summaries = [w for w in works if not w[6]]
434        for w in works_without_summaries:
435            _, bauthor, btitle, byear, bpubtype, bjournal, _, _, _, _ = w
436            other_works_html += f"""
437            <div class="paper-detail">
438                <h4>{escape(btitle or '')}</h4>
439                <div class="paper-meta">{escape(bjournal or '')} ({byear or '?'}) [{bpubtype or ''}]</div>
440                <p class="no-summary" style="color:var(--text-muted); font-style:italic; font-size:0.9rem">Summary not yet available.</p>
441            </div>"""
442
443        # Build sections
444        works_section = ''
445        if archive_works_html:
446            works_section += f'<h3>Works in Archive</h3>{archive_works_html}'
447        if other_works_html:
448            works_section += f'<h3>Other Known Works</h3>{other_works_html}'
449        if not works_section and not author_summaries:
450            works_section = '<p style="color:var(--text-muted)">No works catalogued yet.</p>'
451
452        # Provenance
453        provenance_html = f"""
454        <div class="provenance-section" style="margin-top:2rem; padding:1rem; background:var(--bg-card); border-radius:4px">
455            <h4 style="margin-top:0">Review Status / Provenance</h4>
456            <p>{review_status_badge(rev_status or 'DRAFT')} Source: {escape(source_method or 'LLM_ASSISTED')}</p>
457        </div>"""
458
459        detail_body = f"""
460        <div class="scholar-detail">
461            <p><a href="../scholars.html">&larr; All Scholars</a></p>
462            <h2>{escape(name)}{hist_badge} {review_html}</h2>
463            {overview_html}
464            {works_section}
465            {provenance_html}
466        </div>"""
467
468        detail_page = page_shell(name, detail_body, active_nav='scholars', depth=1)
469        (scholar_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8')
470        pages_built += 1
471
472    # Build index page
473    # Count stats
474    modern_count = sum(1 for s in all_scholars if not s[6])
475    hist_count = sum(1 for s in all_scholars if s[6])
476
477    index_body = f"""
478        <div class="scholars-grid">
479            <div class="intro" style="margin-bottom:2rem">
480                <h2>Scholars of the <em>Hypnerotomachia</em></h2>
481                <p>The scholarship on the <em>Hypnerotomachia Poliphili</em> is wide-ranging
482                and interdisciplinary. It spans bibliography, book history, architecture,
483                garden studies, allegory, philology, reception history, emblematic reading,
484                and the history of interpretation. This section organizes that scholarly
485                landscape by person: {modern_count} modern scholars and {hist_count} historical
486                figures, with {len(summaries)} article and monograph summaries.</p>
487                <p>These pages are meant to make the field legible at a glance.
488                Where content is LLM-assisted or otherwise provisional, that status
489                remains visible.</p>
490            </div>
491            {''.join(scholar_cards)}
492        </div>"""
493
494    index_page = page_shell('Scholars', index_body, active_nav='scholars')
495    (SITE_DIR / 'scholars.html').write_text(index_page, encoding='utf-8')
496    print(f"  scholars.html + {pages_built} scholar pages")
497
498
499# ============================================================
500# Dictionary pages
501# ============================================================
502
503def review_status_badge(status):
504    """Generate a colored review status badge."""
505    colors = {
506        'DRAFT': ('review-badge-draft', 'Draft'),
507        'REVIEWED': ('review-badge-reviewed', 'Reviewed'),
508        'VERIFIED': ('review-badge-verified', 'Verified'),
509        'PROVISIONAL': ('review-badge-provisional', 'Provisional'),
510    }
511    cls, label = colors.get(status, ('review-badge-draft', status or 'Draft'))
512    return f'<span class="review-status-badge {cls}">{label}</span>'
513
514
515def build_dictionary_pages(conn):
516    """Generate dictionary/index.html and dictionary/*.html from DB."""
517    cur = conn.cursor()
518    dict_dir = SITE_DIR / 'dictionary'
519    dict_dir.mkdir(exist_ok=True)
520
521    # Get all terms with enriched fields
522    cur.execute("""
523        SELECT id, slug, label, category, definition_short, definition_long,
524               source_basis, review_status, needs_review,
525               significance_to_hp, significance_to_scholarship,
526               source_documents, source_page_refs, source_quotes_short,
527               source_method, confidence, notes, related_scholars
528        FROM dictionary_terms ORDER BY label
529    """)
530    terms = cur.fetchall()
531
532    # Build per-term link map
533    term_links = {}
534    cur.execute("""
535        SELECT t1.slug, t2.slug, t2.label, l.link_type
536        FROM dictionary_term_links l
537        JOIN dictionary_terms t1 ON l.term_id = t1.id
538        JOIN dictionary_terms t2 ON l.linked_term_id = t2.id
539    """)
540    for row in cur.fetchall():
541        term_links.setdefault(row[0], []).append({
542            'slug': row[1], 'label': row[2], 'type': row[3]
543        })
544
545    # Group by category for index
546    by_category = {}
547    for t in terms:
548        by_category.setdefault(t[3], []).append(t)
549
550    # Build index page
551    cat_sections = ''
552    for cat in sorted(by_category.keys()):
553        cat_terms = by_category[cat]
554        items = ''
555        for t in sorted(cat_terms, key=lambda x: x[2]):
556            review = ' <span class="review-badge">Draft</span>' if t[8] else ''
557            items += f"""
558                <div class="dict-entry">
559                    <h4><a href="{t[1]}.html">{escape(t[2])}</a>{review}</h4>
560                    <p>{escape(t[4])}</p>
561                </div>"""
562        cat_sections += f"""
563            <section class="dict-category">
564                <h3>{escape(cat)}</h3>
565                {items}
566            </section>"""
567
568    index_body = f"""
569        <div class="dictionary-index">
570            <div class="intro">
571                <h2>Dictionary of the <em>Hypnerotomachia</em></h2>
572                <p>The dictionary is the conceptual armature of the site. It defines
573                the {len(terms)} terms across {len(by_category)} categories that recur across the
574                <em>Hypnerotomachia</em> corpus and its scholarship&mdash;book-historical
575                terms, annotation concepts, alchemical vocabulary, architectural and garden
576                discourse, textual-visual rhetoric, and major historiographical debates.
577                Its purpose is not merely lexical; it is to help readers move between page
578                evidence and scholarly interpretation.</p>
579                <p>This section is most useful when read alongside the folio pages, essays,
580                and bibliography. Terms are cross-linked: each page lists related concepts
581                and see-also references, so you can follow threads through the HP's
582                intellectual world. Review and provenance badges indicate which entries
583                rest on stable ground and which are still in draft.</p>
584            </div>
585            {cat_sections}
586        </div>"""
587
588    dict_css = '<style>' + """
589        .dictionary-index { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
590        .dict-category { margin-bottom: 2.5rem; }
591        .dict-category h3 {
592            font-size: 1.2rem; color: var(--accent);
593            border-bottom: 1px solid var(--border);
594            padding-bottom: 0.3rem; margin-bottom: 1rem;
595        }
596        .dict-entry { margin-bottom: 1rem; }
597        .dict-entry h4 { font-size: 1rem; margin-bottom: 0.2rem; }
598        .dict-entry h4 a { color: var(--text); text-decoration: none; }
599        .dict-entry h4 a:hover { color: var(--accent); }
600        .dict-entry p { font-size: 0.9rem; color: var(--text-muted); line-height: 1.5; }
601        .dict-detail { max-width: 800px; margin: 2rem auto; padding: 0 2rem; }
602        .dict-detail h2 { color: var(--accent); margin-bottom: 0.5rem; }
603        .dict-detail .category-label {
604            font-size: 0.85rem; color: var(--text-muted);
605            font-family: var(--font-sans); margin-bottom: 1.5rem;
606        }
607        .dict-detail .definition-short {
608            font-size: 1.1rem; font-style: italic; margin-bottom: 1.5rem;
609            padding: 1rem; background: var(--bg-card); border-left: 3px solid var(--accent);
610        }
611        .dict-detail .definition-long { line-height: 1.8; margin-bottom: 1.5rem; }
612        .dict-detail .source-basis {
613            font-size: 0.85rem; color: var(--text-muted);
614            font-family: var(--font-sans); margin-top: 1rem;
615            padding-top: 1rem; border-top: 1px solid var(--border);
616        }
617        .related-terms { margin-top: 1.5rem; }
618        .related-terms h4 { font-size: 0.95rem; color: var(--accent); margin-bottom: 0.5rem; }
619        .related-terms a {
620            display: inline-block; margin: 0.2rem 0.3rem 0.2rem 0;
621            padding: 0.2rem 0.6rem; background: var(--bg);
622            border: 1px solid var(--border); border-radius: 3px;
623            font-size: 0.85rem; color: var(--text); text-decoration: none;
624        }
625        .related-terms a:hover { border-color: var(--accent); color: var(--accent); }
626    """ + '</style>'
627
628    index_page = page_shell('Dictionary', index_body, active_nav='dictionary', depth=1)
629    (dict_dir / 'index.html').write_text(index_page, encoding='utf-8')
630
631    # Build individual term pages
632    for t in terms:
633        (tid, slug, label, category, def_short, def_long, source, status,
634         needs_rev, sig_hp, sig_schol, src_docs, src_pages, src_quotes,
635         src_method, confidence, notes, related_scholars) = t
636        links = term_links.get(slug, [])
637
638        related_html = ''
639        see_also_html = ''
640        for lk in links:
641            link_el = f'<a href="{lk["slug"]}.html">{escape(lk["label"])}</a>'
642            if lk['type'] == 'SEE_ALSO':
643                see_also_html += link_el
644            else:
645                related_html += link_el
646
647        links_section = ''
648        if related_html:
649            links_section += f'<div class="related-terms"><h4>Related Terms</h4>{related_html}</div>'
650        if see_also_html:
651            links_section += f'<div class="related-terms"><h4>See Also</h4>{see_also_html}</div>'
652
653        # Status badge
654        status_html = review_status_badge(status)
655        source_html = f'<div class="source-basis"><strong>Sources:</strong> {escape(source or "")}</div>' if source else ''
656
657        # Significance sections
658        sig_hp_html = ''
659        if sig_hp:
660            sig_hp_html = f'''
661            <div class="dict-section">
662                <h3>Why It Matters for the <em>Hypnerotomachia</em></h3>
663                <p>{escape(sig_hp)}</p>
664            </div>'''
665
666        sig_schol_html = ''
667        if sig_schol:
668            sig_schol_html = f'''
669            <div class="dict-section">
670                <h3>Why It Matters in Scholarship</h3>
671                <p>{escape(sig_schol)}</p>
672            </div>'''
673
674        # Evidence section
675        evidence_html = ''
676        if src_quotes:
677            quotes = src_quotes.split(' | ')
678            quote_items = ''.join(f'<li>{escape(q)}</li>' for q in quotes)
679            evidence_html = f'''
680            <div class="dict-section">
681                <h3>Key Passages / Evidence</h3>
682                <ul class="evidence-list">{quote_items}</ul>
683            </div>'''
684
685        # Source documents
686        src_docs_html = ''
687        if src_docs:
688            src_docs_html = f'''
689            <div class="dict-section">
690                <h3>Source Documents</h3>
691                <p class="source-docs">{escape(src_docs)}</p>
692            </div>'''
693
694        # Page references
695        src_pages_html = ''
696        if src_pages:
697            src_pages_html = f'<div class="source-pages"><strong>Page references:</strong> {escape(src_pages)}</div>'
698
699        # Related scholars
700        scholars_html = ''
701        if related_scholars:
702            scholars_html = f'''
703            <div class="dict-section">
704                <h3>Related Scholars / Bibliography</h3>
705                <p>{escape(related_scholars)}</p>
706            </div>'''
707
708        # Provenance section
709        provenance_items = []
710        if src_method:
711            provenance_items.append(f'Source method: {escape(src_method)}')
712        if confidence:
713            provenance_items.append(f'Confidence: {escape(confidence)}')
714        if notes:
715            provenance_items.append(f'Notes: {escape(notes)}')
716        provenance_html = ''
717        if provenance_items:
718            prov_list = ''.join(f'<li>{p}</li>' for p in provenance_items)
719            provenance_html = f'''
720            <div class="dict-section provenance-section">
721                <h3>Review Status / Provenance</h3>
722                <div class="provenance-status">{status_html}</div>
723                <ul class="provenance-list">{prov_list}</ul>
724            </div>'''
725
726        # Essay cross-links: terms that appear in essays
727        essay_links_html = ''
728        russell_terms = {
729            'alchemical-allegory', 'master-mercury', 'sol-luna', 'chemical-wedding',
730            'ideogram', 'annotator-hand', 'activity-book', 'prisca-sapientia',
731            'ingegno', 'marginalia', 'elephant-obelisk', 'reception-history',
732            'inventio', 'acutezze', 'commentary', 'allegory',
733            'poliphilo', 'polia', 'venus-aphrodite', 'cupid-eros',
734            'beroalde-1600', '1499-edition', '1545-edition',
735        }
736        concordance_terms = {
737            'signature', 'folio', 'collation', 'quire', 'recto', 'verso',
738            'gathering', 'annotator-hand', 'marginalia', 'incunabulum',
739            '1499-edition', '1545-edition',
740        }
741        essay_links = []
742        if slug in russell_terms:
743            essay_links.append('<a href="../russell-alchemical-hands.html">Alchemical Hands Essay</a>')
744        if slug in concordance_terms:
745            essay_links.append('<a href="../concordance-method.html">Concordance Methodology</a>')
746        if essay_links:
747            essay_links_html = f'''
748            <div class="related-terms">
749                <h4>Discussed In</h4>
750                {''.join(essay_links)}
751            </div>'''
752
753        detail_body = f"""
754        <div class="dict-detail">
755            <p><a href="index.html">&larr; Dictionary</a></p>
756            <h2>{escape(label)} {status_html}</h2>
757            <div class="category-label">{escape(category)}</div>
758            <div class="definition-short">{escape(def_short)}</div>
759            <div class="definition-long">{escape(def_long or '')}</div>
760            {sig_hp_html}
761            {sig_schol_html}
762            {evidence_html}
763            {src_docs_html}
764            {src_pages_html}
765            {source_html}
766            {scholars_html}
767            {links_section}
768            {essay_links_html}
769            {provenance_html}
770        </div>"""
771
772        term_page = page_shell(label, detail_body, active_nav='dictionary', depth=1)
773        (dict_dir / f'{slug}.html').write_text(term_page, encoding='utf-8')
774
775    print(f"  dictionary/index.html + {len(terms)} term pages")
776
777
778# ============================================================
779# Marginalia folio detail pages
780# ============================================================
781
782def _render_deep_reading(dr):
783    """Render a Phase 3 deep reading JSON object as HTML."""
784    parts = []
785    parts.append('<h3 style="margin:1.5rem 0 1rem">Vision Reading <span style="font-size:0.7rem;font-weight:400;color:var(--text-muted)">(Phase 3 deep analysis)</span></h3>')
786
787    # Metadata bar
788    meta_items = []
789    if dr.get('primary_hand'):
790        meta_items.append(f'<strong>Primary hand:</strong> {escape(dr["primary_hand"])}')
791    if dr.get('hands_detected'):
792        meta_items.append(f'<strong>Hands:</strong> {dr["hands_detected"]}')
793    if dr.get('page_type'):
794        meta_items.append(f'<strong>Type:</strong> {escape(dr["page_type"])}')
795    if dr.get('signature'):
796        meta_items.append(f'<strong>Sig:</strong> {escape(dr["signature"])}')
797    if meta_items:
798        parts.append(
799            '<div style="font-size:0.85rem;font-family:var(--font-sans);color:var(--text-muted);'
800            f'margin-bottom:1rem">{" &middot; ".join(meta_items)}</div>'
801        )
802
803    # Woodcut analysis
804    wc = dr.get('woodcut_analysis')
805    if wc:
806        wc_html = f'<div class="marg-annotation" style="border-left:3px solid #6b8e5a">'
807        wc_html += f'<h4 style="color:#6b8e5a;margin-bottom:0.5rem">Woodcut: {escape(wc.get("subject", ""))}</h4>'
808        if wc.get('description'):
809            wc_html += f'<p style="line-height:1.7;margin-bottom:0.5rem">{escape(wc["description"])}</p>'
810        if wc.get('inscription_below'):
811            wc_html += f'<div class="marginal-text" style="border-color:#6b8e5a">{escape(wc["inscription_below"])}</div>'
812        if wc.get('condition'):
813            wc_html += f'<div class="hand-info">Condition: {escape(wc["condition"])}</div>'
814        wc_html += '</div>'
815        parts.append(wc_html)
816
817    # Transcription attempts
818    transcriptions = dr.get('transcription_attempts', [])
819    if transcriptions:
820        parts.append('<h4 style="color:var(--accent);margin:1rem 0 0.5rem;font-size:0.95rem">Transcription Attempts</h4>')
821        for t in transcriptions:
822            conf_color = {'HIGH': '#2d8a4e', 'MEDIUM': '#b8860b', 'LOW': '#c44'}.get(
823                t.get('confidence', ''), '#888')
824            loc = escape(t.get('location', ''))
825            lang = escape(t.get('language', ''))
826            text = escape(t.get('text_partial', t.get('text', '')))
827            notes = escape(t.get('notes', ''))
828            conf = t.get('confidence', '')
829
830            t_html = '<div class="marg-annotation">'
831            t_html += f'<div class="hand-info" style="margin-bottom:0.5rem">'
832            t_html += f'<strong>{loc}</strong>'
833            if lang:
834                t_html += f' &middot; {lang}'
835            if conf:
836                t_html += (f' &middot; <span style="color:{conf_color};font-weight:600">'
837                          f'{conf}</span>')
838            t_html += '</div>'
839            if text:
840                t_html += f'<div class="marginal-text">{text}</div>'
841            if notes:
842                t_html += f'<div class="context">{notes}</div>'
843            t_html += '</div>'
844            parts.append(t_html)
845
846    # Symbols detected
847    symbols = dr.get('symbols_detected', [])
848    if symbols:
849        parts.append('<h4 style="color:var(--accent);margin:1rem 0 0.5rem;font-size:0.95rem">Symbols Detected</h4>')
850        for s in symbols:
851            s_html = '<div class="marg-annotation">'
852            s_html += f'<div class="hand-info"><strong>{escape(s.get("type", ""))}</strong>'
853            if s.get('location'):
854                s_html += f' &middot; {escape(s["location"])}'
855            s_html += '</div>'
856            if s.get('description'):
857                s_html += f'<p style="line-height:1.7;margin:0.5rem 0">{escape(s["description"])}</p>'
858            s_html += '</div>'
859            parts.append(s_html)
860
861    # Scholarly significance
862    if dr.get('scholarly_significance'):
863        parts.append(
864            '<div class="marg-annotation" style="border-left:3px solid var(--accent);background:var(--bg)">'
865            '<h4 style="color:var(--accent);margin-bottom:0.5rem">Scholarly Significance</h4>'
866            f'<p style="line-height:1.7">{escape(dr["scholarly_significance"])}</p>'
867            '</div>'
868        )
869
870    # Cross-references
871    xrefs = dr.get('cross_references', [])
872    if xrefs:
873        refs_html = ', '.join(escape(x) for x in xrefs)
874        parts.append(
875            f'<div class="hand-info" style="margin-top:0.5rem">'
876            f'<strong>Cross-references:</strong> {refs_html}</div>'
877        )
878
879    # Discrepancies
880    discreps = dr.get('discrepancies', [])
881    if discreps:
882        for d in discreps:
883            parts.append(
884                '<div style="background:#fff8e1;border:1px solid #f0e68c;border-radius:4px;'
885                'padding:0.75rem 1rem;margin:0.5rem 0;font-size:0.85rem">'
886                f'<strong>Note ({escape(d.get("type", ""))}):</strong> '
887                f'{escape(d.get("description", ""))}</div>'
888            )
889
890    # Provenance badge
891    parts.append(
892        '<div class="hand-info" style="margin-top:0.75rem">'
893        '<span class="review-badge">Vision reading (Claude Code, Phase 3)</span></div>'
894    )
895
896    return '\n'.join(parts)
897
898
899def build_marginalia_pages(conn):
900    """Generate marginalia/index.html and marginalia/[signature].html."""
901    cur = conn.cursor()
902    marg_dir = SITE_DIR / 'marginalia'
903    marg_dir.mkdir(exist_ok=True)
904
905    # Get all matched signatures with their images and annotations
906    cur.execute("""
907        SELECT
908            a.signature_ref, a.thesis_page, dr.context_text, a.annotation_text,
909            a.thesis_chapter, m.shelfmark, m.institution, m.city,
910            i.filename, COALESCE(i.web_path, i.relative_path), i.folio_number, i.side,
911            mat.confidence, mat.needs_review,
912            h.hand_label, h.attribution, h.is_alchemist, h.school,
913            sm.quire, sm.leaf_in_quire,
914            a.annotation_type
915        FROM matches mat
916        JOIN annotations a ON mat.ref_id = a.id
917        JOIN images i ON mat.image_id = i.id
918        JOIN manuscripts m ON i.manuscript_id = m.id
919        LEFT JOIN dissertation_refs dr ON a.id = dr.id
920        LEFT JOIN signature_map sm ON LOWER(a.signature_ref) = LOWER(sm.signature)
921        LEFT JOIN annotator_hands h ON a.hand_id = h.id
922        WHERE i.page_type = 'PAGE'
923        ORDER BY COALESCE(sm.folio_number, 999), m.shelfmark
924    """)
925
926    # Group by signature
927    by_sig = {}
928    for row in cur.fetchall():
929        sig = row[0]
930        by_sig.setdefault(sig, []).append(row)
931
932    marg_css = '<style>' + """
933        .marg-detail { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; }
934        .marg-detail h2 { color: var(--accent); margin-bottom: 0.5rem; }
935        .marg-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 1.5rem; margin: 1.5rem 0; }
936        .marg-image-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
937        .marg-image-card img { width: 100%; display: block; }
938        .marg-image-card .caption { padding: 0.75rem 1rem; font-size: 0.85rem; font-family: var(--font-sans); }
939        .marg-annotation { background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 1rem; }
940        .marg-annotation .marginal-text { font-style: italic; font-size: 1.05rem; padding: 0.75rem; border-left: 3px solid var(--accent-light); margin: 0.75rem 0; }
941        .marg-annotation .context { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; max-height: 200px; overflow-y: auto; }
942        .marg-annotation .hand-info { font-size: 0.85rem; font-family: var(--font-sans); color: var(--text-muted); margin-top: 0.5rem; }
943        .alchemist-tag { display: inline-block; padding: 0.1rem 0.5rem; background: #e8d4d4; color: #6b2323; border-radius: 2px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; }
944        .marg-index { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; }
945        .marg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; }
946        .marg-grid a { display: block; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; text-decoration: none; color: var(--text); transition: all 0.2s; }
947        .marg-grid a:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
948        .marg-grid .sig-label { font-weight: 600; font-size: 1.1rem; color: var(--accent); }
949        .marg-grid .sig-meta { font-size: 0.8rem; color: var(--text-muted); font-family: var(--font-sans); }
950    """ + '</style>'
951
952    # Load folio descriptions (alchemist analyses)
953    folio_descs = {}
954    try:
955        cur2 = conn.cursor()
956        cur2.execute("""
957            SELECT signature_ref, manuscript_shelfmark, hand_label,
958                   title, description, alchemical_element, alchemical_process,
959                   alchemical_framework, russell_page_ref
960            FROM folio_descriptions
961        """)
962        for fd in cur2.fetchall():
963            folio_descs.setdefault(fd[0], []).append({
964                'ms': fd[1], 'hand': fd[2], 'title': fd[3],
965                'desc': fd[4], 'element': fd[5], 'process': fd[6],
966                'framework': fd[7], 'pages': fd[8],
967            })
968    except:
969        pass
970
971    # Load annotation types by signature
972    ann_types_by_sig = {}
973    try:
974        cur_at = conn.cursor()
975        cur_at.execute("""
976            SELECT signature_ref, annotation_type, COUNT(*) FROM annotations
977            WHERE annotation_type IS NOT NULL
978            GROUP BY signature_ref, annotation_type
979        """)
980        for row in cur_at.fetchall():
981            ann_types_by_sig.setdefault(row[0], []).append({
982                'type': row[1], 'count': row[2]
983            })
984    except:
985        pass
986
987    # Load symbol occurrences by signature
988    symbol_by_sig = {}
989    try:
990        cur3 = conn.cursor()
991        cur3.execute("""
992            SELECT so.signature_ref, s.symbol_name, s.metal, s.planet, s.gender,
993                   so.context_text, so.latin_inflection, so.thesis_page, so.confidence,
994                   h.hand_label, h.manuscript_shelfmark
995            FROM symbol_occurrences so
996            JOIN alchemical_symbols s ON so.symbol_id = s.id
997            JOIN annotator_hands h ON so.hand_id = h.id
998            ORDER BY so.signature_ref, s.symbol_name
999        """)
1000        for row in cur3.fetchall():
1001            symbol_by_sig.setdefault(row[0], []).append({
1002                'symbol': row[1], 'metal': row[2], 'planet': row[3],
1003                'gender': row[4], 'context': row[5], 'inflection': row[6],
1004                'thesis_page': row[7], 'confidence': row[8],
1005                'hand': row[9], 'ms': row[10],
1006            })
1007    except:
1008        pass
1009
1010    # Load Phase 3 deep readings by signature
1011    deep_by_sig = {}
1012    try:
1013        cur_dr = conn.cursor()
1014        cur_dr.execute("""
1015            SELECT sm.signature, ir.deep_reading_json
1016            FROM image_readings ir
1017            JOIN images i ON ir.image_id = i.id
1018            JOIN signature_map sm ON (
1019                CAST(REPLACE(REPLACE(i.filename, 'C_60_o_12-', ''), '.jpg', '') AS INTEGER) - 13
1020            ) = sm.id
1021            WHERE ir.phase = 3 AND ir.deep_reading_json IS NOT NULL
1022        """)
1023        for row in cur_dr.fetchall():
1024            deep_by_sig[row[0].lower()] = row[1]
1025    except Exception as e:
1026        print(f"  Warning: could not load deep readings: {e}")
1027
1028    # Build individual folio pages
1029    for sig, rows in by_sig.items():
1030        sig_slug = sig.lower().replace(' ', '')
1031
1032        images_html = ''
1033        annotations_html = ''
1034        seen_images = set()
1035
1036        for row in rows:
1037            (sig_ref, thesis_page, context, marginal, chapter,
1038             shelfmark, institution, city, img_file, img_path,
1039             folio_num, side, confidence, needs_rev,
1040             hand_label, attribution, is_alchemist, school,
1041             quire, leaf, annotation_type) = row
1042
1043            # Image card (deduplicate)
1044            if img_file not in seen_images:
1045                seen_images.add(img_file)
1046                conf_badge = confidence_badge_html(confidence)
1047                rev_badge = '<span class="review-badge">Unverified</span>' if needs_rev else ''
1048                images_html += f"""
1049                    <div class="marg-image-card">
1050                        <img src="../{img_path}" alt="Folio {sig}" loading="lazy">
1051                        <div class="caption">
1052                            {escape(institution)}, {escape(city)} &mdash; {escape(shelfmark)}
1053                            {conf_badge} {rev_badge}
1054                        </div>
1055                    </div>"""
1056
1057            # Annotation card
1058            hand_html = ''
1059            if hand_label:
1060                alch_tag = ' <span class="alchemist-tag">Alchemist</span>' if is_alchemist else ''
1061                school_info = f' ({escape(school)})' if school else ''
1062                hand_html = f'<div class="hand-info">Hand {escape(hand_label)}: {escape(attribution or "Anonymous")}{school_info}{alch_tag}</div>'
1063
1064            type_html = ''
1065            if annotation_type:
1066                type_label = annotation_type.replace('_', ' ').title()
1067                type_html = f'<span style="display:inline-block;padding:0.1rem 0.5rem;background:var(--bg);border:1px solid var(--border);border-radius:2px;font-size:0.7rem;font-weight:600;text-transform:uppercase;margin-left:0.5rem;color:var(--text-muted)">{type_label}</span>'
1068
1069            marginal_html = ''
1070            if marginal:
1071                marginal_html = f'<div class="marginal-text">&ldquo;{escape(marginal)}&rdquo;</div>'
1072
1073            context_html = ''
1074            if context:
1075                ctx = context[:500] + ('...' if len(context) > 500 else '')
1076                context_html = f'<div class="context">{escape(ctx)}</div>'
1077
1078            annotations_html += f"""
1079                <div class="marg-annotation">
1080                    {hand_html}{type_html}
1081                    {marginal_html}
1082                    {context_html}
1083                    <div class="hand-info">Russell, PhD Thesis, p. {thesis_page} (Ch. {chapter})</div>
1084                </div>"""
1085
1086        folio_info = f'Folio {rows[0][10] or "?"}{rows[0][11] or ""}'
1087        quire_info = f', Quire {rows[0][18]}' if rows[0][18] else ''
1088
1089        # Render folio description (alchemist analysis) if available
1090        desc_html = ''
1091        descs = folio_descs.get(sig, [])
1092        if descs:
1093            for fd in descs:
1094                element_html = f'<div class="hand-info"><strong>Element:</strong> {escape(fd["element"])}</div>' if fd.get('element') else ''
1095                process_html = f'<div class="hand-info"><strong>Process:</strong> {escape(fd["process"])}</div>' if fd.get('process') else ''
1096                framework_html = f'<div class="hand-info"><strong>Framework:</strong> {escape(fd["framework"])}</div>' if fd.get('framework') else ''
1097                pages_html = f'<div class="hand-info">Russell, {escape(fd["pages"])}</div>' if fd.get('pages') else ''
1098                desc_html += f"""
1099                <div class="marg-annotation" style="border-left:3px solid var(--accent); background:var(--bg)">
1100                    <h4 style="color:var(--accent); margin-bottom:0.5rem; font-size:1.05rem">{escape(fd["title"])}</h4>
1101                    <p style="line-height:1.7; margin-bottom:0.75rem">{escape(fd["desc"])}</p>
1102                    {element_html}
1103                    {process_html}
1104                    {framework_html}
1105                    {pages_html}
1106                    <div class="hand-info" style="margin-top:0.5rem"><span class="review-badge">LLM-assisted synthesis from Russell</span></div>
1107                </div>"""
1108
1109        # Symbol occurrences for this folio
1110        symbols_html = ''
1111        sig_symbols = symbol_by_sig.get(sig, [])
1112        if sig_symbols:
1113            sym_rows = ''
1114            for s in sig_symbols:
1115                conf = confidence_badge_html(s['confidence'])
1116                infl = f' <code>{escape(s["inflection"])}</code>' if s.get('inflection') else ''
1117                sym_rows += f"""<tr>
1118                    <td><strong>{escape(s['symbol'])}</strong></td>
1119                    <td>{escape(s.get('metal') or '')}</td>
1120                    <td>{escape(s.get('planet') or '')}</td>
1121                    <td>{escape(s.get('gender') or '')}</td>
1122                    <td>Hand {escape(s['hand'])}{infl}</td>
1123                    <td>{conf}</td>
1124                </tr>"""
1125            symbols_html = f"""
1126            <h3 style="margin:1.5rem 0 1rem">Alchemical Symbols Present</h3>
1127            <table style="width:100%; border-collapse:collapse; font-size:0.85rem; margin-bottom:1rem">
1128                <thead><tr style="background:var(--bg)">
1129                    <th style="padding:0.5rem; border:1px solid var(--border)">Symbol</th>
1130                    <th style="padding:0.5rem; border:1px solid var(--border)">Metal</th>
1131                    <th style="padding:0.5rem; border:1px solid var(--border)">Planet</th>
1132                    <th style="padding:0.5rem; border:1px solid var(--border)">Gender</th>
1133                    <th style="padding:0.5rem; border:1px solid var(--border)">Hand</th>
1134                    <th style="padding:0.5rem; border:1px solid var(--border)">Conf.</th>
1135                </tr></thead>
1136                <tbody>{sym_rows}</tbody>
1137            </table>"""
1138
1139        # Cross-links for alchemical folios
1140        alchem_cross = ''
1141        if desc_html or sig_symbols:
1142            links = ['<a href="../dictionary/alchemical-allegory.html">Alchemical Allegory</a>',
1143                     '<a href="../russell-alchemical-hands.html">Alchemical Hands Essay</a>']
1144            for s in sig_symbols:
1145                slug = slugify(s['symbol'])
1146                if slug in ('sol', 'mercury', 'hermaphrodite', 'sulphur'):
1147                    term_slugs = {'sol': 'sol-luna', 'mercury': 'master-mercury',
1148                                  'hermaphrodite': 'chemical-wedding', 'sulphur': 'great-work'}
1149                    ts = term_slugs.get(slug)
1150                    if ts:
1151                        links.append(f'<a href="../dictionary/{ts}.html">{escape(s["symbol"])}</a>')
1152            alchem_cross = f"""
1153            <div style="margin-top:1rem; padding-top:0.5rem; border-top:1px solid var(--border)">
1154                <strong style="font-size:0.85rem">Related:</strong> {''.join(set(links))}
1155            </div>"""
1156
1157        detail_body = f"""
1158        <div class="marg-detail">
1159            <p><a href="index.html">&larr; All Folios</a></p>
1160            <h2>Signature {escape(sig)}</h2>
1161            <p style="color:var(--text-muted); font-family:var(--font-sans); margin-bottom:1.5rem">
1162                {folio_info}{quire_info}</p>
1163            <div class="marg-images">{images_html}</div>
1164            {f'<h3 style="margin:1.5rem 0 1rem">Alchemical Analysis</h3>' + desc_html if desc_html else ''}
1165            {symbols_html}
1166            {alchem_cross}"""
1167
1168        # Deep reading section (Phase 3 vision analysis)
1169        deep_json = deep_by_sig.get(sig.lower())
1170        if deep_json:
1171            import json as _json
1172            try:
1173                dr = _json.loads(deep_json)
1174                dr_html = _render_deep_reading(dr)
1175                detail_body += dr_html
1176            except Exception:
1177                pass
1178
1179        detail_body += """
1180            <h3 style="margin:1.5rem 0 1rem">Annotations</h3>"""
1181
1182        # Add annotation type badges
1183        ann_types = ann_types_by_sig.get(sig, [])
1184        if ann_types:
1185            type_badges = ''
1186            for at in ann_types:
1187                type_badges += (
1188                    f'<span style="display:inline-block;padding:0.15rem 0.5rem;'
1189                    f'margin:0.1rem;border-radius:3px;font-size:0.7rem;font-weight:600;'
1190                    f'font-family:var(--font-sans);background:var(--bg);'
1191                    f'border:1px solid var(--border)">'
1192                    f'{escape(at["type"])} ({at["count"]})</span>'
1193                )
1194            detail_body += f'\n            <div style="margin-bottom:0.75rem">{type_badges}</div>'
1195
1196        detail_body += f"""
1197            {annotations_html}
1198        </div>"""
1199
1200        detail_page = page_shell(f'Folio {sig}', detail_body, active_nav='marginalia', depth=1)
1201        (marg_dir / f'{sig_slug}.html').write_text(detail_page, encoding='utf-8')
1202
1203    # Build standalone pages for deep readings without Russell annotations
1204    standalone_dr = 0
1205    for dr_sig, dr_json in deep_by_sig.items():
1206        # Skip if already rendered in a marginalia page
1207        if any(dr_sig == s.lower() for s in by_sig.keys()):
1208            continue
1209
1210        sig_slug = dr_sig.lower().replace(' ', '')
1211        import json as _json
1212        try:
1213            dr = _json.loads(dr_json)
1214        except Exception:
1215            continue
1216
1217        # Look up folio info from signature_map
1218        sm_row = cur.execute(
1219            "SELECT folio_number, side, quire FROM signature_map WHERE LOWER(signature) = ?",
1220            (dr_sig,)
1221        ).fetchone()
1222        folio_info = f'Folio {sm_row[0]}{sm_row[1]}' if sm_row else ''
1223        quire_info = f', Quire {sm_row[2]}' if sm_row and sm_row[2] else ''
1224
1225        # Get BL image if available
1226        page_num = None
1227        if sm_row:
1228            page_num_row = cur.execute(
1229                "SELECT id FROM signature_map WHERE LOWER(signature) = ?", (dr_sig,)
1230            ).fetchone()
1231            if page_num_row:
1232                page_num = page_num_row[0]
1233
1234        img_html = ''
1235        if page_num:
1236            bl_photo = page_num + 13
1237            img_row = cur.execute(
1238                "SELECT COALESCE(web_path, relative_path), filename FROM images WHERE filename LIKE ?",
1239                (f'C_60_o_12-{bl_photo:03d}%',)
1240            ).fetchone()
1241            if img_row:
1242                img_html = f"""
1243                <div class="marg-images">
1244                    <div class="marg-image-card">
1245                        <img src="../{img_row[0]}" alt="Folio {dr_sig}" loading="lazy">
1246                        <div class="caption">British Library, London &mdash; C.60.o.12</div>
1247                    </div>
1248                </div>"""
1249
1250        dr_html = _render_deep_reading(dr)
1251
1252        detail_body = f"""
1253        <div class="marg-detail">
1254            <p><a href="index.html">&larr; All Folios</a></p>
1255            <h2>Signature {escape(dr_sig)}</h2>
1256            <p style="color:var(--text-muted); font-family:var(--font-sans); margin-bottom:1.5rem">
1257                {folio_info}{quire_info}</p>
1258            {img_html}
1259            {dr_html}
1260        </div>"""
1261
1262        detail_page = page_shell(f'Folio {dr_sig}', detail_body, active_nav='marginalia', depth=1)
1263        (marg_dir / f'{sig_slug}.html').write_text(detail_page, encoding='utf-8')
1264        by_sig[dr_sig] = []  # Add to index
1265        standalone_dr += 1
1266
1267    # Build marginalia index
1268    grid_items = ''
1269    def _sig_sort_key(s):
1270        rows = by_sig[s]
1271        if rows:
1272            return (str(rows[0][18] or 'zzz'), int(rows[0][10] or 999))
1273        # Standalone deep-reading pages: sort by signature string
1274        return (s[0] if s else 'zzz', int(''.join(c for c in s if c.isdigit()) or '999'))
1275
1276    for sig in sorted(by_sig.keys(), key=_sig_sort_key):
1277        rows = by_sig[sig]
1278        sig_slug = sig.lower().replace(' ', '')
1279
1280        if rows:
1281            n_images = len(set(r[8] for r in rows))
1282            n_annotations = len(rows)
1283            has_alchemist = any(r[16] for r in rows)
1284            alch = ' <span class="alchemist-tag">Alch.</span>' if has_alchemist else ''
1285            meta = f'{n_images} image{"s" if n_images != 1 else ""}, {n_annotations} ref{"s" if n_annotations != 1 else ""}'
1286        else:
1287            alch = ''
1288            has_dr = sig.lower() in deep_by_sig
1289            meta = 'Vision reading only' if has_dr else 'No annotations'
1290
1291        grid_items += f"""
1292            <a href="{sig_slug}.html">
1293                <div class="sig-label">{escape(sig)}{alch}</div>
1294                <div class="sig-meta">{meta}</div>
1295            </a>"""
1296
1297    index_body = f"""
1298        <div class="marg-index">
1299            <div class="intro">
1300                <h2>Marginalia by Folio</h2>
1301                <p>Each card below represents a folio of the <em>Hypnerotomachia
1302                Poliphili</em> where marginal annotations have been documented in
1303                James Russell's PhD thesis. Folios are identified by their
1304                <strong>signature</strong>&mdash;a bibliographic notation combining
1305                a quire letter, leaf number, and side (<em>r</em> for recto,
1306                <em>v</em> for verso). For example, <em>b6v</em> is the verso of
1307                the sixth leaf in quire <em>b</em>.</p>
1308                <p>Folios tagged <span class="alchemist-tag" style="font-size:0.65rem">Alch.</span>
1309                contain annotations by one of two identified alchemist readers:
1310                Hand B in the British Library copy (a follower of d'Espagnet's
1311                mercury-centered framework) or Hand E in the Buffalo copy
1312                (a follower of pseudo-Geber's sulphur and Sol/Luna emphasis).</p>
1313                <p>{len(by_sig)} annotated folios from {len(set(r[5] for rows in by_sig.values() for r in rows))} manuscript copies.</p>
1314            </div>
1315            <div class="marg-grid">{grid_items}</div>
1316        </div>"""
1317
1318    index_page = page_shell('Marginalia', index_body, active_nav='marginalia', depth=1)
1319    (marg_dir / 'index.html').write_text(index_page, encoding='utf-8')
1320    print(f"  marginalia/index.html + {len(by_sig)} folio pages")
1321
1322
1323# ============================================================
1324# Bibliography page
1325# ============================================================
1326
1327def build_bibliography_page(conn):
1328    """Generate bibliography.html with full HP bibliography from DB."""
1329    cur = conn.cursor()
1330
1331    # Get all bibliography entries
1332    cur.execute("""
1333        SELECT id, author, title, year, pub_type, journal_or_publisher,
1334               hp_relevance, topic_cluster, in_collection, notes,
1335               review_status, needs_review
1336        FROM bibliography
1337        ORDER BY
1338            CASE WHEN year IS NULL THEN 9999 ELSE CAST(year AS INTEGER) END,
1339            author
1340    """)
1341    entries = cur.fetchall()
1342
1343    # Group by relevance
1344    by_relevance = {}
1345    for e in entries:
1346        rel = e[6] or 'TANGENTIAL'
1347        by_relevance.setdefault(rel, []).append(e)
1348
1349    relevance_order = ['PRIMARY', 'DIRECT', 'INDIRECT', 'TANGENTIAL']
1350    relevance_labels = {
1351        'PRIMARY': 'Primary Sources & Editions',
1352        'DIRECT': 'HP Scholarship (Direct)',
1353        'INDIRECT': 'Related Studies',
1354        'TANGENTIAL': 'General References',
1355    }
1356
1357    bib_css = '<style>' + """
1358        .bib-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
1359        .bib-section { margin-bottom: 2.5rem; }
1360        .bib-section h3 {
1361            font-size: 1.1rem; color: var(--accent);
1362            border-bottom: 1px solid var(--border);
1363            padding-bottom: 0.3rem; margin-bottom: 1rem;
1364        }
1365        .bib-entry { margin-bottom: 1rem; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; }
1366        .bib-entry .bib-author { font-weight: 600; }
1367        .bib-entry .bib-title { font-style: italic; }
1368        .bib-entry .bib-details { font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-sans); margin-top: 0.25rem; }
1369        .bib-entry .bib-badges { margin-top: 0.3rem; }
1370        .bib-badge-collection { display: inline-block; padding: 0.1rem 0.4rem; background: #d4edda; color: #155724; border-radius: 2px; font-size: 0.65rem; font-weight: 600; font-family: var(--font-sans); text-transform: uppercase; }
1371        .bib-badge-missing { display: inline-block; padding: 0.1rem 0.4rem; background: #f8d7da; color: #721c24; border-radius: 2px; font-size: 0.65rem; font-weight: 600; font-family: var(--font-sans); text-transform: uppercase; }
1372        .bib-stats { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 2rem; }
1373        .bib-stat { text-align: center; padding: 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; flex: 1; min-width: 120px; }
1374        .bib-stat .num { font-size: 1.5rem; font-weight: 700; color: var(--accent); display: block; }
1375        .bib-stat .lbl { font-size: 0.8rem; color: var(--text-muted); font-family: var(--font-sans); }
1376    """ + '</style>'
1377
1378    # Stats
1379    total = len(entries)
1380    in_coll = sum(1 for e in entries if e[8])
1381    primary = len(by_relevance.get('PRIMARY', []))
1382    direct = len(by_relevance.get('DIRECT', []))
1383    needs_rev = sum(1 for e in entries if e[11])
1384
1385    stats_html = f"""
1386        <div class="bib-stats">
1387            <div class="bib-stat"><span class="num">{total}</span><span class="lbl">Total Works</span></div>
1388            <div class="bib-stat"><span class="num">{in_coll}</span><span class="lbl">In Collection</span></div>
1389            <div class="bib-stat"><span class="num">{primary}</span><span class="lbl">Primary Sources</span></div>
1390            <div class="bib-stat"><span class="num">{direct}</span><span class="lbl">Direct Scholarship</span></div>
1391            <div class="bib-stat"><span class="num">{needs_rev}</span><span class="lbl">Need Review</span></div>
1392        </div>"""
1393
1394    # Build sections
1395    sections_html = ''
1396    for rel in relevance_order:
1397        items = by_relevance.get(rel, [])
1398        if not items:
1399            continue
1400
1401        entries_html = ''
1402        for e in items:
1403            (eid, author, title, year, pub_type, journal,
1404             relevance, topic, in_coll_flag, notes,
1405             rev_status, needs_rev_flag) = e
1406
1407            year_str = f' ({year})' if year else ''
1408            journal_str = f'. {escape(journal)}' if journal else ''
1409            type_str = f' [{pub_type}]' if pub_type else ''
1410
1411            collection_badge = '<span class="bib-badge-collection">In Collection</span>' if in_coll_flag else '<span class="bib-badge-missing">Not in Collection</span>'
1412            review_badge = review_badge_html(needs_rev_flag) if needs_rev_flag else ''
1413            topic_html = topic_badges_html(topic) if topic else ''
1414
1415            entries_html += f"""
1416                <div class="bib-entry">
1417                    <div><span class="bib-author">{escape(author or "")}</span>{year_str}.
1418                    <span class="bib-title">{escape(title)}</span>{journal_str}{type_str}.</div>
1419                    <div class="bib-badges">{collection_badge} {topic_html} {review_badge}</div>
1420                </div>"""
1421
1422        label = relevance_labels.get(rel, rel)
1423        sections_html += f"""
1424            <section class="bib-section">
1425                <h3>{label} ({len(items)})</h3>
1426                {entries_html}
1427            </section>"""
1428
1429    body = f"""
1430        <div class="bib-page">
1431            <div class="intro">
1432                <h2>Bibliography</h2>
1433                <p>This bibliography is not a simple reading list. It is the site's
1434                map of the scholarly field: a structured record of primary sources,
1435                direct <em>Hypnerotomachia</em> scholarship, and related studies that
1436                help explain the book's material form, reception, imagery, and
1437                interpretive traditions. Entries are grouped so that readers can
1438                distinguish the core literature from the wider scholarly surround.</p>
1439                <p>Because bibliography is one of the project's main trust surfaces,
1440                this section places emphasis on review state, collection status,
1441                and data hygiene. Each entry shows whether we hold the work in our
1442                collection and whether the citation has been verified. That
1443                uncertainty remains visible rather than hidden behind uniform
1444                presentation.</p>
1445            </div>
1446            {stats_html}
1447            {sections_html}
1448        </div>"""
1449
1450    page = page_shell('Bibliography', body, active_nav='bibliography')
1451    (SITE_DIR / 'bibliography.html').write_text(page, encoding='utf-8')
1452
1453    print(f"  bibliography.html: {total} entries")
1454
1455
1456# ============================================================
1457# About page
1458# ============================================================
1459
1460def build_about_page(conn):
1461    cur = conn.cursor()
1462
1463    # Get stats
1464    stats = {}
1465    for table in ['documents', 'images', 'annotations', 'matches',
1466                   'annotator_hands', 'bibliography', 'scholars', 'dictionary_terms',
1467                   'timeline_events']:
1468        try:
1469            cur.execute(f"SELECT COUNT(*) FROM {table}")
1470            stats[table] = cur.fetchone()[0]
1471        except:
1472            stats[table] = 0
1473
1474    cur.execute("SELECT COUNT(*) FROM matches WHERE confidence='HIGH'")
1475    stats['high_conf'] = cur.fetchone()[0]
1476    cur.execute("SELECT COUNT(*) FROM matches WHERE confidence='LOW'")
1477    stats['low_conf'] = cur.fetchone()[0]
1478    cur.execute("SELECT COUNT(*) FROM bibliography WHERE in_collection=1")
1479    stats['in_collection'] = cur.fetchone()[0]
1480
1481    body = f"""
1482        <div class="scholar-detail">
1483            <h2>About This Project</h2>
1484
1485            <div class="paper-detail">
1486                <h3>What Is This?</h3>
1487                <div class="paper-summary-full"><p>
1488                The <em>Hypnerotomachia Poliphili</em>, published by Aldus Manutius
1489                in Venice in 1499, is among the most celebrated and least understood
1490                books of the Italian Renaissance. Written in a macaronic blend of
1491                Italian, Latin, Greek, and pseudo-hieroglyphs, and illustrated with
1492                172 woodcuts of extraordinary refinement, it tells the story of
1493                Poliphilo's dream-journey through ruined temples, enchanted gardens,
1494                and allegorical processions in pursuit of his beloved Polia.
1495                </p><p>
1496                This project documents the <em>readership</em> of the HP &mdash; not just
1497                the text itself, but what five centuries of readers did with it.
1498                James Russell's PhD thesis (Durham, 2014) conducted a world census
1499                of annotated copies, finding that readers as diverse as Ben Jonson,
1500                Pope Alexander VII, Benedetto Giovio, and anonymous alchemists left
1501                extensive marginalia recording their encounters with the text.
1502                This site presents Russell's findings alongside photographs of two
1503                annotated copies, a bibliography of HP scholarship, a dictionary of
1504                essential terminology, and the full documentation of how the platform
1505                was built.
1506                </p></div>
1507            </div>
1508
1509            <div class="paper-detail">
1510                <h3>Database Statistics</h3>
1511                <div class="paper-summary-full">
1512                <ul style="list-style:none; padding:0;">
1513                    <li><strong>{stats['images']}</strong> manuscript images catalogued</li>
1514                    <li><strong>{stats['annotations']}</strong> annotations extracted from Russell's thesis</li>
1515                    <li><strong>{stats['matches']}</strong> image-reference matches
1516                        ({stats['high_conf']} high confidence, {stats['low_conf']} low/provisional)</li>
1517                    <li><strong>{stats['annotator_hands']}</strong> annotator hands identified</li>
1518                    <li><strong>{stats['bibliography']}</strong> works in bibliography
1519                        ({stats['in_collection']} in our collection)</li>
1520                    <li><strong>{stats['scholars']}</strong> scholar profiles</li>
1521                    <li><strong>{stats['dictionary_terms']}</strong> dictionary terms</li>
1522                    <li><strong>{stats['timeline_events']}</strong> timeline events</li>
1523                </ul>
1524                </div>
1525            </div>
1526
1527            <div class="paper-detail">
1528                <h3>Data Provenance</h3>
1529                <div class="paper-summary-full"><p>
1530                Content on this site has varying levels of verification:
1531                </p>
1532                <ul>
1533                    <li><strong>Verified</strong>: Signature maps, collation formulae, and image
1534                    cataloging are deterministic and correct.</li>
1535                    <li><strong>High confidence</strong>: Siena O.III.38 image matches use explicit
1536                    recto/verso naming.</li>
1537                    <li><strong>Low confidence</strong>: BL C.60.o.12 matches assume sequential photo
1538                    numbers equal folio numbers. The BL copy is the 1545 edition; the signature map
1539                    is based on the 1499. Manual verification needed.</li>
1540                    <li><strong>Unreviewed</strong>: Scholar summaries, dictionary definitions, and
1541                    hand attributions were generated with LLM assistance and have not been verified
1542                    by a domain expert. These are marked with
1543                    <span class="review-badge" style="font-size:0.7rem">Unreviewed</span> badges.</li>
1544                </ul>
1545                </div>
1546            </div>
1547
1548            <div class="paper-detail">
1549                <h3>How to Rebuild</h3>
1550                <div class="paper-summary-full"><p>
1551                The site is generated from a SQLite database (<code>db/hp.db</code>).
1552                To rebuild all pages:
1553                </p>
1554                <pre style="background:var(--bg); padding:1rem; border-radius:4px; overflow-x:auto; font-size:0.85rem">
1555python scripts/migrate_v2.py        # Schema migration (idempotent)
1556python scripts/seed_dictionary.py   # Dictionary terms (idempotent)
1557python scripts/build_site.py        # Generate all HTML + JSON
1558                </pre>
1559                <p>Individual pipeline steps:</p>
1560                <pre style="background:var(--bg); padding:1rem; border-radius:4px; overflow-x:auto; font-size:0.85rem">
1561python scripts/init_db.py           # Initialize DB schema
1562python scripts/catalog_images.py    # Catalog manuscript images
1563python scripts/build_signature_map.py  # Build signature map
1564python scripts/extract_references.py   # Extract refs from thesis PDF
1565python scripts/match_refs_to_images.py # Match refs to images
1566python scripts/add_hands.py         # Add annotator hand profiles
1567python scripts/add_bibliography.py  # Add bibliography and timeline
1568                </pre>
1569                </div>
1570            </div>
1571        </div>"""
1572
1573    page = page_shell('About', body, active_nav='about')
1574    (SITE_DIR / 'about.html').write_text(page, encoding='utf-8')
1575    print("  about.html")
1576
1577
1578# ============================================================
1579# Documents tab
1580# ============================================================
1581
1582# Document metadata: (filename, title, one-line summary)
1583DOC_METADATA = {
1584    # Core docs (root level)
1585    'SYSTEM.md': ('System Architecture', 'Architecture, data flow, operating modes, and constraints for the HP platform.'),
1586    'ONTOLOGY.md': ('Data Ontology', 'All 22 tables: canonical entities, deprecated tables, relationships, coverage, and confidence.'),
1587    'PIPELINE.md': ('Build Pipeline', 'Every script in execution order, from PDF ingestion through site generation.'),
1588    'INTERFACE.md': ('Interface Design', 'How data becomes pages: navigation, surfacing audit, page builders, design language.'),
1589    'ROADMAP.md': ('Execution Roadmap', 'What is BUILT, READY, BLOCKED, and SPECULATIVE. No hypothetical features.'),
1590    'README.md': ('README', 'Project overview for GitHub.'),
1591    # Archived docs (still published on the site for reference)
1592    'docs/archive/HPCONCORD.md': ('Concordance Methodology', 'How the 6-step folio-to-image concordance was built from Russell\'s thesis.'),
1593    'docs/archive/HPMIT.md': ('MIT Site Analysis', 'Reverse-engineering of the MIT Electronic Hypnerotomachia (1997).'),
1594    'docs/archive/HPMULTIMODAL.md': ('Multimodal RAG Study', 'Vision model proposal for reading 674 manuscript images.'),
1595    'docs/archive/MISTAKESTOAVOID.md': ('Mistakes to Avoid', 'Twelve hard-won lessons from this project.'),
1596    'docs/archive/IMAGEIDENTIFICATION.md': ('Image Identification', 'What Claude saw reading 69 BL manuscript photographs.'),
1597    'docs/archive/CONCORDANCEHACKING.md': ('Concordance Progress', 'How the concordance problem-solving progressed and metrics.'),
1598    'docs/archive/WOODCUTRESEARCHREPORT.md': ('Woodcut Research', '18 woodcuts: subjects, attribution, scholarly context.'),
1599    'docs/archive/ISIDORE4.md': ('System Critique', 'Concordance system audit: 7 issues, 0 integrity errors.'),
1600    'docs/archive/OUTWARDNOTDEEPER.md': ('Session Findings', 'Build outward not deeper: session discoveries and philosophy.'),
1601}
1602
1603# Script metadata: (filename, title, one-line summary)
1604SCRIPT_METADATA = {
1605    'init_db.py': ('Initialize Database', 'Creates SQLite schema (7 core tables) and catalogs PDFs/documents from the filesystem.'),
1606    'catalog_images.py': ('Catalog Images', 'Parses image filenames from BL and Siena collections into the images table with folio/side metadata.'),
1607    'build_signature_map.py': ('Build Signature Map', 'Generates the 448-entry signature-to-folio concordance from the Aldine collation formula (a-z, A-G).'),
1608    'extract_references.py': ('Extract References', 'Uses PyMuPDF + regex to extract 282 folio/signature references from Russell\'s PhD thesis PDF.'),
1609    'match_refs_to_images.py': ('Match Refs to Images', 'SQL join pipeline matching dissertation references to manuscript images via the signature map.'),
1610    'add_hands.py': ('Add Annotator Hands', 'Creates 11 annotator hand profiles and attributes dissertation references to specific hands.'),
1611    'add_bibliography.py': ('Add Bibliography', 'Populates bibliography (58 entries), scholars (29), timeline (39 events) from hardcoded research data.'),
1612    'migrate_v2.py': ('Schema Migration V2', 'Adds annotations, annotators, doc_folio_refs, dictionary tables, review/provenance columns. Downgrades BL confidence.'),
1613    'seed_dictionary.py': ('Seed Dictionary', 'Inserts 37 dictionary terms across 6 categories with 76 bidirectional cross-reference links.'),
1614    'build_site.py': ('Build Site', 'Unified site generator: exports data.json, builds all HTML pages (scholars, dictionary, marginalia, bibliography, docs, code, about).'),
1615    'build_scholar_profiles.py': ('Build Scholar Profiles (Legacy)', 'Original scholar page generator from summaries.json. Superseded by build_site.py.'),
1616    'export_showcase_data.py': ('Export Showcase Data (Legacy)', 'Original data.json exporter for the gallery. Superseded by build_site.py.'),
1617    'validate.py': ('Validate & QA', 'Checks data integrity (duplicate slugs, broken links, confidence distribution) and writes AUDIT_REPORT.md.'),
1618    'ingest_perplexity.py': ('Ingest Perplexity Research', 'Adds 9 bibliography entries and 3 timeline events from HPPERPLEXITY.txt web research.'),
1619    'pdf_to_markdown.py': ('PDF to Markdown', 'Extracts all PDFs to markdown with YAML frontmatter, page markers, and metadata lookup.'),
1620    'chunk_documents.py': ('Chunk Documents', 'Splits markdown files into ~1500-word semantic chunks for RAG/retrieval systems.'),
1621    'migrate_dictionary_v2.py': ('Dictionary Schema V2', 'Extends dictionary_terms with significance, source tracking, provenance, and confidence columns.'),
1622    'corpus_search.py': ('Corpus Search', 'Keyword-based search across markdown chunks and documents with provenance tracking.'),
1623    'dictionary_audit.py': ('Dictionary Audit', 'Audits dictionary coverage: missing fields, duplicate slugs, orphaned links, weak terms.'),
1624    'build_reading_packets.py': ('Build Reading Packets', 'Assembles structured research packets from corpus search for dictionary enrichment.'),
1625    'enrich_dictionary.py': ('Enrich Dictionary', 'Populates dictionary fields from reading packets with source provenance and review status.'),
1626    'build_essay_data.py': ('Build Essay Data', 'Extracts structured evidence from DB and corpus for the Russell and Concordance essays.'),
1627    'add_alchemist_descriptions.py': ('Add Alchemist Descriptions', 'Inserts 13 folio-specific scholarly descriptions for the two alchemist annotators from Russell Ch. 6-7.'),
1628    'seed_dictionary_v2.py': ('Seed Dictionary V2', 'Seeds 43 HP entity terms: characters, places, architecture, gardens, processions, aesthetics, materials.'),
1629    'seed_dictionary_v3.py': ('Seed Dictionary V3', 'Seeds 14 additional terms: narrative form, built form, aesthetics, alchemy, material culture.'),
1630    'generate_dictionary_significance.py': ('Generate Significance', 'Generates significance_to_hp and significance_to_scholarship prose for all 80+ dictionary terms.'),
1631    'link_scholars.py': ('Link Scholars', 'Links scholars to bibliography, tags historical figures, matches summaries.json to bibliography entries.'),
1632    'generate_scholar_overviews.py': ('Generate Scholar Overviews', 'Generates 2-3 paragraph overview prose for modern scholars and role descriptions for historical figures.'),
1633    'migrate_timeline.py': ('Timeline Migration', 'Adds category, medium, location, image_ref, confidence columns to timeline_events table.'),
1634    'seed_timeline_v2.py': ('Seed Timeline V2', 'Seeds ~30 new timeline events: art, literary influence, scholarly milestones, garden design.'),
1635    'seed_copies.py': ('Seed Copies', 'Creates hp_copies table and seeds six annotated copies with full metadata from Russell 2014.'),
1636}
1637
1638
1639def markdown_to_html(md_text):
1640    """Minimal markdown-to-HTML conversion for document display."""
1641    import re
1642    lines = md_text.split('\n')
1643    html_lines = []
1644    in_code = False
1645    in_list = False
1646    in_table = False
1647    table_header_done = False
1648
1649    for line in lines:
1650        # Code blocks
1651        if line.strip().startswith('```'):
1652            if in_code:
1653                html_lines.append('</code></pre>')
1654                in_code = False
1655            else:
1656                lang = line.strip()[3:]
1657                html_lines.append(f'<pre><code class="lang-{lang}">')
1658                in_code = True
1659            continue
1660        if in_code:
1661            html_lines.append(escape(line))
1662            continue
1663
1664        # Close list if needed
1665        if in_list and not line.strip().startswith(('-', '*', '1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.')):
1666            html_lines.append('</ul>')
1667            in_list = False
1668
1669        # Tables
1670        if '|' in line and line.strip().startswith('|'):
1671            cells = [c.strip() for c in line.strip().split('|')[1:-1]]
1672            if all(set(c) <= set('- :') for c in cells):
1673                table_header_done = True
1674                continue
1675            if not in_table:
1676                html_lines.append('<table class="doc-table">')
1677                in_table = True
1678            tag = 'th' if not table_header_done else 'td'
1679            row = ''.join(f'<{tag}>{escape(c)}</{tag}>' for c in cells)
1680            html_lines.append(f'<tr>{row}</tr>')
1681            continue
1682        elif in_table:
1683            html_lines.append('</table>')
1684            in_table = False
1685            table_header_done = False
1686
1687        stripped = line.strip()
1688
1689        # Headings
1690        if stripped.startswith('### '):
1691            html_lines.append(f'<h4>{escape(stripped[4:])}</h4>')
1692        elif stripped.startswith('## '):
1693            html_lines.append(f'<h3>{escape(stripped[3:])}</h3>')
1694        elif stripped.startswith('# '):
1695            html_lines.append(f'<h2>{escape(stripped[2:])}</h2>')
1696        elif stripped.startswith('---'):
1697            html_lines.append('<hr>')
1698        elif stripped.startswith(('- ', '* ')):
1699            if not in_list:
1700                html_lines.append('<ul>')
1701                in_list = True
1702            html_lines.append(f'<li>{escape(stripped[2:])}</li>')
1703        elif stripped.startswith('>'):
1704            html_lines.append(f'<blockquote>{escape(stripped[1:].strip())}</blockquote>')
1705        elif stripped == '':
1706            html_lines.append('')
1707        else:
1708            # Apply inline formatting
1709            text = escape(stripped)
1710            # Bold
1711            text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
1712            # Italic
1713            text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
1714            # Inline code
1715            text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
1716            html_lines.append(f'<p>{text}</p>')
1717
1718    if in_list:
1719        html_lines.append('</ul>')
1720    if in_table:
1721        html_lines.append('</table>')
1722    if in_code:
1723        html_lines.append('</code></pre>')
1724
1725    return '\n'.join(html_lines)
1726
1727
1728def build_docs_pages():
1729    """Generate docs/index.html and docs/*.html from project markdown files."""
1730    docs_dir = SITE_DIR / 'docs'
1731    docs_dir.mkdir(exist_ok=True)
1732
1733    doc_css = '<style>' + """
1734        .docs-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
1735        .docs-table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
1736        .docs-table th, .docs-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
1737        .docs-table th { font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
1738        .docs-table td a { color: var(--accent); text-decoration: none; font-weight: 600; }
1739        .docs-table td a:hover { text-decoration: underline; }
1740        .docs-table .doc-summary { font-size: 0.85rem; color: var(--text-muted); }
1741        .doc-content { max-width: 800px; margin: 2rem auto; padding: 0 2rem; }
1742        .doc-content h2 { color: var(--accent); margin: 2rem 0 0.5rem; }
1743        .doc-content h3 { color: var(--text); margin: 1.5rem 0 0.5rem; }
1744        .doc-content h4 { color: var(--text-muted); margin: 1rem 0 0.5rem; }
1745        .doc-content p { margin-bottom: 0.75rem; line-height: 1.7; }
1746        .doc-content pre { background: var(--bg); padding: 1rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; margin: 1rem 0; }
1747        .doc-content code { font-size: 0.9em; background: var(--bg); padding: 0.1rem 0.3rem; border-radius: 2px; }
1748        .doc-content pre code { background: none; padding: 0; }
1749        .doc-content blockquote { border-left: 3px solid var(--accent-light); padding-left: 1rem; color: var(--text-muted); font-style: italic; margin: 1rem 0; }
1750        .doc-content ul { margin: 0.5rem 0 1rem 1.5rem; }
1751        .doc-content li { margin-bottom: 0.3rem; line-height: 1.6; }
1752        .doc-content hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
1753        .doc-content table.doc-table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; }
1754        .doc-content table.doc-table th, .doc-content table.doc-table td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); }
1755        .doc-content table.doc-table th { background: var(--bg); font-weight: 600; }
1756    """ + '</style>'
1757
1758    # Read all docs and build pages
1759    docs = []
1760    for filename, (title, summary) in sorted(DOC_METADATA.items()):
1761        filepath = BASE_DIR / filename
1762        if not filepath.exists():
1763            continue
1764
1765        slug = slugify(title)
1766        content = filepath.read_text(encoding='utf-8')
1767        word_count = len(content.split())
1768
1769        docs.append({
1770            'filename': filename,
1771            'title': title,
1772            'summary': summary,
1773            'slug': slug,
1774            'word_count': word_count,
1775        })
1776
1777        # Build detail page
1778        content_html = markdown_to_html(content)
1779        detail_body = f"""
1780        <div class="doc-content">
1781            <p><a href="index.html">&larr; All Documents</a></p>
1782            <h2>{escape(title)}</h2>
1783            <p style="color:var(--text-muted); font-family:var(--font-sans); font-size:0.85rem; margin-bottom:1.5rem">
1784                {escape(filename)} &mdash; {word_count:,} words</p>
1785            {content_html}
1786        </div>"""
1787
1788        detail_page = page_shell(title, detail_body, active_nav='docs', depth=1)
1789        (docs_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8')
1790
1791    # Build index page
1792    rows_html = ''
1793    for d in docs:
1794        rows_html += f"""
1795            <tr>
1796                <td><a href="{d['slug']}.html">{escape(d['title'])}</a></td>
1797                <td class="doc-summary">{escape(d['summary'])}</td>
1798                <td style="font-family:var(--font-sans); font-size:0.85rem; white-space:nowrap">{d['word_count']:,}</td>
1799            </tr>"""
1800
1801    index_body = f"""
1802        <div class="docs-page">
1803            <div class="intro">
1804                <h2>Project Documents</h2>
1805                <p>This section makes the project legible as a research build
1806                rather than a black box. It gathers {len(docs)} internal methodological
1807                and reflective documents&mdash;ontology notes, concordance method,
1808                boundary audits, proposals, mistakes, aesthetic reviews, and related
1809                planning texts&mdash;that explain what the project thinks it is doing
1810                and where its assumptions have shifted over time.</p>
1811                <p>These documents are part of the scholarly apparatus, not just
1812                engineering residue. They record how the database, site, and
1813                interpretive claims were assembled, where the strongest arguments
1814                lie, and where the project has had to revise itself.</p>
1815            </div>
1816            <table class="docs-table">
1817                <thead><tr><th>Document</th><th>Description</th><th>Words</th></tr></thead>
1818                <tbody>{rows_html}</tbody>
1819            </table>
1820        </div>"""
1821
1822    index_page = page_shell('Documents', index_body, active_nav='docs', depth=1)
1823    (docs_dir / 'index.html').write_text(index_page, encoding='utf-8')
1824    print(f"  docs/index.html + {len(docs)} document pages")
1825
1826
1827def build_code_pages():
1828    """Generate code/index.html and code/*.html from Python scripts."""
1829    code_dir = SITE_DIR / 'code'
1830    code_dir.mkdir(exist_ok=True)
1831
1832    code_css = '<style>' + """
1833        .code-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
1834        .code-table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
1835        .code-table th, .code-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); }
1836        .code-table th { font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
1837        .code-table td a { color: var(--accent); text-decoration: none; font-weight: 600; font-family: monospace; }
1838        .code-table td a:hover { text-decoration: underline; }
1839        .code-table .code-summary { font-size: 0.85rem; color: var(--text-muted); }
1840        .code-content { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; }
1841        .code-content h2 { color: var(--accent); margin-bottom: 0.5rem; }
1842        .code-content pre {
1843            background: #1e1e1e; color: #d4d4d4; padding: 1.5rem; border-radius: 4px;
1844            overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
1845            max-height: 80vh; overflow-y: auto;
1846        }
1847        .code-content .line-num { color: #858585; user-select: none; display: inline-block; width: 3.5em; text-align: right; margin-right: 1em; }
1848        .code-meta { font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1.5rem; }
1849    """ + '</style>'
1850
1851    scripts = []
1852    scripts_dir = BASE_DIR / 'scripts'
1853
1854    for filename, (title, summary) in sorted(SCRIPT_METADATA.items()):
1855        filepath = scripts_dir / filename
1856        if not filepath.exists():
1857            continue
1858
1859        slug = slugify(filename.replace('.py', ''))
1860        content = filepath.read_text(encoding='utf-8')
1861        line_count = len(content.splitlines())
1862
1863        scripts.append({
1864            'filename': filename,
1865            'title': title,
1866            'summary': summary,
1867            'slug': slug,
1868            'line_count': line_count,
1869        })
1870
1871        # Build detail page with syntax-highlighted code
1872        lines = content.splitlines()
1873        code_lines = []
1874        for i, line in enumerate(lines, 1):
1875            num = f'<span class="line-num">{i}</span>'
1876            code_lines.append(f'{num}{escape(line)}')
1877        code_html = '\n'.join(code_lines)
1878
1879        detail_body = f"""
1880        <div class="code-content">
1881            <p><a href="index.html">&larr; All Scripts</a></p>
1882            <h2>{escape(title)}</h2>
1883            <div class="code-meta">{escape(filename)} &mdash; {line_count} lines</div>
1884            <p style="margin-bottom:1rem">{escape(summary)}</p>
1885            <pre>{code_html}</pre>
1886        </div>"""
1887
1888        detail_page = page_shell(title, detail_body, active_nav='code', depth=1)
1889        (code_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8')
1890
1891    # Build index page
1892    rows_html = ''
1893    for s in scripts:
1894        rows_html += f"""
1895            <tr>
1896                <td><a href="{s['slug']}.html">{escape(s['filename'])}</a></td>
1897                <td>{escape(s['title'])}</td>
1898                <td class="code-summary">{escape(s['summary'])}</td>
1899                <td style="font-family:var(--font-sans); font-size:0.85rem; white-space:nowrap">{s['line_count']}</td>
1900            </tr>"""
1901
1902    index_body = f"""
1903        <div class="code-page">
1904            <div class="intro">
1905                <h2>Pipeline Scripts</h2>
1906                <p>This section exposes the deterministic pipeline behind the site.
1907                Rather than hiding the build logic, it presents these {len(scripts)} Python
1908                scripts that initialize the database, parse filenames, extract references,
1909                match folios to images, seed and enrich metadata, validate assumptions,
1910                and generate the static pages. The aim is transparency and reproducibility.</p>
1911                <p>The project's architectural stance is deliberately conservative: SQLite
1912                as source of truth, Python for transformation, JSON and static HTML for
1913                delivery, and as little framework machinery as possible. That simplicity
1914                is not an omission; it is part of the project's long-term durability model.</p>
1915            </div>
1916            <table class="code-table">
1917                <thead><tr><th>Script</th><th>Name</th><th>Description</th><th>Lines</th></tr></thead>
1918                <tbody>{rows_html}</tbody>
1919            </table>
1920        </div>"""
1921
1922    index_page = page_shell('Code', index_body, active_nav='code', depth=1)
1923    (code_dir / 'index.html').write_text(index_page, encoding='utf-8')
1924    print(f"  code/index.html + {len(scripts)} script pages")
1925
1926
1927# ============================================================
1928# Essay: Russell's Alchemical Hands
1929# ============================================================
1930
1931def _build_annotated_woodcuts_gallery(conn):
1932    """Build the annotated woodcuts gallery section for the Alchemical Hands page.
1933
1934    Shows the 18 CORPUS_EXTRACTION woodcuts with their scholarly apparatus:
1935    images, descriptions, annotation notes, dictionary links, and discussion.
1936    """
1937    cur = conn.cursor()
1938    cur.execute("""
1939        SELECT slug, title, signature_1499, page_1499,
1940               subject_category, description, narrative_context,
1941               has_annotation, alchemical_annotation, annotation_density,
1942               dictionary_terms, scholarly_discussion, influence,
1943               depicted_elements
1944        FROM woodcuts
1945        WHERE source_method = 'CORPUS_EXTRACTION'
1946        ORDER BY page_1499
1947    """)
1948    woodcuts = cur.fetchall()
1949    if not woodcuts:
1950        return ''
1951
1952    ia_img_dir = SITE_DIR / 'images' / 'woodcuts_1499'
1953    marg_dir = SITE_DIR / 'marginalia'
1954    dict_dir = SITE_DIR / 'dictionary'
1955    _ag_marg_sigs = {f.stem for f in marg_dir.glob('*.html') if f.stem != 'index'} if marg_dir.exists() else set()
1956    _ag_dict_slugs = {f.stem for f in dict_dir.glob('*.html') if f.stem != 'index'} if dict_dir.exists() else set()
1957
1958    cat_colors = {
1959        'ARCHITECTURAL': '#8b5cf6', 'LANDSCAPE': '#10b981', 'NARRATIVE': '#3b82f6',
1960        'HIEROGLYPHIC': '#f59e0b', 'PROCESSION': '#ef4444', 'DECORATIVE': '#6366f1',
1961        'PORTRAIT': '#ec4899', 'DIAGRAM': '#14b8a6',
1962    }
1963
1964    cards = ''
1965    for (slug, title, sig, page, cat, desc, narrative,
1966         has_ann, has_alch, ann_density, dict_terms,
1967         scholarly, influence, elements) in woodcuts:
1968
1969        color = cat_colors.get(cat, '#6b7280')
1970        img_filename = f'hp1499_p{page:03d}.jpg'
1971        img_exists = (ia_img_dir / img_filename).exists()
1972
1973        # Image
1974        if img_exists:
1975            img_tag = f'<img src="images/woodcuts_1499/{img_filename}" alt="{escape(title)}" style="width:100%;max-height:400px;object-fit:contain;border:1px solid var(--border);background:#f5f0e8;">'
1976        else:
1977            img_tag = '<div style="height:200px;background:#e8e0d0;display:flex;align-items:center;justify-content:center;color:#999">Image pending</div>'
1978
1979        # Badges
1980        badges = f'<span class="wc-cat-badge" style="background:{color}">{escape(cat or "")}</span>'
1981        if has_alch:
1982            badges += ' <span class="alchemist-tag">Alchemist</span>'
1983        if ann_density:
1984            badges += f' <span style="font-size:0.7rem;color:var(--text-muted)">{ann_density}</span>'
1985
1986        # Annotation note
1987        ann_note = ''
1988        if has_ann:
1989            ann_note = f'<p style="font-size:0.85rem;color:var(--accent);margin:0.5rem 0 0"><strong>BL annotations present</strong>'
1990            if has_alch:
1991                ann_note += ' (alchemical)'
1992            if sig and sig.lower() in _ag_marg_sigs:
1993                ann_note += f' &mdash; <a href="marginalia/{sig.lower()}.html">View folio {escape(sig)}</a>'
1994            ann_note += '</p>'
1995
1996        # Dictionary links
1997        dict_html = ''
1998        if dict_terms:
1999            terms = [t.strip() for t in dict_terms.split(',') if t.strip() and t.strip() in _ag_dict_slugs]
2000            dict_links = ' '.join(f'<a href="dictionary/{t}.html" style="font-size:0.75rem;padding:0.1rem 0.4rem;background:var(--bg);border:1px solid var(--border);border-radius:2px;text-decoration:none;color:var(--text)">{t.replace("-", " ").title()}</a>' for t in terms)
2001            dict_html = f'<div style="margin-top:0.5rem">{dict_links}</div>'
2002
2003        # Scholarly discussion
2004        schol_html = ''
2005        if scholarly:
2006            schol_html = f'<p style="font-size:0.85rem;color:var(--text-muted);margin-top:0.5rem;font-style:italic">{escape(scholarly[:200])}{"..." if len(scholarly) > 200 else ""}</p>'
2007
2008        cards += f"""
2009        <div style="background:var(--bg-card);border:1px solid var(--border);border-radius:6px;padding:1.25rem;margin-bottom:1.5rem">
2010            <div style="display:grid;grid-template-columns:minmax(200px,350px) 1fr;gap:1.5rem;align-items:start">
2011                <div>{img_tag}</div>
2012                <div>
2013                    <h4 style="margin:0 0 0.3rem;font-size:1.05rem;color:var(--text)">{escape(title)}</h4>
2014                    <div style="margin-bottom:0.5rem">{badges} <span style="font-size:0.8rem;color:var(--text-muted)">{escape(sig or "")} &middot; p.{page}</span></div>
2015                    <p style="line-height:1.7;margin:0">{escape(desc or "")}</p>
2016                    {schol_html}
2017                    {ann_note}
2018                    {dict_html}
2019                </div>
2020            </div>
2021        </div>"""
2022
2023    alch_count = sum(1 for w in woodcuts if w[8])  # has_alch
2024    heavy_count = sum(1 for w in woodcuts if w[9] == 'HEAVY')
2025
2026    return f"""
2027        <h2 id="annotated-woodcuts">Woodcuts with Marginal Annotations
2028            <a class="section-anchor" href="#annotated-woodcuts">#</a></h2>
2029        <p>Russell's dissertation identified 18 woodcut pages in the BL copy (C.60.o.12) that bear
2030        marginal annotations. Of these, {alch_count} have specifically alchemical annotations by Hand B,
2031        and {heavy_count} have heavy annotation density. These pages represent the intersection of the
2032        HP's visual program with its readers' interpretive activity &mdash; the woodcuts that provoked
2033        the most active engagement from the annotating hands.</p>
2034        <p class="evidence-note">These woodcuts were extracted from Russell's corpus analysis. Each entry
2035        preserves the original scholarly description, annotation data, and dictionary cross-references.
2036        Click the folio link to view the full marginalia page with Phase 3 deep readings.</p>
2037        {cards}"""
2038
2039
2040def build_russell_essay_page(conn):
2041    """Generate russell-alchemical-hands.html from DB evidence + corpus data."""
2042    cur = conn.cursor()
2043
2044    # Get alchemist hands
2045    cur.execute("""
2046        SELECT hand_label, manuscript_shelfmark, attribution, school,
2047               description, language, ink_color, date_range, interests
2048        FROM annotator_hands WHERE is_alchemist = 1
2049    """)
2050    alchemist_hands = cur.fetchall()
2051
2052    # Get alchemist refs with signatures
2053    cur.execute("""
2054        SELECT a.signature_ref, a.thesis_page, dr.context_text, a.annotation_text,
2055               a.thesis_chapter, h.manuscript_shelfmark, h.hand_label, h.school
2056        FROM annotations a
2057        JOIN annotator_hands h ON a.hand_id = h.id
2058        LEFT JOIN dissertation_refs dr ON a.id = dr.id
2059        WHERE h.is_alchemist = 1
2060        ORDER BY a.thesis_page
2061    """)
2062    alch_refs = cur.fetchall()
2063
2064    # Count matched images for alchemist refs
2065    cur.execute("""
2066        SELECT mat.confidence, COUNT(*)
2067        FROM matches mat
2068        JOIN annotations a ON mat.ref_id = a.id
2069        JOIN annotator_hands h ON a.hand_id = h.id
2070        WHERE h.is_alchemist = 1
2071        GROUP BY mat.confidence
2072    """)
2073    conf_dist = {row[0]: row[1] for row in cur.fetchall()}
2074
2075    # Get specific BL refs
2076    bl_refs = [r for r in alch_refs if r[5] == 'C.60.o.12']
2077    buf_refs = [r for r in alch_refs if r[5] == 'Buffalo RBR']
2078
2079    # Build BL refs table
2080    bl_rows = ''
2081    for r in bl_refs[:20]:
2082        marginal = escape(r[3] or '') if r[3] else '<em>none recorded</em>'
2083        bl_rows += f'<tr><td>{escape(r[0] or "")}</td><td>{r[1]}</td><td>{marginal}</td></tr>'
2084
2085    buf_rows = ''
2086    for r in buf_refs[:20]:
2087        marginal = escape(r[3] or '') if r[3] else '<em>none recorded</em>'
2088        buf_rows += f'<tr><td>{escape(r[0] or "")}</td><td>{r[1]}</td><td>{marginal}</td></tr>'
2089
2090    essay_css = '<style>' + """
2091        .essay-page { max-width: 850px; margin: 2rem auto; padding: 0 2rem; }
2092        .essay-page h2 { color: var(--accent); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
2093        .essay-page h3 { margin: 1.5rem 0 0.5rem; }
2094        .essay-page p { line-height: 1.8; margin-bottom: 1rem; }
2095        .essay-page .provisional { background: #fff3cd; padding: 0.5rem 1rem; border-left: 3px solid #ffc107; margin: 1rem 0; font-size: 0.9rem; }
2096        .essay-page .evidence-note { background: var(--bg-card); padding: 0.5rem 1rem; border-left: 3px solid var(--accent); margin: 1rem 0; font-size: 0.85rem; color: var(--text-muted); }
2097        .essay-page table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; }
2098        .essay-page th, .essay-page td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; }
2099        .essay-page th { background: var(--bg); font-weight: 600; }
2100        .essay-page blockquote { border-left: 3px solid var(--accent-light); padding-left: 1rem; color: var(--text-muted); font-style: italic; margin: 1rem 0; }
2101        .essay-page .section-anchor { color: var(--text-muted); text-decoration: none; font-size: 0.8em; margin-left: 0.3rem; }
2102        .essay-page .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
2103        .essay-page .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; }
2104        .essay-page .cross-links a:hover { border-color: var(--accent); color: var(--accent); }
2105    """ + '</style>'
2106
2107    body = f"""
2108    <div class="essay-page">
2109        <p><a href="index.html">&larr; Home</a></p>
2110
2111        <h1>Russell's Research on the Alchemical Hands</h1>
2112        <p class="evidence-note">This essay synthesizes evidence from James Russell's PhD thesis
2113        (Durham, 2014) and data extracted by this project's concordance pipeline. All claims about
2114        specific folios and images are grounded in retrieved dissertation references and matched
2115        image data. Where claims depend on provisional BL concordance data, this is marked explicitly.</p>
2116
2117        <h2 id="overview">Overview of the Dissertation Project
2118            <a class="section-anchor" href="#overview">#</a></h2>
2119        <p>Russell's thesis, submitted to Durham University in 2014, documented the marginalia in six
2120        copies of the 1499 <em>Hypnerotomachia Poliphili</em>, identifying {len(alchemist_hands) + 9}
2121        distinct annotator hands across these copies. His work demonstrated that the HP was actively
2122        read and annotated by humanists, playwrights, a pope, and alchemists over roughly two centuries
2123        (1499&ndash;1700). The thesis framed the annotated HP as a "humanistic activity book" in which
2124        readers cultivated <em>ingegno</em> through creative engagement with the text and its woodcuts.</p>
2125
2126        <h2 id="annotated-copies">Annotated Copies Relevant to the Alchemical Interpretation
2127            <a class="section-anchor" href="#annotated-copies">#</a></h2>
2128        <p>Two of the six copies studied by Russell contain alchemical annotations:</p>
2129        <ul>
2130            <li><strong>British Library, C.60.o.12</strong> (the "BL copy"): Contains two hands.
2131            Hand A is attributed to Ben Jonson. Hand B is an anonymous alchemist, possibly connected
2132            to the Royal Society circle, following the framework of Jean d'Espagnet.</li>
2133            <li><strong>Buffalo Rare Book Room</strong> (the "Buffalo copy"): Contains five interleaved
2134            hands (A&ndash;E). Hand E is an anonymous alchemist following the pseudo-Geber (Jabir ibn Hayyan)
2135            school, emphasizing sulphur and Sol/Luna pairings.</li>
2136        </ul>
2137
2138        <h2 id="bl-alchemist">The BL Alchemical Hand (Hand B)
2139            <a class="section-anchor" href="#bl-alchemist">#</a></h2>
2140        <p>The BL alchemist (Hand B in C.60.o.12) is the more extensively documented of the two
2141        alchemical readers. Russell devotes Chapter 6 of his thesis to this hand, situating it within
2142        the broader tradition of seventeenth-century alchemical theory. The animating purpose of
2143        that theory was the recovery of <a href="dictionary/prisca-sapientia.html"><em>prisca sapientia</em></a>&mdash;an original wisdom, beginning
2144        with Hermes Trismegistus, that had been progressively obscured over the centuries. A text
2145        as complicated and linguistically obscure as the HP would have drawn much attention from
2146        an alchemist steeped in this tradition: the more recondite the text, the more ancient
2147        wisdom it was presumed to contain (Russell 2014, pp. 154&ndash;156).</p>
2148
2149        <div style="margin:1.5rem 0; text-align:center">
2150            <img src="images/bl/C_60_o_12-003.jpg" alt="BL flyleaf verso: Master Mercury declaration"
2151                 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy">
2152            <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem">
2153                BL C.60.o.12, flyleaf verso (photo 003). Hand B's Master Mercury declaration.
2154                The shelfmark "C.60.o.12" and the date "1641" are visible below.</p>
2155        </div>
2156
2157        <h3>The <a href="dictionary/master-mercury.html" style="color:inherit">Master Mercury</a> Declaration</h3>
2158        <p>On the flyleaf verso, Hand B wrote a Latin summary declaring what the HP truly means:</p>
2159        <blockquote>"verus sensus intentionis huius libri est 3um: Geni et Totius Naturae energiae
2160        &amp; operationum Magisteri Mercurii Descriptio elegans, ampla"</blockquote>
2161        <p><em>The true sense of the intention of this book is threefold: the full and elegant
2162        description of the energy and spirit of the whole nature, and of the operations of
2163        Master Mercury.</em></p>
2164        <p>This declaration positions mercury (quicksilver) as the central principle. In the
2165        alchemical framework of Jean d'Espagnet (1564&ndash;1637), whose <em>Enchiridion Physicae
2166        Restitutae</em> was available in English from 1651, mercury is the vehicle of the
2167        <em>spiritus mundi</em>&mdash;the world spirit emanating from the sun. Mercury was understood
2168        to be the primal metal, always liquid at room temperature, while all other metals achieved
2169        liquidity only through heat. It was, as Russell puts it, "all things to all people" in the
2170        alchemical tradition (Russell 2014, pp. 159&ndash;161).</p>
2171        <p>The annotator also twice praises the HP as "ingeniosissimo," recognizing it as exemplifying
2172        the <a href="dictionary/ingegno.html"><em>ingegno</em></a>&mdash;the improvisational
2173        intelligence&mdash;that early modern readers cultivated through annotation.</p>
2174
2175        <div style="margin:1.5rem 0; text-align:center">
2176            <img src="images/bl/C_60_o_12-041.jpg" alt="BL b6v: Elephant and Obelisk with alchemical annotations"
2177                 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy">
2178            <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem">
2179                BL C.60.o.12, signature b6v (photo 041). The elephant and obelisk woodcut,
2180                densely annotated by Hand B with alchemical ideograms. This image later
2181                inspired Bernini's 1667 sculpture in Piazza della Minerva, Rome.</p>
2182        </div>
2183
2184        <h3>The Ideographic Vocabulary</h3>
2185        <p>Hand B used an extensive vocabulary of alchemical ideograms&mdash;compact symbols for
2186        gold, silver, mercury, Venus, Jupiter, and other elements&mdash;embedded within the syntax
2187        of Latin sentences. A particularly distinctive feature is the way B appended Latin case
2188        inflections to these symbols: "aurum" would be written as the gold symbol + "-um," and
2189        "Veneris" as the Venus symbol + "-eris." This creates a hybrid semiotic system where
2190        chemical signs function as Latin nouns within grammatical sentences
2191        (Russell 2014, pp. 149&ndash;152).</p>
2192        <p>Russell notes that B's ideographic vocabulary shows striking consistency with Isaac
2193        Newton's manuscripts in the Keynes collection at Cambridge. Although the BL hand is
2194        certainly not Newton's, this consistency "may suggest that B was attached to the Royal
2195        Society, from the environs of Cambridge, or was otherwise connected to the figure of
2196        Newton" (Russell 2014, pp. 158&ndash;159). A careful comparison of B's annotations with
2197        Newton's remains, Russell notes, a productive avenue for future research.</p>
2198
2199        <p class="evidence-note">Source: Russell 2014, Ch. 6, pp. 154&ndash;168. This project has
2200        {len(bl_refs)} dissertation references attributed to Hand B, and has visually confirmed
2201        the Master Mercury declaration and ideographic annotations on BL photographs 003 (flyleaf)
2202        and 041 (b6v, elephant and obelisk).</p>
2203
2204        <div style="display:flex; gap:1rem; flex-wrap:wrap; margin:1.5rem 0">
2205            <div style="flex:1; min-width:250px; text-align:center">
2206                <img src="images/bl/C_60_o_12-014.jpg" alt="BL a1r: opening page with annotations"
2207                     style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy">
2208                <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem">
2209                    a1r (photo 014). The HP's opening page: "POLIPHILO INCOMINCIA."
2210                    Marginal annotations from both Hand A (Jonson) and Hand B (alchemist).</p>
2211            </div>
2212            <div style="flex:1; min-width:250px; text-align:center">
2213                <img src="images/bl/C_60_o_12-004.jpg" alt="BL title page with Jonson ownership"
2214                     style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy">
2215                <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem">
2216                    Title page (photo 004). "RISTAMPATO DI NOVO... M.D.XXXXV" (1545).
2217                    Ben Jonson's ownership inscription "Sum Ben: Ionsonij" at bottom.</p>
2218            </div>
2219        </div>
2220
2221        <h3>BL Alchemist: Referenced Folios</h3>
2222        <table>
2223            <thead><tr><th>Signature</th><th>Thesis Page</th><th>Marginal Text</th></tr></thead>
2224            <tbody>{bl_rows}</tbody>
2225        </table>
2226        <div class="evidence-note">BL image matches are HIGH confidence: the BL offset
2227        (page = photo number &minus; 13) has been vision-verified at 174 of 174 readable pages
2228        with zero mismatches. The BL copy is the 1545 edition; the signature map is based on
2229        the 1499 collation, but the offset is confirmed stable across the entire volume.</div>
2230
2231        <h2 id="buffalo-alchemist">The Buffalo Alchemical Hand (Hand E)
2232            <a class="section-anchor" href="#buffalo-alchemist">#</a></h2>
2233        <p>Hand E in the Buffalo copy is the latest of five interleaved hands, overwriting Hand D.
2234        Like the BL alchemist, Hand E labels passages and woodcuts with the element or stage of
2235        the alchemical process they were presumed to allegorize. But Hand E follows a fundamentally
2236        different alchemical school: the pseudo-Geber tradition (from Jabir ibn Hayyan), which
2237        emphasizes sulphur and the <a href="dictionary/sol-luna.html">Sol/Luna</a> (Sun/Moon)
2238        duality rather than mercury (Russell 2014, pp. 170&ndash;186).</p>
2239        <p>In the Geberian framework, the masculine principle is represented by Sol (gold) and the
2240        feminine by Luna (silver). What makes Geber's thought distinctive is that these are understood
2241        as creative inverses: gold is masculine "in its height" and feminine "in its depth," while
2242        silver is feminine "in its height" and masculine "in its depth." It is the variance in these
2243        proportions that differentiates elements (Russell 2014, p. 187).</p>
2244
2245        <h3>The Chess Match as Alchemical Distillation</h3>
2246        <p>Hand E's most remarkable annotation is a sustained alchemical reading of the human chess
2247        match at Queen Eleuterylida's palace (g8r&ndash;h1r). Thirty-two maidens play, one side
2248        dressed in silver and the other in gold. But the queens of both sides wear gold, and the
2249        kings of both wear silver&mdash;the inverse of the Geberian ideal, signaling that transmutation
2250        has yet to occur.</p>
2251        <p>The annotator recorded the results of each of three rounds. Silver wins the first match;
2252        Hand E writes "Argentum" and draws a small crescent moon (Luna). Silver wins again in the
2253        second round. Gold finally triumphs in the third, and E initially writes "Rex ex auro factus
2254        victoriam ultimam"&mdash;then hesitates, corrects "Rex" to "[Re]gina," "auro" to "aura,"
2255        "factus" to the solar symbol + "uestita," and "victor" to "victrix." E then cancels "Regina"
2256        and places "Auru(m)" as the culmination. The king of gold wins only after multiple rounds of
2257        play, each round representing a distillation (Russell 2014, pp. 188&ndash;189).</p>
2258        <p>The outcome of this alchemical marriage is a hermaphrodite&mdash;the <em>coincidentia
2259        oppositorum</em>, the union of opposite qualities. Hand E found this principle encoded in
2260        the epigram D.AMBIG.D.D. ("dedicated to the ambiguous gods") on a statue base (b5r),
2261        which E reads as "diis ambiguis id est metallis hermafroditis"&mdash;"to the ambiguous gods,
2262        that is, to the metallic hermaphrodites" (Russell 2014, pp. 189&ndash;190).</p>
2263
2264        <div style="margin:1.5rem 0; text-align:center">
2265            <img src="images/bl/C_60_o_12-036.jpg" alt="BL b4r: Twin inscribed monuments with D.AMBIG.D.D."
2266                 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy">
2267            <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem">
2268                BL C.60.o.12, signature b4r (photo 036). The twin inscribed monuments bearing
2269                "D.AMBIG.D.D." &mdash; which Hand E in the Buffalo copy reads as "dedicated to
2270                the ambiguous gods, that is, to the metallic hermaphrodites." The BL copy
2271                shows this same passage heavily annotated by both hands.</p>
2272        </div>
2273
2274        <p class="evidence-note">Source: Russell 2014, Ch. 7, pp. 170&ndash;191. This project has
2275        {len(buf_refs)} dissertation references attributed to Hand E. The Buffalo copy has no
2276        photographs; images above show the same folios in the BL copy.</p>
2277
2278        <div style="margin:1.5rem 0; text-align:center">
2279            <img src="images/bl/C_60_o_12-033.jpg" alt="BL page 20: alchemical ideograms visible in left margin"
2280                 style="max-width:100%; border:1px solid var(--border); border-radius:4px" loading="lazy">
2281            <p style="font-size:0.8rem; color:var(--text-muted); margin-top:0.3rem">
2282                BL C.60.o.12, page 20 (photo 033). Alchemical ideograms visible in the left
2283                margin, showing Hand B's characteristic practice of embedding planetary
2284                symbols within Latin syntax. The pyramid passage is densely annotated.</p>
2285        </div>
2286
2287        <h3>Buffalo Alchemist: Referenced Folios</h3>
2288        <table>
2289            <thead><tr><th>Signature</th><th>Thesis Page</th><th>Marginal Text</th></tr></thead>
2290            <tbody>{buf_rows}</tbody>
2291        </table>
2292
2293        <h2 id="differences">Differences in Notation, Language, and Interpretive Logic
2294            <a class="section-anchor" href="#differences">#</a></h2>
2295        <table>
2296            <thead><tr><th>Feature</th><th>BL Hand B</th><th>Buffalo Hand E</th></tr></thead>
2297            <tbody>
2298                <tr><td>Alchemical School</td><td>d'Espagnet (mercury-centered)</td><td>pseudo-Geber (sulphur / Sol-Luna)</td></tr>
2299                <tr><td>Language</td><td>Latin with alchemical ideograms</td><td>Latin and Italian, fewer ideograms</td></tr>
2300                <tr><td>Central Principle</td><td>Master Mercury as catalytic agent</td><td>Sol/Luna duality and chemical wedding</td></tr>
2301                <tr><td>Key Passage</td><td>Flyleaf verso (Master Mercury declaration)</td><td>h1r (chess match as transmutation)</td></tr>
2302                <tr><td>Notation Style</td><td>Extensive ideographic vocabulary</td><td>Minimal ideograms; more discursive</td></tr>
2303                <tr><td>Approximate Date</td><td>Late 17th century (post-1623)</td><td>Unknown (latest of five hands)</td></tr>
2304            </tbody>
2305        </table>
2306
2307        <h2 id="images">What Images We Currently Have
2308            <a class="section-anchor" href="#images">#</a></h2>
2309        <p>This project has matched {sum(conf_dist.values())} images to alchemist-attributed
2310        dissertation references. The confidence distribution is:</p>
2311        <ul>
2312            <li>HIGH confidence: {conf_dist.get('HIGH', 0)} matches</li>
2313            <li>MEDIUM confidence: {conf_dist.get('MEDIUM', 0)} matches</li>
2314            <li>LOW confidence: {conf_dist.get('LOW', 0)} matches</li>
2315        </ul>
2316        <div class="evidence-note">All BL matches are HIGH confidence, verified by vision reading
2317        of 174 sequential BL photographs with a confirmed offset of 13 (zero mismatches).</div>
2318
2319        <h2 id="secure-vs-provisional">What Is Secure vs. Provisional
2320            <a class="section-anchor" href="#secure-vs-provisional">#</a></h2>
2321        <p><strong>Secure:</strong></p>
2322        <ul>
2323            <li>The existence and general character of two alchemical annotators (BL Hand B and Buffalo Hand E)</li>
2324            <li>The alchemical schools they followed (d'Espagnet vs. pseudo-Geber)</li>
2325            <li>The Master Mercury declaration on the BL flyleaf</li>
2326            <li>The folio signatures referenced by Russell in his thesis</li>
2327            <li>The signature map for the 1499 edition</li>
2328            <li>BL photograph-to-folio matches (HIGH confidence, vision-verified at 174/174 pages)</li>
2329        </ul>
2330        <p><strong>Provisional:</strong></p>
2331        <ul>
2332            <li>The attribution of Hand B to the Royal Society circle</li>
2333            <li>The precise dating of Hand E relative to the other Buffalo hands</li>
2334            <li>Any specific image claimed to show a particular alchemical annotation</li>
2335        </ul>
2336
2337        <h2 id="reception">Why the Alchemical Readers Matter for HP Reception History
2338            <a class="section-anchor" href="#reception">#</a></h2>
2339        <p>The alchemical annotations demonstrate that the HP's readership extended well beyond
2340        antiquarian humanists and poets. The two alchemist annotators independently decoded the
2341        HP as concealing alchemical formulae beneath its love narrative&mdash;yet they arrived
2342        at fundamentally different readings because they operated within different alchemical
2343        traditions. This diversity illustrates Russell's broader argument that the HP functioned
2344        as a "humanistic activity book": a text whose deliberate obscurity invited readers to
2345        impose their own interpretive frameworks.</p>
2346        <p>The alchemical tradition of reading the HP stretches back to Beroalde de Verville's
2347        1600 French edition, which included a "tableau steganographique" listing alchemical
2348        equivalents for the narrative's symbols. The BL and Buffalo annotations show that this
2349        tradition persisted into the seventeenth century and took distinct forms depending on
2350        the alchemical school of the reader.</p>
2351
2352        {_build_annotated_woodcuts_gallery(conn)}
2353
2354        <div class="cross-links">
2355            <h4>Related Dictionary Terms</h4>
2356            <a href="dictionary/alchemical-allegory.html">Alchemical Allegory</a>
2357            <a href="dictionary/master-mercury.html">Master Mercury</a>
2358            <a href="dictionary/sol-luna.html">Sol and Luna</a>
2359            <a href="dictionary/chemical-wedding.html">Chemical Wedding</a>
2360            <a href="dictionary/ideogram.html">Ideogram</a>
2361            <a href="dictionary/prisca-sapientia.html">Prisca Sapientia</a>
2362            <a href="dictionary/annotator-hand.html">Annotator Hand</a>
2363            <a href="dictionary/activity-book.html">Activity Book</a>
2364            <a href="dictionary/ingegno.html">Ingegno</a>
2365            <a href="dictionary/elephant-obelisk.html">Elephant and Obelisk</a>
2366            <a href="dictionary/beroalde-1600.html">Beroalde 1600 Edition</a>
2367            <a href="dictionary/poliphilo.html">Poliphilo</a>
2368            <a href="dictionary/polia.html">Polia</a>
2369            <a href="dictionary/venus-aphrodite.html">Venus</a>
2370            <a href="dictionary/cupid-eros.html">Cupid / Eros</a>
2371            <h4>Related Pages</h4>
2372            <a href="concordance-method.html">Concordance Methodology</a>
2373            <a href="scholar/james-russell.html">James Russell</a>
2374            <a href="dictionary/reception-history.html">Reception History</a>
2375        </div>
2376    </div>"""
2377
2378    page = page_shell("Russell's Alchemical Hands", body, active_nav='russell',
2379                       extra_css=essay_css)
2380    (SITE_DIR / 'russell-alchemical-hands.html').write_text(page, encoding='utf-8')
2381    print("  russell-alchemical-hands.html")
2382
2383
2384# ============================================================
2385# Essay: Concordance Methodology
2386# ============================================================
2387
2388def build_concordance_essay_page(conn):
2389    """Generate concordance-method.html explaining the matching pipeline."""
2390    cur = conn.cursor()
2391
2392    # Gather stats
2393    cur.execute("SELECT COUNT(*) FROM signature_map")
2394    sig_count = cur.fetchone()[0]
2395    cur.execute("SELECT COUNT(*) FROM annotations")
2396    ref_count = cur.fetchone()[0]
2397    cur.execute("SELECT COUNT(*) FROM images")
2398    img_count = cur.fetchone()[0]
2399    cur.execute("SELECT COUNT(*) FROM matches")
2400    match_count = cur.fetchone()[0]
2401
2402    cur.execute("SELECT confidence, COUNT(*) FROM matches GROUP BY confidence")
2403    conf_dist = {row[0]: row[1] for row in cur.fetchall()}
2404
2405    cur.execute("SELECT match_method, COUNT(*) FROM matches GROUP BY match_method")
2406    method_dist = {row[0]: row[1] for row in cur.fetchall()}
2407
2408    cur.execute("""
2409        SELECT m.shelfmark, mat.confidence, COUNT(*)
2410        FROM matches mat
2411        JOIN images i ON mat.image_id = i.id
2412        JOIN manuscripts m ON i.manuscript_id = m.id
2413        GROUP BY m.shelfmark, mat.confidence
2414    """)
2415    ms_conf = {}
2416    for row in cur.fetchall():
2417        ms_conf.setdefault(row[0], {})[row[1]] = row[2]
2418
2419    siena_data = ms_conf.get('O.III.38', {})
2420    bl_data = ms_conf.get('C.60.o.12', {})
2421
2422    cur.execute("""
2423        SELECT m.shelfmark, COUNT(DISTINCT i.id) FROM images i
2424        JOIN manuscripts m ON i.manuscript_id = m.id
2425        GROUP BY m.shelfmark
2426    """)
2427    img_by_ms = {row[0]: row[1] for row in cur.fetchall()}
2428
2429    cur.execute("""
2430        SELECT manuscript_shelfmark, COUNT(*) FROM dissertation_refs
2431        WHERE manuscript_shelfmark IS NOT NULL
2432        GROUP BY manuscript_shelfmark ORDER BY COUNT(*) DESC
2433    """)
2434    ref_by_ms = {row[0]: row[1] for row in cur.fetchall()}
2435
2436    essay_css = '<style>' + """
2437        .essay-page { max-width: 850px; margin: 2rem auto; padding: 0 2rem; }
2438        .essay-page h2 { color: var(--accent); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
2439        .essay-page h3 { margin: 1.5rem 0 0.5rem; }
2440        .essay-page p { line-height: 1.8; margin-bottom: 1rem; }
2441        .essay-page .provisional { background: #fff3cd; padding: 0.5rem 1rem; border-left: 3px solid #ffc107; margin: 1rem 0; font-size: 0.9rem; }
2442        .essay-page .evidence-note { background: var(--bg-card); padding: 0.5rem 1rem; border-left: 3px solid var(--accent); margin: 1rem 0; font-size: 0.85rem; color: var(--text-muted); }
2443        .essay-page table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; }
2444        .essay-page th, .essay-page td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; }
2445        .essay-page th { background: var(--bg); font-weight: 600; }
2446        .essay-page .process-flow {{ display: flex; flex-wrap: wrap; gap: 1rem; margin: 1.5rem 0; }}
2447        .essay-page .process-step {{ flex: 1; min-width: 200px; padding: 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; }}
2448        .essay-page .process-step .step-num {{ display: inline-block; width: 1.5rem; height: 1.5rem; background: var(--accent); color: white; border-radius: 50%; text-align: center; line-height: 1.5rem; font-size: 0.8rem; margin-right: 0.3rem; }}
2449        .essay-page .process-step h4 {{ margin: 0 0 0.5rem 0; font-size: 0.9rem; }}
2450        .essay-page .process-step p {{ font-size: 0.85rem; margin: 0; line-height: 1.5; }}
2451        .essay-page .section-anchor { color: var(--text-muted); text-decoration: none; font-size: 0.8em; margin-left: 0.3rem; }
2452        .essay-page .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
2453        .essay-page .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; }
2454        .essay-page .cross-links a:hover { border-color: var(--accent); color: var(--accent); }
2455    """ + '</style>'
2456
2457    body = f"""
2458    <div class="essay-page">
2459        <p><a href="index.html">&larr; Home</a></p>
2460
2461        <h1>Concordance Methodology</h1>
2462        <p class="evidence-note">This page documents the actual methods used to build the concordance
2463        between Russell's dissertation references and the manuscript photograph collections. All
2464        statistics are drawn from the project database. Uncertainty and provisional status are
2465        marked explicitly throughout.</p>
2466
2467        <h2 id="problem">The Problem Russell Presents
2468            <a class="section-anchor" href="#problem">#</a></h2>
2469        <p>Russell's thesis references folios by signature (e.g., "b6v", "h1r") rather than by
2470        page number, since the 1499 edition has no printed pagination. Our manuscript photograph
2471        collections, however, use different naming conventions: the Siena images use folio numbers
2472        with recto/verso suffixes (e.g., O.III.38_0014r.jpg), while the BL images use sequential
2473        numbers (C_60_o_12-001.jpg through C_60_o_12-196.jpg). The core challenge is mapping
2474        Russell's {ref_count} signature-based references to the {img_count} available photographs.</p>
2475
2476        <h2 id="image-sets">BL vs. Siena Image Sets
2477            <a class="section-anchor" href="#image-sets">#</a></h2>
2478        <table>
2479            <thead><tr><th>Property</th><th>Siena O.III.38</th><th>BL C.60.o.12</th></tr></thead>
2480            <tbody>
2481                <tr><td>Images</td><td>{img_by_ms.get('O.III.38', 0)}</td><td>{img_by_ms.get('C.60.o.12', 0)}</td></tr>
2482                <tr><td>Naming</td><td>Folio number + r/v suffix</td><td>Sequential number only</td></tr>
2483                <tr><td>Edition</td><td>1499 Aldine</td><td>1545 Aldine (second edition)</td></tr>
2484                <tr><td>Folio mapping</td><td>Directly encoded in filename</td><td>Requires inference</td></tr>
2485                <tr><td>Matching confidence</td><td>HIGH / MEDIUM</td><td>LOW (all provisional)</td></tr>
2486            </tbody>
2487        </table>
2488        <div class="provisional">The BL copy is the 1545 edition, not the 1499. The signature map
2489        is based on the 1499 collation formula. While the 1545 edition largely follows the same
2490        structure, any pagination differences between editions would make sequential-number-to-folio
2491        inference unreliable. All BL matches must be treated as provisional.</div>
2492
2493        <h2 id="signature-map">How the Signature Map Works
2494            <a class="section-anchor" href="#signature-map">#</a></h2>
2495        <p>The signature map is a deterministic lookup table of {sig_count} entries generated from
2496        the 1499 collation formula: a&ndash;z<sup>8</sup> (omitting j, u, w), A&ndash;F<sup>8</sup>,
2497        G<sup>4</sup>. Each quire has 8 leaves (except G with 4), and each leaf has recto and verso
2498        sides. The map converts any valid signature (e.g., "b6v") to a sequential folio number
2499        and vice versa.</p>
2500        <p>This map is generated by <code>build_signature_map.py</code> and is fully deterministic.
2501        It is correct for the 1499 edition. Its applicability to the 1545 edition is assumed but
2502        not verified.</p>
2503
2504        <h2 id="ref-extraction">How Dissertation References Were Extracted
2505            <a class="section-anchor" href="#ref-extraction">#</a></h2>
2506        <p>Russell's thesis PDF was processed by <code>extract_references.py</code> using PyMuPDF
2507        for text extraction and regular expressions for signature pattern matching. The script
2508        extracted {ref_count} references, distributed across manuscripts:</p>
2509        <table>
2510            <thead><tr><th>Manuscript</th><th>References</th></tr></thead>
2511            <tbody>{''.join(f"<tr><td>{escape(k)}</td><td>{v}</td></tr>" for k, v in sorted(ref_by_ms.items(), key=lambda x: -x[1]))}</tbody>
2512        </table>
2513
2514        <h2 id="image-parsing">How Image Filenames Were Parsed
2515            <a class="section-anchor" href="#image-parsing">#</a></h2>
2516        <p><code>catalog_images.py</code> parses image filenames from two manuscript collections into
2517        structured records with folio number, side (recto/verso), and page type. The Siena images
2518        encode folio information directly (O.III.38_0014r.jpg = folio 14 recto). The BL images
2519        use sequential numbers that do not directly encode folio information.</p>
2520
2521        <h2 id="matching">How Matching Was Performed
2522            <a class="section-anchor" href="#matching">#</a></h2>
2523        <div class="process-flow">
2524            <div class="process-step">
2525                <h4><span class="step-num">1</span> Signature Lookup</h4>
2526                <p>Convert each dissertation reference's signature (e.g., "b6v") to a folio number
2527                using the signature map.</p>
2528            </div>
2529            <div class="process-step">
2530                <h4><span class="step-num">2</span> Manuscript Filter</h4>
2531                <p>Filter images to the manuscript shelfmark specified in the dissertation reference.</p>
2532            </div>
2533            <div class="process-step">
2534                <h4><span class="step-num">3</span> Folio Match</h4>
2535                <p>Match the computed folio number to images with the same folio number and side.</p>
2536            </div>
2537            <div class="process-step">
2538                <h4><span class="step-num">4</span> Fallback Cross-Match</h4>
2539                <p>If no direct match, attempt cross-manuscript matching (lower confidence).</p>
2540            </div>
2541            <div class="process-step">
2542                <h4><span class="step-num">5</span> Confidence Assignment</h4>
2543                <p>Assign HIGH (exact Siena match), MEDIUM (folio-based), or LOW (BL sequential inference).</p>
2544            </div>
2545        </div>
2546
2547        <h2 id="confidence">Where Confidence Is High vs. Low
2548            <a class="section-anchor" href="#confidence">#</a></h2>
2549        <table>
2550            <thead><tr><th>Confidence</th><th>Matches</th><th>Description</th></tr></thead>
2551            <tbody>
2552                <tr><td>HIGH</td><td>{conf_dist.get('HIGH', 0)}</td><td>Siena images with explicit folio+side in filename, matched by signature lookup</td></tr>
2553                <tr><td>MEDIUM</td><td>{conf_dist.get('MEDIUM', 0)}</td><td>Siena images matched by folio number (cross-manuscript or side ambiguous)</td></tr>
2554                <tr><td>LOW</td><td>{conf_dist.get('LOW', 0)}</td><td>BL images where sequential photo number is assumed to equal folio number</td></tr>
2555            </tbody>
2556        </table>
2557
2558        <h3>By Manuscript</h3>
2559        <table>
2560            <thead><tr><th>Manuscript</th><th>HIGH</th><th>MEDIUM</th><th>LOW</th></tr></thead>
2561            <tbody>
2562                <tr><td>Siena O.III.38</td><td>{siena_data.get('HIGH', 0)}</td><td>{siena_data.get('MEDIUM', 0)}</td><td>{siena_data.get('LOW', 0)}</td></tr>
2563                <tr><td>BL C.60.o.12</td><td>{bl_data.get('HIGH', 0)}</td><td>{bl_data.get('MEDIUM', 0)}</td><td>{bl_data.get('LOW', 0)}</td></tr>
2564            </tbody>
2565        </table>
2566
2567        <h2 id="bl-provisional">Why BL Matches Are Provisional
2568            <a class="section-anchor" href="#bl-provisional">#</a></h2>
2569        <div class="provisional"><strong>Critical caveat:</strong> All {sum(bl_data.values())} BL matches
2570        are classified as LOW confidence. The BL photographs are sequentially numbered (001&ndash;196)
2571        without folio labels. Our matching pipeline assumes that photo N corresponds to folio N, but
2572        this assumption is unverified. The BL copy is the 1545 edition, not the 1499 edition from which
2573        the signature map was built. Any differences in pagination, inserted leaves, or missing leaves
2574        between editions would invalidate this mapping. These matches require manual verification
2575        against the physical photographs.</div>
2576
2577        <h2 id="human-review">What Human Review Is Still Needed
2578            <a class="section-anchor" href="#human-review">#</a></h2>
2579        <ul>
2580            <li>Verify BL photograph-to-folio correspondence against physical or high-resolution images</li>
2581            <li>Confirm that the 1545 edition follows the same collation as the 1499</li>
2582            <li>Spot-check MEDIUM confidence Siena matches for side (recto/verso) accuracy</li>
2583            <li>Review hand attribution for edge cases where multiple hands annotate the same folio</li>
2584            <li>Validate marginal text transcriptions against original manuscript images</li>
2585        </ul>
2586
2587        <h2 id="future">How This Methodology Supports Future Scholarship
2588            <a class="section-anchor" href="#future">#</a></h2>
2589        <p>The concordance pipeline produces a structured, queryable dataset linking Russell's
2590        close reading to digital images. Once the BL matches are verified, this enables:</p>
2591        <ul>
2592            <li>Folio-level browsing of annotations alongside manuscript images</li>
2593            <li>Systematic comparison of annotation density across copies</li>
2594            <li>Identification of folios that attracted multiple annotators</li>
2595            <li>Cross-referencing between annotations and the dictionary of terms</li>
2596            <li>Future multimodal analysis using computer vision on manuscript images</li>
2597        </ul>
2598
2599        <div class="cross-links">
2600            <h4>Related Dictionary Terms</h4>
2601            <a href="dictionary/signature.html">Signature</a>
2602            <a href="dictionary/folio.html">Folio</a>
2603            <a href="dictionary/recto.html">Recto</a>
2604            <a href="dictionary/verso.html">Verso</a>
2605            <a href="dictionary/quire.html">Quire</a>
2606            <a href="dictionary/collation.html">Collation</a>
2607            <a href="dictionary/incunabulum.html">Incunabulum</a>
2608            <a href="dictionary/annotator-hand.html">Annotator Hand</a>
2609            <a href="dictionary/marginalia.html">Marginalia</a>
2610            <a href="dictionary/1499-edition.html">1499 Edition</a>
2611            <a href="dictionary/1545-edition.html">1545 Edition</a>
2612            <h4>Related Pages</h4>
2613            <a href="russell-alchemical-hands.html">Alchemical Hands Essay</a>
2614            <a href="scholar/james-russell.html">James Russell</a>
2615            <a href="docs/concordance-methodology.html">Concordance Methodology (Doc)</a>
2616            <a href="code/match-refs-to-images.html">match_refs_to_images.py</a>
2617            <a href="code/build-signature-map.html">build_signature_map.py</a>
2618        </div>
2619    </div>"""
2620
2621    page = page_shell('Concordance Methodology', body, active_nav='concordance',
2622                       extra_css=essay_css)
2623    (SITE_DIR / 'concordance-method.html').write_text(page, encoding='utf-8')
2624    print("  concordance-method.html")
2625
2626
2627# ============================================================
2628# Timeline page
2629# ============================================================
2630
2631def build_timeline_page(conn):
2632    """Generate timeline.html with chronological HP reception history."""
2633    cur = conn.cursor()
2634    cur.execute("""
2635        SELECT year, year_end, event_type, title, description, category,
2636               medium, location, confidence, manuscript_shelfmark
2637        FROM timeline_events ORDER BY year, id
2638    """)
2639    events = cur.fetchall()
2640
2641    # Group by year
2642    by_year = {}
2643    for e in events:
2644        by_year.setdefault(e[0], []).append(e)
2645
2646    # Category colors
2647    cat_colors = {
2648        'art': '#8b5cf6', 'scholarship': '#3b82f6', 'edition': '#10b981',
2649        'literary': '#f59e0b', '': '#6b7280',
2650    }
2651    cat_labels = {
2652        'art': 'Art & Design', 'scholarship': 'Scholarship', 'edition': 'Edition',
2653        'literary': 'Literary Influence', '': 'Other',
2654    }
2655
2656    timeline_css = '<style>' + """
2657        .timeline-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
2658        .timeline-page h2 { color: var(--accent); }
2659        .timeline-page p { line-height: 1.8; }
2660        .timeline-filters { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 1.5rem 0; }
2661        .timeline-filters label { display: flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; font-family: var(--font-sans); cursor: pointer; padding: 0.3rem 0.6rem; border: 1px solid var(--border); border-radius: 3px; }
2662        .timeline-filters input { margin: 0; }
2663        .timeline { position: relative; padding-left: 2rem; margin-top: 2rem; }
2664        .timeline::before { content: ''; position: absolute; left: 0.5rem; top: 0; bottom: 0; width: 2px; background: var(--border); }
2665        .timeline-year { margin-bottom: 2rem; position: relative; }
2666        .year-marker { position: sticky; top: 4rem; font-size: 1.3rem; font-weight: 700; color: var(--accent); font-family: var(--font-sans); margin-bottom: 0.5rem; padding-left: 0; margin-left: -2rem; background: var(--bg-main, #fff); z-index: 1; }
2667        .timeline-card { padding: 0.75rem 1rem; margin-bottom: 0.75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; position: relative; }
2668        .timeline-card::before { content: ''; position: absolute; left: -1.55rem; top: 1rem; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
2669        .card-type-badge { display: inline-block; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; padding: 0.1rem 0.4rem; border-radius: 2px; color: white; margin-bottom: 0.3rem; font-family: var(--font-sans); }
2670        .timeline-card h4 { margin: 0 0 0.3rem 0; font-size: 0.95rem; }
2671        .timeline-card p { font-size: 0.85rem; color: var(--text-muted); margin: 0; line-height: 1.6; }
2672        .timeline-card .card-meta { font-size: 0.75rem; color: var(--text-muted); font-family: var(--font-sans); margin-top: 0.3rem; }
2673    """ + '</style>'
2674
2675    # Build timeline HTML
2676    timeline_html = ''
2677    for year in sorted(by_year.keys()):
2678        year_events = by_year[year]
2679        cards = ''
2680        for e in year_events:
2681            (yr, yr_end, etype, title, desc, cat, medium, loc, conf, ms) = e
2682            cat = cat or ''
2683            color = cat_colors.get(cat, '#6b7280')
2684            label = cat_labels.get(cat, etype or 'Other')
2685            badge = f'<span class="card-type-badge" style="background:{color}">{escape(label)}</span>'
2686            meta_parts = []
2687            if medium:
2688                meta_parts.append(escape(medium))
2689            if loc:
2690                meta_parts.append(escape(loc))
2691            if conf and conf != 'HIGH':
2692                meta_parts.append(f'Confidence: {escape(conf)}')
2693            meta = f'<div class="card-meta">{" | ".join(meta_parts)}</div>' if meta_parts else ''
2694
2695            cards += f"""
2696                <div class="timeline-card" data-category="{escape(cat)}">
2697                    {badge}
2698                    <h4>{escape(title)}</h4>
2699                    <p>{escape(desc or '')}</p>
2700                    {meta}
2701                </div>"""
2702
2703        year_label = f"{year}" if not year_events[0][1] else f"{year}-{year_events[0][1]}"
2704        timeline_html += f"""
2705            <div class="timeline-year">
2706                <div class="year-marker">{year_label}</div>
2707                {cards}
2708            </div>"""
2709
2710    # Filters
2711    categories = sorted(set(e[5] or '' for e in events))
2712    filter_html = ''
2713    for cat in categories:
2714        label = cat_labels.get(cat, cat or 'Other')
2715        color = cat_colors.get(cat, '#6b7280')
2716        filter_html += f'<label><input type="checkbox" checked data-filter="{escape(cat)}"> <span style="color:{color}">{escape(label)}</span></label>'
2717
2718    filter_js = """
2719    <script>
2720    document.querySelectorAll('.timeline-filters input').forEach(cb => {
2721        cb.addEventListener('change', () => {
2722            const cat = cb.dataset.filter;
2723            const show = cb.checked;
2724            document.querySelectorAll(`.timeline-card[data-category="${cat}"]`).forEach(card => {
2725                card.style.display = show ? '' : 'none';
2726            });
2727            // Hide empty year markers
2728            document.querySelectorAll('.timeline-year').forEach(yr => {
2729                const visible = yr.querySelectorAll('.timeline-card:not([style*="display: none"])');
2730                yr.style.display = visible.length ? '' : 'none';
2731            });
2732        });
2733    });
2734    </script>"""
2735
2736    body = f"""
2737    <div class="timeline-page">
2738        <h2>Timeline of the <em>Hypnerotomachia Poliphili</em></h2>
2739        <p>A chronological view of the HP's five-century reception: editions, translations,
2740        annotations, scholarship, and art inspired by the book. {len(events)} events spanning
2741        {min(by_year.keys())}&ndash;{max(by_year.keys())}.</p>
2742        <p style="font-size:0.85rem; color:var(--text-muted)">Filter by category:</p>
2743        <div class="timeline-filters">{filter_html}</div>
2744        <div class="timeline">{timeline_html}</div>
2745    </div>"""
2746
2747    page = page_shell('Timeline', body, active_nav='timeline',
2748                       extra_css=timeline_css, extra_js=filter_js)
2749    (SITE_DIR / 'timeline.html').write_text(page, encoding='utf-8')
2750    print(f"  timeline.html ({len(events)} events)")
2751
2752
2753# ============================================================
2754# Manuscripts pages
2755# ============================================================
2756
2757def build_manuscripts_pages(conn):
2758    """Generate manuscripts/index.html and manuscripts/*.html from hp_copies."""
2759    cur = conn.cursor()
2760    manu_dir = SITE_DIR / 'manuscripts'
2761    manu_dir.mkdir(exist_ok=True)
2762
2763    # Get all copies
2764    cur.execute("""
2765        SELECT id, shelfmark, institution, city, country, edition,
2766               has_annotations, studied_by, annotation_summary,
2767               hand_count, copy_notes, has_images_in_project,
2768               confidence, review_status
2769        FROM hp_copies ORDER BY edition, shelfmark
2770    """)
2771    copies = cur.fetchall()
2772
2773    # Get hands per copy
2774    cur.execute("""
2775        SELECT manuscript_shelfmark, hand_label, attribution, is_alchemist, school, description
2776        FROM annotator_hands ORDER BY manuscript_shelfmark, hand_label
2777    """)
2778    hands_by_ms = {}
2779    for row in cur.fetchall():
2780        hands_by_ms.setdefault(row[0], []).append(row)
2781
2782    # Get ref counts per copy (uses dissertation_refs because 51 annotations
2783    # lack manuscript_id linkage — TODO: backfill manuscript_id in annotations)
2784    cur.execute("""
2785        SELECT manuscript_shelfmark, COUNT(*) FROM dissertation_refs
2786        WHERE manuscript_shelfmark IS NOT NULL
2787        GROUP BY manuscript_shelfmark
2788    """)
2789    refs_by_ms = {row[0]: row[1] for row in cur.fetchall()}
2790
2791    # Get match counts per copy
2792    cur.execute("""
2793        SELECT m.shelfmark, mat.confidence, COUNT(*)
2794        FROM matches mat
2795        JOIN images i ON mat.image_id = i.id
2796        JOIN manuscripts m ON i.manuscript_id = m.id
2797        GROUP BY m.shelfmark, mat.confidence
2798    """)
2799    matches_by_ms = {}
2800    for row in cur.fetchall():
2801        matches_by_ms.setdefault(row[0], {})[row[1]] = row[2]
2802
2803    manu_css = '<style>' + """
2804        .manuscripts-page { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
2805        .manuscripts-page h2 { color: var(--accent); border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
2806        .manuscripts-page h3 { margin-top: 2rem; }
2807        .manuscripts-page p { line-height: 1.8; }
2808        .copy-card { padding: 1.5rem; margin-bottom: 1.5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; }
2809        .copy-card h3 { margin-top: 0; color: var(--accent); }
2810        .copy-card .copy-meta { font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-sans); margin-bottom: 0.5rem; }
2811        .copy-card .copy-notes { font-size: 0.9rem; line-height: 1.7; }
2812        .copy-detail { max-width: 850px; margin: 2rem auto; padding: 0 2rem; }
2813        .copy-detail h2 { color: var(--accent); }
2814        .copy-detail h3 { margin-top: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; }
2815        .copy-detail p { line-height: 1.8; }
2816        .copy-detail table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.85rem; }
2817        .copy-detail th, .copy-detail td { padding: 0.5rem 0.75rem; border: 1px solid var(--border); text-align: left; }
2818        .copy-detail th { background: var(--bg); font-weight: 600; }
2819        .copy-detail .provisional { background: #fff3cd; padding: 0.5rem 1rem; border-left: 3px solid #ffc107; margin: 1rem 0; font-size: 0.9rem; }
2820        .copy-detail .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
2821        .copy-detail .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; }
2822        .copy-detail .cross-links a:hover { border-color: var(--accent); color: var(--accent); }
2823    """ + '</style>'
2824
2825    # Build detail pages
2826    annotated_cards = ''
2827    for copy in copies:
2828        (cid, shelfmark, inst, city, country, edition, has_annot,
2829         studied_by, annot_summary, hand_count, notes,
2830         has_images, confidence, status) = copy
2831
2832        slug = slugify(shelfmark)
2833        hands = hands_by_ms.get(shelfmark, [])
2834        ref_count = refs_by_ms.get(shelfmark, 0)
2835        match_data = matches_by_ms.get(shelfmark, {})
2836
2837        # Confidence badge
2838        conf_badge = confidence_badge_html(confidence) if confidence else ''
2839
2840        # Card for index
2841        meta_parts = [f'{escape(inst)}, {escape(city or "")}']
2842        if edition:
2843            meta_parts.append(f'{edition} edition')
2844        if hand_count:
2845            meta_parts.append(f'{hand_count} annotator hand{"s" if hand_count != 1 else ""}')
2846        if ref_count:
2847            meta_parts.append(f'{ref_count} dissertation refs')
2848
2849        annotated_cards += f"""
2850        <div class="copy-card">
2851            <h3><a href="{slug}.html">{escape(shelfmark)}</a> {conf_badge}</h3>
2852            <div class="copy-meta">{" | ".join(meta_parts)}</div>
2853            <div class="copy-notes">{escape(notes or '')}</div>
2854        </div>"""
2855
2856        # Detail page
2857        # Hands table
2858        hands_html = ''
2859        if hands:
2860            rows = ''
2861            for h in hands:
2862                alch = ' (alchemist)' if h[3] else ''
2863                school = f' [{escape(h[4])}]' if h[4] else ''
2864                rows += f'<tr><td>{escape(h[1])}</td><td>{escape(h[2] or "Anonymous")}{alch}{school}</td><td>{escape((h[5] or "")[:200])}</td></tr>'
2865            hands_html = f"""
2866            <h3>Annotator Hands</h3>
2867            <table>
2868                <thead><tr><th>Hand</th><th>Attribution</th><th>Description</th></tr></thead>
2869                <tbody>{rows}</tbody>
2870            </table>"""
2871
2872        # Match stats
2873        match_html = ''
2874        if match_data:
2875            total = sum(match_data.values())
2876            match_html = f"""
2877            <h3>Image Matches</h3>
2878            <p>{total} images matched to dissertation references.</p>
2879            <ul>
2880                <li>HIGH confidence: {match_data.get('HIGH', 0)}</li>
2881                <li>MEDIUM confidence: {match_data.get('MEDIUM', 0)}</li>
2882                <li>LOW confidence: {match_data.get('LOW', 0)}</li>
2883            </ul>"""
2884
2885        # Provisional warning for BL
2886        prov_html = ''
2887        if confidence == 'LOW':
2888            prov_html = '<div class="provisional">All image matches for this copy are LOW confidence. The photograph numbering does not directly encode folio information. Manual verification is required.</div>'
2889
2890        # Cross links
2891        cross_links = ['<a href="../dictionary/annotator-hand.html">Annotator Hand</a>',
2892                       '<a href="../dictionary/marginalia.html">Marginalia</a>',
2893                       '<a href="../concordance-method.html">Concordance Method</a>']
2894        if any(h[3] for h in hands):
2895            cross_links.append('<a href="../russell-alchemical-hands.html">Alchemical Hands Essay</a>')
2896            cross_links.append('<a href="../dictionary/alchemical-allegory.html">Alchemical Allegory</a>')
2897
2898        detail_body = f"""
2899        <div class="copy-detail">
2900            <p><a href="index.html">&larr; All Manuscripts</a></p>
2901            <h2>{escape(shelfmark)} {conf_badge}</h2>
2902            <p><strong>{escape(inst)}</strong>, {escape(city or '')} ({escape(country or '')})</p>
2903            <p>Edition: {escape(edition or 'unknown')} | Studied by: {escape(studied_by or 'not studied')} |
2904               Dissertation references: {ref_count} | Images in project: {'Yes' if has_images else 'No'}</p>
2905            <h3>Description</h3>
2906            <p>{escape(notes or 'No description available.')}</p>
2907            {hands_html}
2908            {match_html}
2909            {prov_html}
2910            <div class="cross-links">
2911                <h4>Related Pages</h4>
2912                {''.join(cross_links)}
2913            </div>
2914        </div>"""
2915
2916        detail_page = page_shell(shelfmark, detail_body, active_nav='manuscripts',
2917                                  extra_css=manu_css, depth=1)
2918        (manu_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8')
2919
2920    # Index page
2921    index_body = f"""
2922    <div class="manuscripts-page">
2923        <h2>Manuscripts of the <em>Hypnerotomachia Poliphili</em></h2>
2924        <p>The 1499 <em>Hypnerotomachia Poliphili</em> survives in approximately 200 copies
2925        worldwide. Russell's PhD thesis (2014) studied six annotated copies in detail,
2926        identifying eleven distinct annotator hands. This section presents those copies
2927        with their annotation profiles, hand attributions, and links to the project's
2928        marginalia and concordance data.</p>
2929        <p>Each copy page shows the annotator hands identified by Russell, the number of
2930        dissertation references attributed to that copy, and any matched images. Where
2931        matching confidence is low, that status is marked explicitly.</p>
2932
2933        <h3>Annotated Copies Studied by Russell (2014)</h3>
2934        {annotated_cards}
2935    </div>"""
2936
2937    index_page = page_shell('Manuscripts', index_body, active_nav='manuscripts',
2938                             extra_css=manu_css, depth=1)
2939    (manu_dir / 'index.html').write_text(index_page, encoding='utf-8')
2940    print(f"  manuscripts/index.html + {len(copies)} copy pages")
2941
2942
2943# ============================================================
2944# Woodcuts pages
2945# ============================================================
2946
2947def build_woodcuts_pages(conn):
2948    """Generate woodcuts/index.html and woodcuts/*.html — 1499 edition gallery.
2949
2950    Sources woodcut images from Internet Archive facsimile (A336080v1,
2951    University of Seville copy, 600ppi). Falls back to BL photographs
2952    where IA images are not yet cached.
2953    """
2954    cur = conn.cursor()
2955    wc_dir = SITE_DIR / 'woodcuts'
2956    wc_dir.mkdir(exist_ok=True)
2957    ia_img_dir = SITE_DIR / 'images' / 'woodcuts_1499'
2958
2959    # Clean up stale pages from previous builds
2960    for old_file in wc_dir.glob('*.html'):
2961        if old_file.stem != 'index':
2962            old_file.unlink()
2963
2964    # Which signatures have marginalia pages?
2965    marg_dir = SITE_DIR / 'marginalia'
2966    _wc_marg_sigs = set()
2967    if marg_dir.exists():
2968        for f in marg_dir.glob('*.html'):
2969            if f.stem != 'index':
2970                _wc_marg_sigs.add(f.stem)
2971
2972    # Which dictionary terms exist?
2973    dict_dir = SITE_DIR / 'dictionary'
2974    _wc_dict_slugs = set()
2975    if dict_dir.exists():
2976        for f in dict_dir.glob('*.html'):
2977            if f.stem != 'index':
2978                _wc_dict_slugs.add(f.stem)
2979
2980    cur.execute("""
2981        SELECT id, slug, title, signature_1499, page_1499, page_1499_ia,
2982               bl_photo_number, subject_category, woodcut_type,
2983               description, narrative_context, chapter_context,
2984               depicted_elements, has_annotation, alchemical_annotation,
2985               annotation_density, dictionary_terms, scholarly_discussion,
2986               influence, source_method, confidence, notes,
2987               ia_image_cached
2988        FROM woodcuts
2989        WHERE page_1499 IS NOT NULL
2990          AND source_method != 'CORPUS_EXTRACTION'
2991        ORDER BY page_1499
2992    """)
2993    woodcuts = cur.fetchall()
2994
2995    if not woodcuts:
2996        print("  woodcuts: no data")
2997        return
2998
2999    cat_colors = {
3000        'ARCHITECTURAL': '#8b5cf6', 'LANDSCAPE': '#10b981', 'NARRATIVE': '#3b82f6',
3001        'HIEROGLYPHIC': '#f59e0b', 'PROCESSION': '#ef4444', 'DECORATIVE': '#6366f1',
3002        'PORTRAIT': '#ec4899', 'DIAGRAM': '#14b8a6',
3003    }
3004
3005    wc_css = '<style>' + """
3006        .woodcuts-page { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; }
3007        .woodcuts-page h1 { color: var(--accent); font-size: 1.8rem; margin-bottom: 0.3rem; }
3008        .woodcuts-page .intro { max-width: 800px; line-height: 1.8; margin-bottom: 1.5rem; color: var(--text-muted); }
3009        .wc-filters { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1.5rem; }
3010        .wc-filter-btn { padding: 0.3rem 0.7rem; border: 1px solid var(--border); border-radius: 3px;
3011                         font-size: 0.75rem; font-weight: 600; text-transform: uppercase; cursor: pointer;
3012                         background: var(--bg); color: var(--text-muted); transition: all 0.15s; }
3013        .wc-filter-btn:hover, .wc-filter-btn.active { background: var(--accent); color: white; border-color: var(--accent); }
3014        .woodcut-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1.5rem; }
3015        .woodcut-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
3016                        overflow: hidden; transition: box-shadow 0.2s, transform 0.2s; }
3017        .woodcut-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); transform: translateY(-2px); }
3018        .woodcut-card img { width: 100%; height: 320px; object-fit: cover; object-position: top;
3019                            border-bottom: 1px solid var(--border); background: #f5f0e8; }
3020        .woodcut-card .wc-info { padding: 0.75rem 1rem; }
3021        .woodcut-card h4 { margin: 0 0 0.3rem; font-size: 0.95rem; }
3022        .woodcut-card h4 a { color: var(--text); text-decoration: none; }
3023        .woodcut-card h4 a:hover { color: var(--accent); }
3024        .woodcut-card .wc-meta { font-size: 0.75rem; color: var(--text-muted); font-family: var(--font-sans); }
3025        .woodcut-card .wc-desc { font-size: 0.82rem; color: var(--text-muted); margin: 0.3rem 0 0; line-height: 1.5; }
3026        .wc-cat-badge { display: inline-block; padding: 0.15rem 0.45rem; border-radius: 2px;
3027                        font-size: 0.65rem; font-weight: 600; text-transform: uppercase; color: white; margin-right: 0.3rem; }
3028        .woodcut-detail { max-width: 900px; margin: 2rem auto; padding: 0 2rem; }
3029        .woodcut-detail h2 { color: var(--accent); margin-bottom: 0.5rem; }
3030        .woodcut-detail h3 { margin-top: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; }
3031        .woodcut-detail p { line-height: 1.8; }
3032        .woodcut-detail .wc-image-frame { text-align: center; margin: 1.5rem 0; }
3033        .woodcut-detail .wc-image-frame img { max-width: 100%; max-height: 700px; border: 1px solid var(--border);
3034                                               box-shadow: 0 2px 8px rgba(0,0,0,0.1); background: #f5f0e8; }
3035        .woodcut-detail .wc-image-caption { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.5rem; font-style: italic; }
3036        .woodcut-detail .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
3037        .woodcut-detail .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem;
3038                                          background: var(--bg); border: 1px solid var(--border); border-radius: 3px;
3039                                          font-size: 0.85rem; color: var(--text); text-decoration: none; }
3040        .woodcut-detail .cross-links a:hover { border-color: var(--accent); color: var(--accent); }
3041        .wc-nav-strip { display: flex; justify-content: space-between; margin: 1.5rem 0; font-size: 0.85rem; }
3042        .wc-nav-strip a { color: var(--accent); text-decoration: none; }
3043        .wc-annotation-note { background: var(--bg-card); border-left: 3px solid var(--accent);
3044                               padding: 0.75rem 1rem; margin: 1rem 0; font-size: 0.9rem; }
3045        @media (max-width: 768px) {
3046            .woodcut-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
3047            .woodcut-card img { height: 240px; }
3048        }
3049    """ + '</style>'
3050
3051    # Build detail pages and collect card HTML
3052    cards_html = ''
3053    slugs = []  # For prev/next navigation
3054
3055    for wc in woodcuts:
3056        (wid, slug, title, sig, page, ia_page, photo, cat, wtype,
3057         desc, narrative, context, elements, has_ann, has_alch,
3058         ann_density, dict_terms, scholarly, influence, source,
3059         conf, notes, ia_cached) = wc
3060        slugs.append((slug, title, page))
3061
3062    for idx, wc in enumerate(woodcuts):
3063        (wid, slug, title, sig, page, ia_page, photo, cat, wtype,
3064         desc, narrative, context, elements, has_ann, has_alch,
3065         ann_density, dict_terms, scholarly, influence, source,
3066         conf, notes, ia_cached) = wc
3067
3068        color = cat_colors.get(cat, '#6b7280')
3069        cat_badge = f'<span class="wc-cat-badge" style="background:{color}">{escape(cat or "")}</span>'
3070        alch_badge = ' <span class="alchemist-tag">Alchemist</span>' if has_alch else ''
3071
3072        # Determine image path
3073        img_filename = f'hp1499_p{page:03d}.jpg'
3074        img_exists = (ia_img_dir / img_filename).exists()
3075        img_path = f'../images/woodcuts_1499/{img_filename}' if img_exists else ''
3076
3077        # Card for index
3078        img_tag = f'<img src="../images/woodcuts_1499/{img_filename}" alt="{escape(title)}" loading="lazy">' if img_exists else '<div style="height:320px;background:#e8e0d0;display:flex;align-items:center;justify-content:center;color:#999;font-style:italic">Image pending</div>'
3079        cards_html += f"""
3080        <div class="woodcut-card" data-category="{escape(cat or '')}">
3081            <a href="{slug}.html">{img_tag}</a>
3082            <div class="wc-info">
3083                <h4><a href="{slug}.html">{escape(title)}</a></h4>
3084                <div class="wc-meta">{cat_badge} p.{page}{alch_badge}</div>
3085                <p class="wc-desc">{escape((desc or '')[:120])}{'...' if desc and len(desc) > 120 else ''}</p>
3086            </div>
3087        </div>"""
3088
3089        # Detail page
3090        # Image
3091        if img_exists:
3092            img_html = f"""
3093            <div class="wc-image-frame">
3094                <img src="../images/woodcuts_1499/{img_filename}" alt="{escape(title)}">
3095                <div class="wc-image-caption">From the 1499 Aldine first edition (University of Seville copy, via Internet Archive)</div>
3096            </div>"""
3097        else:
3098            img_html = ''
3099
3100        # Narrative context (prefer narrative_context, fall back to chapter_context)
3101        narr = narrative or context or ''
3102        narr_html = f'<h3>In the Narrative</h3><p>{escape(narr)}</p>' if narr else ''
3103
3104        desc_html = f'<p>{escape(desc or "")}</p>' if desc else ''
3105        scholarly_html = f'<h3>In Scholarship</h3><p>{escape(scholarly or "")}</p>' if scholarly else ''
3106        influence_html = f'<h3>Influence &amp; Reception</h3><p>{escape(influence or "")}</p>' if influence else ''
3107
3108        # Annotation note (if this page has BL annotations)
3109        ann_html = ''
3110        if has_ann or has_alch:
3111            ann_parts = []
3112            if has_alch:
3113                ann_parts.append('alchemical annotations by Hand B')
3114            if ann_density:
3115                ann_parts.append(f'{ann_density.lower()} annotation density')
3116            if photo:
3117                ann_parts.append(f'BL photograph #{photo}')
3118            folio_link = f' <a href="../marginalia/{(sig or "").lower()}.html">View folio &rarr;</a>' if (sig or '').lower() in _wc_marg_sigs else ''
3119            ann_html = f'<div class="wc-annotation-note"><strong>In the BL copy (C.60.o.12):</strong> This page has {", ".join(ann_parts)}.{folio_link}</div>'
3120
3121        # Cross links
3122        cross_html = ''
3123        cross_parts = []
3124        if dict_terms:
3125            term_links = ''.join(f'<a href="../dictionary/{t.strip()}.html">{t.strip().replace("-", " ").title()}</a>'
3126                                  for t in dict_terms.split(',') if t.strip() and t.strip() in _wc_dict_slugs)
3127            if term_links:
3128                cross_parts.append(f'<h4>Related Dictionary Terms</h4>{term_links}')
3129        if sig and sig.lower() in _wc_marg_sigs:
3130            cross_parts.append(f'<h4>Related Folio</h4><a href="../marginalia/{sig.lower()}.html">Folio {escape(sig)}</a>')
3131        if cross_parts:
3132            cross_html = f'<div class="cross-links">{"".join(cross_parts)}</div>'
3133
3134        # Prev/next navigation
3135        prev_link = f'<a href="{slugs[idx-1][0]}.html">&larr; {escape(slugs[idx-1][1][:30])}</a>' if idx > 0 else '<span></span>'
3136        next_link = f'<a href="{slugs[idx+1][0]}.html">{escape(slugs[idx+1][1][:30])} &rarr;</a>' if idx < len(slugs) - 1 else '<span></span>'
3137
3138        source_label = source or 'unknown'
3139        detail_body = f"""
3140        <div class="woodcut-detail">
3141            <p><a href="index.html">&larr; All Woodcuts</a></p>
3142            <h2>{escape(title)}</h2>
3143            <div style="margin-bottom:1rem">{cat_badge} {escape(sig or '')} &middot; p.{page}</div>
3144            {img_html}
3145            {desc_html}
3146            {narr_html}
3147            {ann_html}
3148            {scholarly_html}
3149            {influence_html}
3150            {cross_html}
3151            <div class="wc-nav-strip">{prev_link}{next_link}</div>
3152            <p style="font-size:0.8rem; color:var(--text-muted); border-top:1px solid var(--border); padding-top:0.5rem; margin-top:1rem">
3153                Source: {escape(source_label)} &middot; {escape(conf or 'DRAFT')}</p>
3154        </div>"""
3155
3156        detail_page = page_shell(title, detail_body, active_nav='woodcuts',
3157                                  extra_css=wc_css, depth=1)
3158        (wc_dir / f'{slug}.html').write_text(detail_page, encoding='utf-8')
3159
3160    # Count categories for filter buttons
3161    cat_counts = {}
3162    for wc in woodcuts:
3163        cat = wc[7] or 'UNCATEGORIZED'
3164        cat_counts[cat] = cat_counts.get(cat, 0) + 1
3165
3166    filter_buttons = '<button class="wc-filter-btn active" onclick="filterWoodcuts(\'ALL\')">All</button>'
3167    for cat in sorted(cat_counts.keys()):
3168        color = cat_colors.get(cat, '#6b7280')
3169        filter_buttons += f'<button class="wc-filter-btn" onclick="filterWoodcuts(\'{cat}\')" style="border-color:{color}">{cat} ({cat_counts[cat]})</button>'
3170
3171    filter_js = """
3172    <script>
3173    function filterWoodcuts(cat) {
3174        document.querySelectorAll('.wc-filter-btn').forEach(b => b.classList.remove('active'));
3175        event.target.classList.add('active');
3176        document.querySelectorAll('.woodcut-card').forEach(card => {
3177            if (cat === 'ALL' || card.dataset.category === cat) {
3178                card.style.display = '';
3179            } else {
3180                card.style.display = 'none';
3181            }
3182        });
3183    }
3184    </script>"""
3185
3186    with_images = sum(1 for wc in woodcuts if (ia_img_dir / f'hp1499_p{wc[4]:03d}.jpg').exists())
3187
3188    index_body = f"""
3189    <div class="woodcuts-page">
3190        <h1>Woodcuts of the <em>Hypnerotomachia Poliphili</em></h1>
3191        <p class="intro">The 1499 Aldine first edition contains approximately 172 woodcut illustrations &mdash;
3192        the most ambitious visual program of any incunabulum. Their designer remains unidentified,
3193        though Benedetto Bordon is the most widely proposed candidate. The woodcuts integrate text and
3194        image with unprecedented sophistication, each composition calibrated to its surrounding typography.
3195        Images are from the 1499 first edition (University of Seville copy, via Internet Archive).</p>
3196
3197        <div class="wc-filters">{filter_buttons}</div>
3198
3199        <p style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem">
3200            Showing {len(woodcuts)} woodcuts &middot; {with_images} with facsimile images</p>
3201
3202        <div class="woodcut-grid">{cards_html}</div>
3203    </div>"""
3204
3205    index_page = page_shell('Woodcuts &mdash; Hypnerotomachia Poliphili', index_body,
3206                             active_nav='woodcuts', extra_css=wc_css,
3207                             extra_js=filter_js, depth=1)
3208    (wc_dir / 'index.html').write_text(index_page, encoding='utf-8')
3209    print(f"  woodcuts/index.html + {len(woodcuts)} woodcut pages ({with_images} with images)")
3210
3211
3212# ============================================================
3213# "The Book" page — narrative summary of the HP
3214# ============================================================
3215
3216def build_the_book_page():
3217    """Generate the-book.html: a narrative walkthrough of the HP for non-specialists."""
3218
3219    book_css = '<style>' + """
3220        .book-page { max-width: 850px; margin: 2rem auto; padding: 0 2rem; }
3221        .book-page h2 { color: var(--accent); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
3222        .book-page h3 { margin: 1.5rem 0 0.5rem; }
3223        .book-page p { line-height: 1.9; margin-bottom: 1rem; }
3224        .book-page .book-note { background: var(--bg-card); padding: 0.75rem 1rem; border-left: 3px solid var(--accent); margin: 1rem 0; font-size: 0.9rem; color: var(--text-muted); }
3225        .book-page .cross-links { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
3226        .book-page .cross-links a { display: inline-block; margin: 0.2rem 0.3rem; padding: 0.2rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem; color: var(--text); text-decoration: none; }
3227        .book-page .cross-links a:hover { border-color: var(--accent); color: var(--accent); }
3228    """ + '</style>'
3229
3230    body = """
3231    <div class="book-page">
3232        <h1>The Book</h1>
3233
3234        <p class="book-note">This page summarizes the narrative of the <em>Hypnerotomachia
3235        Poliphili</em> for readers who have not encountered it before. It is not a scholarly
3236        analysis but a walkthrough &mdash; a map of the journey Poliphilo takes through
3237        his dream, with pointers to the woodcuts, places, and characters you will encounter
3238        on other pages of this site.</p>
3239
3240        <h2>What the <em>Hypnerotomachia</em> Is</h2>
3241
3242        <p>The <em>Hypnerotomachia Poliphili</em> &mdash; the title means roughly
3243        "Poliphilo's struggle of love in a dream" &mdash; was published in Venice in 1499
3244        by <a href="dictionary/aldus-manutius.html">Aldus Manutius</a>, the most
3245        celebrated printer of the Renaissance. It is a large folio volume of 234 leaves,
3246        illustrated with <a href="woodcuts/index.html">172 woodcut illustrations</a>
3247        of extraordinary refinement. The text is written in a
3248        <a href="dictionary/macaronic-language.html">macaronic language</a> that mixes
3249        Italian syntax with Latin vocabulary, Greek loanwords, and invented words &mdash;
3250        a hybrid tongue that has baffled and delighted readers for five centuries.</p>
3251
3252        <p>The book tells the story of a young man named
3253        <a href="dictionary/poliphilo.html">Poliphilo</a> who falls asleep and dreams.
3254        In his dream, he journeys through ruined classical buildings, elaborate gardens,
3255        and allegorical landscapes in search of his beloved,
3256        <a href="dictionary/polia.html">Polia</a>. Along the way he encounters
3257        queens, nymphs, processions, sacrifices, inscriptions, and monuments &mdash;
3258        each described in painstaking visual and architectural detail. The dream is
3259        also a dream within a dream: partway through his journey, Poliphilo falls
3260        asleep again and enters a deeper vision.</p>
3261
3262        <p>The author's identity is concealed in an <a href="dictionary/acrostic.html">acrostic</a>
3263        formed by the chapter initials: POLIAM FRATER FRANCISCVS COLVMNA PERAMAVIT
3264        ("Brother Francesco Colonna loved Polia greatly"). Which Francesco Colonna &mdash;
3265        a Venetian Dominican friar, a Roman nobleman, or someone else entirely &mdash;
3266        remains the subject of a long-running
3267        <a href="dictionary/authorship-debate.html">authorship debate</a>.</p>
3268
3269        <h2>The Journey</h2>
3270
3271        <h3>The Dark Forest</h3>
3272        <p>The narrative opens with Poliphilo lost in a
3273        <a href="dictionary/dark-forest.html">dark, terrifying forest</a> &mdash; a
3274        deliberate echo of Dante's <em>selva oscura</em> at the beginning of the
3275        <em>Inferno</em>. He is alone, frightened, and disoriented. The
3276        <a href="russell-alchemical-hands.html#annotated-woodcuts">woodcut of the dark forest</a> establishes
3277        the visual register that will govern the entire book: dense, precise, and
3278        atmospheric.</p>
3279
3280        <p>Poliphilo emerges from the forest into an open landscape and, exhausted,
3281        falls asleep &mdash; entering the
3282        <a href="dictionary/dream-within-dream.html">dream within the dream</a>
3283        that contains the main narrative.</p>
3284
3285        <h3>The Ruins and the Pyramid</h3>
3286        <p>In his inner dream, Poliphilo encounters a vast ruined classical complex:
3287        a <a href="dictionary/ruined-temple.html">great temple</a>, a colossal
3288        <a href="dictionary/pyramid.html">pyramid</a> surmounted by an
3289        <a href="dictionary/obelisk.html">obelisk</a>, and monumental gates decorated
3290        with reliefs and inscriptions. He describes their
3291        <a href="dictionary/column-orders.html">column orders</a>,
3292        proportions, and materials with the precision of an architectural treatise.
3293        This is where the book's reputation as an encyclopedia of classical design
3294        originates: the ruins are not mere scenery but occasions for sustained
3295        <a href="dictionary/ekphrasis.html">ekphrasis</a> &mdash; verbal descriptions
3296        so vivid they rival the woodcut illustrations.</p>
3297
3298        <p>The most famous image in the book appears here: the
3299        <a href="russell-alchemical-hands.html#annotated-woodcuts">elephant bearing an obelisk</a>
3300        (signature b6v), decorated with pseudo-<a href="dictionary/hieroglyph.html">hieroglyphic</a>
3301        carvings. This image later inspired Bernini's 1667 sculpture in the Piazza della
3302        Minerva in Rome, commissioned by Pope Alexander VII &mdash; who himself annotated
3303        his own copy of the HP.</p>
3304
3305        <h3>Queen Eleuterylida and the Three Gates</h3>
3306        <p>Poliphilo arrives at the court of <a href="dictionary/eleuterylida.html">Queen
3307        Eleuterylida</a>, whose name derives from the Greek word for freedom.
3308        At the <a href="dictionary/thelemia.html">gate of Thelemia</a> (free will),
3309        he must choose among three doors representing three ways of life: pleasure,
3310        action, and contemplation. He chooses the middle path.</p>
3311
3312        <p>The queen's court introduces the
3313        <a href="dictionary/nymphs-five-senses.html">five nymphs</a> who represent
3314        the bodily senses: sight, hearing, smell, taste, and touch. They will guide
3315        Poliphilo through the next stage of his journey.</p>
3316
3317        <h3>The Nymphs and the Bath</h3>
3318        <p>The five nymphs bathe Poliphilo in elaborate
3319        <a href="dictionary/bath-thermae.html">classical thermae</a>, dress him in fine
3320        garments, and lead him through the queen's palace. These passages combine
3321        sensory education with erotic initiation &mdash; the nymphs awaken Poliphilo's
3322        body and mind simultaneously. The architecture of the bath is described with
3323        attention to water systems, marble surfaces, and spatial arrangement, embodying
3324        what Liane Lefaivre called the
3325        <a href="dictionary/architectural-body.html">"architectural body"</a>:
3326        architecture experienced through the flesh, not through abstract geometry.</p>
3327
3328        <h3>The Triumphal Processions</h3>
3329        <p>Poliphilo witnesses a series of elaborate
3330        <a href="dictionary/triumphal-procession.html">triumphal processions</a>
3331        featuring chariots drawn by exotic animals, musicians, dancers, and allegorical
3332        personifications. The <a href="russell-alchemical-hands.html#annotated-woodcuts">procession
3333        woodcuts</a> are among the most complex in the book. These passages connect the HP
3334        to Renaissance festival culture and to the classical literary tradition of the
3335        <em>triumphus</em>. The alchemical annotators later read them as encoding stages
3336        of the <a href="dictionary/great-work.html">Great Work</a> of transmutation.</p>
3337
3338        <h3>The Sacrifice to Priapus</h3>
3339        <p>One of the HP's most explicitly pagan scenes: a
3340        <a href="dictionary/sacrifice-priapus.html">ritual sacrifice</a> at the altar
3341        of Priapus, the garden god of fertility. A donkey is offered in a ceremony that
3342        combines classical sacrificial practice with frank fertility symbolism. Scholarship
3343        has called this "perhaps the most censored woodcut of the Renaissance."</p>
3344
3345        <h3>The Voyage to Cythera</h3>
3346        <p>Poliphilo and Polia travel by boat to
3347        <a href="dictionary/cythera.html">Cythera</a>, the island sacred to
3348        <a href="dictionary/venus-aphrodite.html">Venus</a>. The sea crossing represents
3349        the passage from earthly desire to divine love. On the island, they encounter
3350        an elaborate <a href="dictionary/circular-garden.html">circular garden</a> with
3351        concentric rings of planting surrounding Venus's temple &mdash; a cosmological
3352        design radiating from the goddess at the center outward through successive levels
3353        of material existence.</p>
3354
3355        <p>At the temple, <a href="dictionary/cupid-eros.html">Cupid</a> presides over
3356        the union of Poliphilo and Polia. The consummation of their love is the narrative
3357        climax of Book I.</p>
3358
3359        <h3>Book II: Polia Speaks</h3>
3360        <p>In a remarkable structural move, the HP gives Polia her own voice. Book II
3361        is narrated by Polia herself, who tells the story of her initial rejection of
3362        Poliphilo, her resistance to love, and her eventual conversion by Venus. This
3363        reframing of the love story from the beloved's perspective makes the HP unusual
3364        among Renaissance love narratives, which typically silence the beloved or reduce
3365        her to a visual object.</p>
3366
3367        <h3>The Awakening</h3>
3368        <p>Poliphilo wakes. Polia dissolves. The dream ends. The reader is returned
3369        to the waking world that framed the narrative from the beginning. What remains
3370        is the book itself &mdash; the woodcuts, the language, the architecture, the
3371        gardens &mdash; and five centuries of readers who have left their marks in its
3372        margins.</p>
3373
3374        <h2>The Readers</h2>
3375        <p>James Russell's 2014 PhD thesis documented the
3376        <a href="dictionary/marginalia.html">marginalia</a> in six copies of the HP,
3377        identifying eleven distinct <a href="dictionary/annotator-hand.html">annotator
3378        hands</a>. What he found overturns the idea that the HP was an unread curiosity.
3379        The Giovio brothers read it as a botanical compendium. Ben Jonson mined it for
3380        stage design imagery. Pope Alexander VII collected examples of verbal
3381        <a href="dictionary/acutezze.html">wit</a>. And two anonymous alchemists,
3382        working independently in different copies, decoded the love story as an
3383        <a href="dictionary/alchemical-allegory.html">alchemical allegory</a> &mdash;
3384        but they disagreed about which kind.</p>
3385
3386        <p>The BL alchemist (Hand B) followed the framework of Jean d'Espagnet,
3387        reading the HP as encoding the operations of
3388        <a href="dictionary/master-mercury.html">Master Mercury</a>. The Buffalo
3389        alchemist (Hand E) followed pseudo-Geber, reading it through the lens of
3390        <a href="dictionary/sol-luna.html">Sol and Luna</a> and the
3391        <a href="dictionary/chemical-wedding.html">chemical wedding</a>. Their
3392        competing readings are explored in the
3393        <a href="russell-alchemical-hands.html">Alchemical Hands essay</a>.</p>
3394
3395        <p>Russell's concept of the HP as a
3396        <a href="dictionary/activity-book.html">"humanistic activity book"</a> &mdash;
3397        a text whose puzzles, obscure language, and visual-textual interplay invited
3398        readers to cultivate <a href="dictionary/ingegno.html"><em>ingegno</em></a>
3399        (improvisational intelligence) through creative annotation &mdash; is the
3400        central argument of his thesis and the intellectual foundation of this site.</p>
3401
3402        <div class="cross-links">
3403            <h4>Explore Further</h4>
3404            <a href="marginalia/index.html">Browse the Marginalia</a>
3405            <a href="woodcuts/index.html">See the Woodcuts</a>
3406            <a href="dictionary/index.html">Dictionary of Terms</a>
3407            <a href="russell-alchemical-hands.html">The Alchemical Hands</a>
3408            <a href="timeline.html">500 Years of Reception</a>
3409            <a href="scholars.html">The Scholars</a>
3410            <a href="manuscripts/index.html">The Copies</a>
3411        </div>
3412    </div>"""
3413
3414    page = page_shell('The Book', body, active_nav='thebook', extra_css=book_css)
3415    (SITE_DIR / 'the-book.html').write_text(page, encoding='utf-8')
3416    print("  the-book.html")
3417
3418
3419# ============================================================
3420# Digital Edition stub page
3421# ============================================================
3422
3423def build_digital_edition_page(conn):
3424    """Generate digital-edition.html — editions of the Hypnerotomachia Poliphili."""
3425    cur = conn.cursor()
3426
3427    # Load editions data
3428    cur.execute("""
3429        SELECT id, title, year, city, printer_publisher, translator,
3430               language, edition_type, description, significance,
3431               woodcut_info, digital_facsimile_url, worldcat_url,
3432               extant_copies, slug
3433        FROM editions ORDER BY year
3434    """)
3435    editions = cur.fetchall()
3436
3437    type_labels = {
3438        'FIRST_EDITION': 'First Edition',
3439        'REPRINT': 'Reprint',
3440        'TRANSLATION': 'Translation',
3441        'ADAPTATION': 'Adaptation',
3442        'FACSIMILE': 'Facsimile',
3443        'CRITICAL_EDITION': 'Critical Edition',
3444        'MODERN_TRANSLATION': 'Modern Translation',
3445    }
3446    type_colors = {
3447        'FIRST_EDITION': '#8b5cf6',
3448        'REPRINT': '#6366f1',
3449        'TRANSLATION': '#3b82f6',
3450        'ADAPTATION': '#ef4444',
3451        'CRITICAL_EDITION': '#10b981',
3452        'MODERN_TRANSLATION': '#f59e0b',
3453    }
3454
3455    edition_css = '<style>' + """
3456        .editions-page { max-width: 1000px; margin: 2rem auto; padding: 0 2rem; }
3457        .editions-page h1 { color: var(--accent); font-size: 1.8rem; }
3458        .editions-page .intro { max-width: 800px; line-height: 1.8; margin-bottom: 2rem; color: var(--text-muted); }
3459        .edition-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
3460                        padding: 1.5rem; margin-bottom: 1.5rem; transition: box-shadow 0.2s; }
3461        .edition-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
3462        .edition-card h3 { margin: 0 0 0.5rem; color: var(--text); font-size: 1.1rem; }
3463        .edition-card .ed-meta { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.75rem;
3464                                  font-family: var(--font-sans); }
3465        .edition-card .ed-type { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 2px;
3466                                  font-size: 0.65rem; font-weight: 600; text-transform: uppercase;
3467                                  color: white; margin-right: 0.5rem; }
3468        .edition-card p { line-height: 1.7; margin: 0.5rem 0; font-size: 0.92rem; }
3469        .edition-card .ed-significance { border-top: 1px solid var(--border); padding-top: 0.75rem;
3470                                          margin-top: 0.75rem; }
3471        .edition-card .ed-significance strong { color: var(--accent); }
3472        .edition-card .ed-woodcuts { font-size: 0.85rem; color: var(--text-muted); font-style: italic;
3473                                      margin-top: 0.5rem; }
3474        .edition-card .ed-links { margin-top: 0.75rem; }
3475        .edition-card .ed-links a { display: inline-block; margin-right: 0.75rem; padding: 0.3rem 0.7rem;
3476                                     background: var(--bg); border: 1px solid var(--border); border-radius: 3px;
3477                                     font-size: 0.8rem; color: var(--accent); text-decoration: none; }
3478        .edition-card .ed-links a:hover { border-color: var(--accent); background: var(--accent); color: white; }
3479        .editions-timeline { position: relative; padding-left: 2rem; }
3480        .editions-timeline::before { content: ''; position: absolute; left: 0.5rem; top: 0; bottom: 0;
3481                                      width: 2px; background: var(--border); }
3482        .edition-card::before { content: ''; position: absolute; left: -1.55rem; top: 1.5rem;
3483                                 width: 10px; height: 10px; border-radius: 50%;
3484                                 background: var(--accent); border: 2px solid var(--bg); }
3485        .edition-card { position: relative; }
3486    """ + '</style>'
3487
3488    # Build edition cards
3489    cards = ''
3490    for ed in editions:
3491        (eid, title, year, city, printer, translator, lang, etype,
3492         desc, significance, woodcuts_info, facsimile_url, worldcat_url,
3493         extant, slug) = ed
3494
3495        color = type_colors.get(etype, '#6b7280')
3496        type_label = type_labels.get(etype, etype or '')
3497        type_badge = f'<span class="ed-type" style="background:{color}">{type_label}</span>'
3498
3499        meta_parts = [str(year)]
3500        if city:
3501            meta_parts.append(escape(city))
3502        if printer:
3503            meta_parts.append(escape(printer))
3504
3505        translator_line = f'<br>Translator: {escape(translator)}' if translator else ''
3506        lang_line = f' &middot; {escape(lang)}' if lang else ''
3507
3508        desc_html = f'<p>{escape(desc)}</p>' if desc else ''
3509        sig_html = f'<div class="ed-significance"><strong>Significance:</strong> {escape(significance)}</div>' if significance else ''
3510        wc_html = f'<p class="ed-woodcuts">{escape(woodcuts_info)}</p>' if woodcuts_info else ''
3511        extant_html = f' &middot; {extant} extant copies' if extant else ''
3512
3513        links = ''
3514        link_parts = []
3515        if facsimile_url:
3516            link_parts.append(f'<a href="{escape(facsimile_url)}" target="_blank">Digital Facsimile &rarr;</a>')
3517        if worldcat_url:
3518            link_parts.append(f'<a href="{escape(worldcat_url)}" target="_blank">WorldCat &rarr;</a>')
3519        if link_parts:
3520            links = f'<div class="ed-links">{"".join(link_parts)}</div>'
3521
3522        cards += f"""
3523        <div class="edition-card">
3524            <h3>{escape(title)}</h3>
3525            <div class="ed-meta">{type_badge} {" &middot; ".join(meta_parts)}{lang_line}{extant_html}{translator_line}</div>
3526            {desc_html}
3527            {sig_html}
3528            {wc_html}
3529            {links}
3530        </div>"""
3531
3532    body = f"""
3533    <div class="editions-page">
3534        <p><a href="index.html">&larr; Home</a></p>
3535        <h1>Editions of the <em>Hypnerotomachia Poliphili</em></h1>
3536        <p class="intro">From its first printing by Aldus Manutius in 1499 to Joscelyn Godwin's
3537        complete English translation exactly five centuries later, the <em>Hypnerotomachia Poliphili</em>
3538        has been published, translated, adapted, and reinterpreted across languages, centuries, and
3539        intellectual traditions. Each edition reflects the concerns of its moment: humanist philology
3540        in 1499, Mannerist aesthetics in 1546, alchemical hermeneutics in 1600, and scholarly
3541        archaeology from 1980 onward.</p>
3542
3543        <div class="editions-timeline">
3544            {cards}
3545        </div>
3546    </div>"""
3547
3548    page = page_shell('Editions', body, active_nav='edition', extra_css=edition_css)
3549    (SITE_DIR / 'digital-edition.html').write_text(page, encoding='utf-8')
3550    print(f"  digital-edition.html ({len(editions)} editions)")
3551
3552
3553# ============================================================
3554# Update main index.html with nav
3555# ============================================================
3556
3557def update_index_nav():
3558    """Replace nav bar in index.html with current version, fix CSS links."""
3559    index_path = SITE_DIR / 'index.html'
3560    if not index_path.exists():
3561        return
3562
3563    content = index_path.read_text(encoding='utf-8')
3564    updated = False
3565
3566    # Replace any existing nav with current 8-link version
3567    import re
3568    nav = nav_html('home')
3569    old_nav_pattern = r'<nav class="site-nav">.*?</nav>'
3570    if re.search(old_nav_pattern, content):
3571        content = re.sub(old_nav_pattern, nav, content)
3572        updated = True
3573    elif 'site-nav' not in content:
3574        content = content.replace(
3575            '</div>\n    </header>',
3576            f'    {nav}\n        </div>\n    </header>',
3577            1
3578        )
3579        updated = True
3580
3581    # Fix CSS links: add scholars.css and components.css if missing
3582    if 'scholars.css' not in content:
3583        content = content.replace(
3584            '<link rel="stylesheet" href="style.css">',
3585            '<link rel="stylesheet" href="style.css">\n    <link rel="stylesheet" href="scholars.css">\n    <link rel="stylesheet" href="components.css">'
3586        )
3587        updated = True
3588
3589    # Add meta description if missing
3590    if 'meta name="description"' not in content:
3591        content = content.replace(
3592            '<meta name="viewport"',
3593            '<meta name="description" content="Digital scholarship and marginalia of the Hypnerotomachia Poliphili (Venice, 1499)">\n    <meta name="viewport"'
3594        )
3595        updated = True
3596
3597    if updated:
3598        index_path.write_text(content, encoding='utf-8')
3599        print("  index.html: nav + CSS updated")
3600
3601
3602# ============================================================
3603# Update CSS for new components
3604# ============================================================
3605
3606def update_styles():
3607    """Append new styles to style.css for nav, badges, etc."""
3608    css_path = SITE_DIR / 'style.css'
3609    content = css_path.read_text(encoding='utf-8')
3610
3611    if 'site-nav' in content:
3612        return  # Already updated
3613
3614    additions = """
3615
3616/* ===== Site Navigation ===== */
3617.site-nav {
3618    margin-top: 1.5rem;
3619    display: flex;
3620    justify-content: center;
3621    gap: 0.25rem;
3622    flex-wrap: wrap;
3623}
3624
3625.site-nav a {
3626    color: #9a8c7a;
3627    text-decoration: none;
3628    font-family: var(--font-sans);
3629    font-size: 0.9rem;
3630    padding: 0.4rem 1rem;
3631    border-radius: 3px;
3632    transition: all 0.2s;
3633}
3634
3635.site-nav a:hover {
3636    color: var(--accent-light);
3637    background: rgba(255,255,255,0.05);
3638}
3639
3640.site-nav a.active {
3641    color: var(--header-text);
3642    background: rgba(255,255,255,0.1);
3643}
3644
3645/* ===== Review & Confidence Badges ===== */
3646.review-badge {
3647    display: inline-block;
3648    padding: 0.1rem 0.4rem;
3649    background: #fff3cd;
3650    color: #856404;
3651    border-radius: 2px;
3652    font-size: 0.65rem;
3653    font-weight: 600;
3654    font-family: var(--font-sans);
3655    text-transform: uppercase;
3656    letter-spacing: 0.03em;
3657    vertical-align: middle;
3658    margin-left: 0.3rem;
3659}
3660
3661.confidence-badge {
3662    display: inline-block;
3663    padding: 0.1rem 0.4rem;
3664    border-radius: 2px;
3665    font-size: 0.65rem;
3666    font-weight: 600;
3667    font-family: var(--font-sans);
3668    text-transform: uppercase;
3669    letter-spacing: 0.03em;
3670    vertical-align: middle;
3671    margin-left: 0.3rem;
3672}
3673
3674.confidence-high { background: #d4edda; color: #155724; }
3675.confidence-medium { background: #fff3cd; color: #856404; }
3676.confidence-low { background: #f8d7da; color: #721c24; }
3677.confidence-provisional { background: #e2e3e5; color: #383d41; }
3678
3679/* ===== Review Status Badges ===== */
3680.review-status-badge {
3681    display: inline-block;
3682    padding: 0.15rem 0.5rem;
3683    border-radius: 3px;
3684    font-size: 0.65rem;
3685    font-weight: 700;
3686    font-family: var(--font-sans);
3687    text-transform: uppercase;
3688    letter-spacing: 0.04em;
3689    vertical-align: middle;
3690    margin-left: 0.3rem;
3691}
3692.review-badge-draft { background: #fff3cd; color: #856404; }
3693.review-badge-reviewed { background: #cce5ff; color: #004085; }
3694.review-badge-verified { background: #d4edda; color: #155724; }
3695.review-badge-provisional { background: #e2e3e5; color: #383d41; }
3696
3697/* ===== Dictionary enrichment sections ===== */
3698.dict-section { margin-top: 1.5rem; }
3699.dict-section h3 { font-size: 1rem; color: var(--accent); margin-bottom: 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.2rem; }
3700.dict-section p { line-height: 1.7; }
3701.evidence-list { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; }
3702.evidence-list li { margin-bottom: 0.5rem; }
3703.source-docs { font-size: 0.85rem; color: var(--text-muted); }
3704.source-pages { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.5rem; }
3705.provenance-section { background: var(--bg-card); padding: 1rem; border-radius: 4px; margin-top: 2rem; }
3706.provenance-status { margin-bottom: 0.5rem; }
3707.provenance-list { font-size: 0.85rem; color: var(--text-muted); }
3708.provenance-list li { margin-bottom: 0.3rem; }
3709"""
3710
3711    css_path.write_text(content + additions, encoding='utf-8')
3712    print("  style.css: nav + badge styles added")
3713
3714
3715# ============================================================
3716# Concordance Browser
3717# ============================================================
3718
3719def build_concordance_browser(conn):
3720    """Generate concordance/index.html — 448-page browser of the 1499 HP."""
3721    cur = conn.cursor()
3722    conc_dir = SITE_DIR / 'concordance'
3723    conc_dir.mkdir(exist_ok=True)
3724    ia_img_dir = SITE_DIR / 'images' / 'woodcuts_1499'
3725
3726    # Which signatures have marginalia pages?
3727    marg_dir = SITE_DIR / 'marginalia'
3728    marg_sigs = set()
3729    if marg_dir.exists():
3730        for f in marg_dir.glob('*.html'):
3731            if f.stem != 'index':
3732                marg_sigs.add(f.stem)
3733
3734    cur.execute("""
3735        SELECT pc.page_seq, pc.signature, pc.folio_number, pc.side, pc.quire,
3736               pc.section, pc.has_woodcut, pc.bl_photo_number,
3737               pc.ia_page_index, pc.notes,
3738               w.slug as woodcut_slug, w.title as woodcut_title,
3739               w.subject_category as woodcut_category
3740        FROM page_concordance pc
3741        LEFT JOIN woodcuts w ON pc.page_seq = w.page_1499
3742        ORDER BY pc.page_seq
3743    """)
3744    pages = cur.fetchall()
3745
3746    if not pages:
3747        print("  concordance: no data")
3748        return
3749
3750    section_colors = {
3751        'PRELIMINARIES': '#6b7280', 'DARK_FOREST': '#065f46', 'PYRAMID_RUINS': '#92400e',
3752        'DRAGON_PORTAL': '#7c2d12', 'FIVE_SENSES': '#4338ca', 'QUEEN_PALACE': '#7e22ce',
3753        'JOURNEY_DOORS': '#0369a1', 'PROCESSION': '#b91c1c', 'VENUS_TEMPLE': '#be185d',
3754        'POLYANDRION': '#374151', 'CYTHERA_VOYAGE': '#0891b2', 'CYTHERA_GARDENS': '#15803d',
3755        'VENUS_FOUNTAIN': '#9333ea', 'BOOK_II_POLIA': '#78350f', 'COLOPHON': '#6b7280',
3756    }
3757    section_labels = {
3758        'PRELIMINARIES': 'Preliminaries', 'DARK_FOREST': 'Dark Forest',
3759        'PYRAMID_RUINS': 'Pyramid & Ruins', 'DRAGON_PORTAL': 'Dragon Portal',
3760        'FIVE_SENSES': 'Five Senses', 'QUEEN_PALACE': "Queen's Palace",
3761        'JOURNEY_DOORS': 'Journey of the Doors', 'PROCESSION': 'Triumphal Procession',
3762        'VENUS_TEMPLE': 'Temple of Venus', 'POLYANDRION': 'Polyandrion',
3763        'CYTHERA_VOYAGE': 'Voyage to Cythera', 'CYTHERA_GARDENS': 'Gardens of Cythera',
3764        'VENUS_FOUNTAIN': 'Fountain of Venus', 'BOOK_II_POLIA': 'Book II: Polia',
3765        'COLOPHON': 'Colophon',
3766    }
3767
3768    conc_css = '<style>' + """
3769        .conc-page { max-width: 1100px; margin: 2rem auto; padding: 0 2rem; }
3770        .conc-page h1 { color: var(--accent); font-size: 1.8rem; margin-bottom: 0.3rem; }
3771        .conc-page .intro { max-width: 800px; line-height: 1.8; margin-bottom: 1.5rem; color: var(--text-muted); }
3772        .conc-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem;
3773                         padding: 0.75rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; }
3774        .conc-controls label { font-size: 0.8rem; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; gap: 0.3rem; }
3775        .conc-controls input[type="checkbox"] { accent-color: var(--accent); }
3776        .conc-jump { padding: 0.35rem 0.6rem; border: 1px solid var(--border); border-radius: 3px; font-size: 0.85rem;
3777                     font-family: var(--font-sans); width: 120px; }
3778        .conc-jump:focus { outline: none; border-color: var(--accent); }
3779        .conc-stats { font-size: 0.8rem; color: var(--text-muted); margin-left: auto; }
3780        .conc-section { margin-bottom: 1rem; }
3781        .conc-section-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem;
3782                                background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
3783                                cursor: pointer; position: sticky; top: 0; z-index: 5; user-select: none; }
3784        .conc-section-header:hover { background: var(--bg-card); }
3785        .conc-section-header .sec-badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 2px;
3786                                           font-size: 0.7rem; font-weight: 600; color: white; text-transform: uppercase; }
3787        .conc-section-header .sec-label { font-weight: 600; font-size: 0.95rem; }
3788        .conc-section-header .sec-count { font-size: 0.75rem; color: var(--text-muted); margin-left: auto; }
3789        .conc-section-header .sec-toggle { font-size: 0.8rem; color: var(--text-muted); transition: transform 0.2s; }
3790        .conc-section.collapsed .sec-toggle { transform: rotate(-90deg); }
3791        .conc-section.collapsed .conc-rows { display: none; }
3792        .conc-rows { border: 1px solid var(--border); border-top: none; border-radius: 0 0 4px 4px; }
3793        .conc-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.75rem;
3794                    border-bottom: 1px solid #eee; font-size: 0.82rem; transition: background 0.1s; }
3795        .conc-row:last-child { border-bottom: none; }
3796        .conc-row:hover { background: var(--bg); }
3797        .conc-row.has-wc { background: rgba(139,69,19,0.04); }
3798        .conc-row .pg { width: 45px; font-weight: 600; color: var(--text); font-family: var(--font-sans); }
3799        .conc-row .sig { width: 45px; font-family: var(--font-sans); color: var(--accent); }
3800        .conc-row .folio { width: 55px; color: var(--text-muted); font-family: var(--font-sans); }
3801        .conc-row .quire-col { width: 30px; color: var(--text-muted); font-family: var(--font-sans); text-align: center; }
3802        .conc-row .wc-thumb { width: 60px; height: 45px; flex-shrink: 0; }
3803        .conc-row .wc-thumb img { width: 60px; height: 45px; object-fit: cover; object-position: top;
3804                                   border: 1px solid var(--border); border-radius: 2px; }
3805        .conc-row .wc-info { flex: 1; min-width: 0; }
3806        .conc-row .wc-title { font-size: 0.8rem; color: var(--text); }
3807        .conc-row .wc-title a { color: var(--text); text-decoration: none; }
3808        .conc-row .wc-title a:hover { color: var(--accent); }
3809        .conc-row .wc-cat { font-size: 0.65rem; font-weight: 600; text-transform: uppercase; padding: 0.1rem 0.3rem;
3810                            border-radius: 2px; color: white; }
3811        .conc-row .links { display: flex; gap: 0.3rem; flex-shrink: 0; }
3812        .conc-row .links a { font-size: 0.7rem; padding: 0.15rem 0.4rem; border: 1px solid var(--border);
3813                              border-radius: 2px; text-decoration: none; color: var(--text-muted); background: var(--bg-card); }
3814        .conc-row .links a:hover { border-color: var(--accent); color: var(--accent); }
3815        .conc-row.hidden { display: none; }
3816        .conc-row.highlight { background: #fef3c7 !important; }
3817        @media (max-width: 768px) {
3818            .conc-row .folio, .conc-row .quire-col { display: none; }
3819            .conc-row .wc-thumb { width: 40px; height: 30px; }
3820            .conc-row .wc-thumb img { width: 40px; height: 30px; }
3821        }
3822    """ + '</style>'
3823
3824    wc_cat_colors = {
3825        'ARCHITECTURAL': '#8b5cf6', 'LANDSCAPE': '#10b981', 'NARRATIVE': '#3b82f6',
3826        'HIEROGLYPHIC': '#f59e0b', 'PROCESSION': '#ef4444', 'DECORATIVE': '#6366f1',
3827        'PORTRAIT': '#ec4899', 'DIAGRAM': '#14b8a6',
3828    }
3829
3830    # Group pages by section
3831    from collections import OrderedDict
3832    sections = OrderedDict()
3833    total_wc = 0
3834    for row in pages:
3835        (pg, sig, folio, side, quire, section, has_wc, bl_photo,
3836         ia_page, notes, wc_slug, wc_title, wc_cat) = row
3837        if section not in sections:
3838            sections[section] = []
3839        sections[section].append(row)
3840        if has_wc:
3841            total_wc += 1
3842
3843    # Build section blocks
3844    sections_html = ''
3845    for section, sec_pages in sections.items():
3846        color = section_colors.get(section, '#6b7280')
3847        label = section_labels.get(section, section.replace('_', ' ').title())
3848        wc_in_sec = sum(1 for p in sec_pages if p[6])
3849        pg_range = f'pp.{sec_pages[0][0]}&ndash;{sec_pages[-1][0]}'
3850
3851        rows_html = ''
3852        for (pg, sig, folio, side, quire, sec, has_wc, bl_photo,
3853             ia_page, notes, wc_slug, wc_title, wc_cat) in sec_pages:
3854
3855            row_cls = 'conc-row'
3856            data_attrs = f'data-section="{section}" data-page="{pg}" data-sig="{sig}"'
3857            if has_wc:
3858                row_cls += ' has-wc'
3859                data_attrs += ' data-woodcut="1"'
3860
3861            # Thumbnail
3862            thumb_html = '<div class="wc-thumb"></div>'
3863            if has_wc:
3864                img_file = f'hp1499_p{pg:03d}.jpg'
3865                if (ia_img_dir / img_file).exists():
3866                    thumb_html = f'<div class="wc-thumb"><img src="../images/woodcuts_1499/{img_file}" alt="" loading="lazy"></div>'
3867
3868            # Woodcut info
3869            wc_html = '<div class="wc-info"></div>'
3870            if wc_title and wc_slug:
3871                # Check if this woodcut's detail page exists (CORPUS_EXTRACTION pages were moved to Alchemical Hands)
3872                wc_page_exists = (SITE_DIR / 'woodcuts' / f'{wc_slug}.html').exists()
3873                if wc_page_exists:
3874                    cat_badge = f'<span class="wc-cat" style="background:{wc_cat_colors.get(wc_cat, "#6b7280")}">{escape(wc_cat or "")}</span> ' if wc_cat else ''
3875                    wc_html = f'<div class="wc-info"><span class="wc-title"><a href="../woodcuts/{wc_slug}.html">{cat_badge}{escape(wc_title[:50])}</a></span></div>'
3876                else:
3877                    # Moved to Alchemical Hands page
3878                    wc_html = f'<div class="wc-info"><span class="wc-title"><a href="../russell-alchemical-hands.html#annotated-woodcuts" style="color:var(--accent)">{escape(wc_title[:50])}</a></span></div>'
3879            elif has_wc and not wc_slug:
3880                wc_html = '<div class="wc-info"><span class="wc-title" style="color:var(--accent)">Annotated woodcut</span></div>'
3881
3882            # Links
3883            links = []
3884            sig_lower = sig.lower() if sig else ''
3885            if sig_lower in marg_sigs:
3886                links.append(f'<a href="../marginalia/{sig_lower}.html" title="View marginalia">M</a>')
3887            if bl_photo:
3888                bl_file = f'C_60_o_12-{bl_photo:03d}.jpg'
3889                if (SITE_DIR / 'images' / 'bl' / bl_file).exists():
3890                    links.append(f'<a href="../images/bl/{bl_file}" title="BL photo #{bl_photo}" target="_blank">BL</a>')
3891            links_html = f'<div class="links">{"".join(links)}</div>' if links else ''
3892
3893            rows_html += f"""
3894            <div class="{row_cls}" {data_attrs} id="p{pg}">
3895                <span class="pg">p.{pg}</span>
3896                <span class="sig">{escape(sig or '')}</span>
3897                <span class="folio">f.{folio}{side}</span>
3898                <span class="quire-col">{escape(quire or '')}</span>
3899                {thumb_html}
3900                {wc_html}
3901                {links_html}
3902            </div>"""
3903
3904        sections_html += f"""
3905        <div class="conc-section" data-section="{section}">
3906            <div class="conc-section-header" onclick="toggleSection(this)">
3907                <span class="sec-badge" style="background:{color}">{escape(label)}</span>
3908                <span class="sec-label">{pg_range}</span>
3909                <span class="sec-count">{len(sec_pages)} pages, {wc_in_sec} woodcuts</span>
3910                <span class="sec-toggle">&#9660;</span>
3911            </div>
3912            <div class="conc-rows">{rows_html}</div>
3913        </div>"""
3914
3915    # Filter buttons for sections
3916    sec_buttons = ''
3917    for section in sections:
3918        color = section_colors.get(section, '#6b7280')
3919        label = section_labels.get(section, section)
3920        sec_buttons += f'<button class="wc-filter-btn" onclick="filterSection(\'{section}\')" style="border-color:{color}">{label}</button> '
3921
3922    conc_js = """
3923    <script>
3924    function toggleSection(header) {
3925        header.parentElement.classList.toggle('collapsed');
3926    }
3927    function filterSection(sec) {
3928        document.querySelectorAll('.conc-section').forEach(s => {
3929            if (sec === 'ALL') { s.style.display = ''; }
3930            else { s.style.display = s.dataset.section === sec ? '' : 'none'; }
3931        });
3932        document.querySelectorAll('.wc-filter-btn').forEach(b => b.classList.remove('active'));
3933        event.target.classList.add('active');
3934        updateStats();
3935    }
3936    function filterWoodcuts(checked) {
3937        document.querySelectorAll('.conc-row').forEach(r => {
3938            if (checked && !r.dataset.woodcut) { r.classList.add('hidden'); }
3939            else { r.classList.remove('hidden'); }
3940        });
3941        updateStats();
3942    }
3943    function jumpToPage(val) {
3944        val = val.trim().toLowerCase();
3945        if (!val) return;
3946        // Try as page number
3947        let target = document.getElementById('p' + val);
3948        // Try as signature
3949        if (!target) {
3950            const rows = document.querySelectorAll('.conc-row');
3951            for (const r of rows) {
3952                if (r.dataset.sig && r.dataset.sig.toLowerCase() === val) {
3953                    target = r; break;
3954                }
3955            }
3956        }
3957        if (target) {
3958            // Expand section if collapsed
3959            const sec = target.closest('.conc-section');
3960            if (sec && sec.classList.contains('collapsed')) {
3961                sec.classList.remove('collapsed');
3962            }
3963            // Show if hidden
3964            target.classList.remove('hidden');
3965            // Scroll and highlight
3966            target.scrollIntoView({ behavior: 'smooth', block: 'center' });
3967            document.querySelectorAll('.conc-row.highlight').forEach(r => r.classList.remove('highlight'));
3968            target.classList.add('highlight');
3969            setTimeout(() => target.classList.remove('highlight'), 3000);
3970        }
3971    }
3972    function updateStats() {
3973        const visible = document.querySelectorAll('.conc-row:not(.hidden):not([style*="display: none"])');
3974        const total = document.querySelectorAll('.conc-row');
3975        const wc = document.querySelectorAll('.conc-row.has-wc:not(.hidden)');
3976        const el = document.getElementById('conc-stats');
3977        if (el) el.textContent = visible.length + ' of ' + total.length + ' pages shown';
3978    }
3979    document.getElementById('conc-jump')?.addEventListener('keydown', function(e) {
3980        if (e.key === 'Enter') { jumpToPage(this.value); }
3981    });
3982    document.getElementById('wc-only')?.addEventListener('change', function() {
3983        filterWoodcuts(this.checked);
3984    });
3985    </script>"""
3986
3987    body = f"""
3988    <div class="conc-page">
3989        <p><a href="../concordance-method.html">Methodology &amp; Confidence &rarr;</a></p>
3990        <h1>Page Concordance</h1>
3991        <p class="intro">All 448 page surfaces of the 1499 Aldine first edition, mapped across three
3992        numbering systems: sequential page, bibliographic signature (quire + leaf + recto/verso), and
3993        folio number. Pages with woodcut illustrations show thumbnails from the Internet Archive facsimile
3994        (University of Seville copy). Links connect to marginalia folios (M) and British Library photographs (BL)
3995        where available.</p>
3996
3997        <div class="conc-controls">
3998            <input type="text" class="conc-jump" id="conc-jump" placeholder="Jump to p.123 or b6v">
3999            <label><input type="checkbox" id="wc-only"> Woodcut pages only</label>
4000            <span class="conc-stats" id="conc-stats">{len(pages)} pages, {total_wc} with woodcuts</span>
4001        </div>
4002
4003        {sections_html}
4004    </div>"""
4005
4006    page_html = page_shell("Page Concordance", body, active_nav='concordance',
4007                            extra_css=conc_css, extra_js=conc_js, depth=1)
4008    (conc_dir / 'index.html').write_text(page_html, encoding='utf-8')
4009    print(f"  concordance/index.html ({len(pages)} pages, {total_wc} woodcuts)")
4010
4011
4012# ============================================================
4013# Main
4014# ============================================================
4015
4016def main():
4017    print("=== Building Site ===\n")
4018
4019    conn = sqlite3.connect(DB_PATH)
4020
4021    print("Exporting data...")
4022    export_data_json(conn)
4023
4024    print("\nBuilding pages...")
4025    update_styles()
4026    update_index_nav()
4027    build_scholars_pages(conn)
4028    build_dictionary_pages(conn)
4029    build_marginalia_pages(conn)
4030    build_bibliography_page(conn)
4031    build_docs_pages()
4032    build_code_pages()
4033    build_about_page(conn)
4034    build_the_book_page()
4035    build_russell_essay_page(conn)
4036    build_concordance_essay_page(conn)
4037    build_digital_edition_page(conn)
4038    build_timeline_page(conn)
4039    build_woodcuts_pages(conn)
4040    build_concordance_browser(conn)
4041    build_manuscripts_pages(conn)
4042
4043    conn.close()
4044    print("\n=== Build Complete ===")
4045
4046
4047if __name__ == "__main__":
4048    main()