/* Self-hosted fonts. EB Garamond + IM Fell English are pulled from
   Google Fonts at build time; serving them locally avoids any runtime
   request to fonts.googleapis.com / fonts.gstatic.com. */
@font-face {
    font-family: "EB Garamond";
    font-style: normal;
    font-weight: 400 700;
    font-display: swap;
    src: url("/static/fonts/eb-garamond-400.woff2") format("woff2");
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: "EB Garamond";
    font-style: normal;
    font-weight: 400 700;
    font-display: swap;
    src: url("/static/fonts/eb-garamond-400-ext.woff2") format("woff2");
    unicode-range:
        U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
        U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
        U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
    font-family: "EB Garamond";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("/static/fonts/eb-garamond-italic-400.woff2") format("woff2");
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: "EB Garamond";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("/static/fonts/eb-garamond-italic-400-ext.woff2") format("woff2");
    unicode-range:
        U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
        U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
        U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
    font-family: "IM Fell English";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("/static/fonts/im-fell-english-400.woff2") format("woff2");
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: "IM Fell English";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("/static/fonts/im-fell-english-italic-400.woff2") format("woff2");
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: "Courier Prime";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("/static/fonts/courier-prime-400.woff2") format("woff2");
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: "Courier Prime";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("/static/fonts/courier-prime-400-ext.woff2") format("woff2");
    unicode-range:
        U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
        U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
        U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
    font-family: "Courier Prime";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("/static/fonts/courier-prime-700.woff2") format("woff2");
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
        U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
        U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: "Courier Prime";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("/static/fonts/courier-prime-700-ext.woff2") format("woff2");
    unicode-range:
        U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
        U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
        U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
    --fg: #1c1a17;
    --muted: #6b6760;
    --bg: #faf8f4;
    --accent: #8a2e21;
    --rule: #e6dfd3;
    --gutter: 2rem;
    --measure: 42rem;
}

@media (min-width: 60rem) {
    :root {
        --gutter: 3rem;
    }
}

* {
    box-sizing: border-box;
}

html {
    overflow-y: auto;
    scrollbar-gutter: stable;
}

html,
body {
    margin: 0;
    padding: 0;
    background: var(--bg);
    color: var(--fg);
    font:
        20px/1.55 "IM Fell English",
        "EB Garamond",
        Georgia,
        "Iowan Old Style",
        "Palatino Linotype",
        serif;
}

/* Paint html with the same secondary paper tone the site uses for
   code blocks and the mobile TOC - a touch deeper than the prose
   cream but lighter than --rule. With scrollbar-gutter: stable
   above, the reserved gutter strip on the right of the viewport
   sits inside html but outside body - so html's background only
   shows in that gutter. Body keeps its cream prose ground from the
   html,body rule above. */
html {
    background: #efe7d6;
}

/* Sticky footer: body becomes a vertical flex column tall enough to fill
   the viewport, and .layout grows to absorb any leftover space so the
   site-footer is pinned to the bottom on short pages (e.g. the 404)
   without altering placement on pages whose content already exceeds the
   viewport. */
body {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

.layout {
    flex: 1 0 auto;
}

/* IM Fell English ships only in regular + italic. Inline emphasis goes to
   EB Garamond so we get a real bold cut instead of a synthesised one.
   Headings stay in IM Fell English at normal weight - its natural stroke
   is substantial enough that size differential carries the hierarchy. */
strong,
b {
    font-family:
        "EB Garamond", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
}

h1,
h2,
h3,
h4,
h5,
h6,
.site-header .brand,
.site-header-floating .brand {
    font-weight: normal;
}

h1,
h2 {
    color: #1e3a5f;
}

a {
    color: var(--accent);
}
a:hover {
    text-decoration: none;
}

code,
kbd,
samp {
    font-family:
        "Courier Prime", ui-monospace, SFMono-Regular, "SF Mono", Menlo,
        Consolas, "Liberation Mono", monospace;
    font-size: 0.95em;
    background: #efe7d6;
    padding: 0.1em 0.35em;
    border-radius: 0.25rem;
}

pre {
    background: #efe7d6;
    padding: 0.75rem 1rem;
    border-radius: 0.4rem;
    overflow-x: auto;
    font-size: 0.85em;
}

pre code {
    background: transparent;
    padding: 0;
    border-radius: 0;
    font-size: inherit;
}

.page,
.site-footer {
    padding-left: var(--gutter);
    padding-right: var(--gutter);
}

/* Clean paper bar that counter-balances the traditional prose. A
   subtle "tartan crown" mirrors the footer's construction in
   reverse: the same diagonal tartan tile is laid across the band
   and overlaid with a paper-coloured wash that's fully opaque at
   the bottom (where the bar meets the prose) and fades up to expose
   ~30% of the weave at the very top edge - a thin woven ribbon
   above an otherwise clean paper bar. Both .site-header and the
   sticky .site-header-floating contain a hero so the two render
   identically. */
.site-header-hero {
    position: relative;
    isolation: isolate;
    overflow: hidden;
    background-color: var(--bg);
    background-image: url("/static/images/tartan-pattern-realistic-diagonal.png");
    background-repeat: repeat;
    background-size: 396px 396px;
    background-position: top center;
    border-bottom: 1px solid var(--rule);
}

/* Warm-paper wash on top of the diagonal tartan. Unlike the footer
   (which sits opaque where it meets the prose so the seam reads
   clean), the header fades continuously across its full height -
   bold tartan ribbon at the top edge, eased down to a quiet wash
   at the bottom where the bar meets the prose, so the weave stays
   faintly visible across the whole header rather than disappearing
   into a plain band below the ribbon. Tone is a fraction deeper
   than the prose cream so the band still reads as a distinct paper
   panel against the lighter prose. */
.site-header-hero::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(
        to top,
        rgba(247, 243, 234, 0.98) 0%,
        rgba(247, 243, 234, 0.96) 20%,
        rgba(247, 243, 234, 0.92) 40%,
        rgba(247, 243, 234, 0.88) 55%,
        rgba(247, 243, 234, 0.82) 70%,
        rgba(247, 243, 234, 0.74) 85%,
        rgba(247, 243, 234, 0.65) 100%
    );
    pointer-events: none;
    z-index: 0;
}

.site-header-bar {
    display: flex;
    align-items: center;
    gap: 2rem;
    padding: calc(0.95rem + 2px) var(--gutter);
}

/* MacGregor pine-sprig motif flush with the right edge of the hero
   - Scots pine is the clan's plant badge. Rendered as a single-tone
   silhouette in the same in-between text colour the nav uses, so it
   reads as a quiet detail rather than a coloured emblem. Absolutely
   positioned (sibling of the bar inside the hero) so it doesn't
   participate in the bar's flex flow - the bar's natural height
   stays fixed by brand + nav, and the motif overlays the bar at
   its own size rather than stretching the bar to fit. right: 0
   sits flush with the hero's right edge (the hero's overflow:
   hidden keeps the rendered silhouette inside that edge).

   Selector double-scopes to .site-header-hero so it beats the
   later `.site-header-hero > *` rule, which would otherwise force
   position: relative on every direct hero child and drop the motif
   back into flow under the bar.

   Hidden at the burger breakpoint so brand + burger get the full
   width. */
.site-header-hero > .site-header-motif-link {
    position: absolute;
    right: 1rem;
    top: 50%;
    /* scaleX(-1) mirrors the SVG horizontally so the cone faces the
       page edge with the needles trailing into the bar, rather than
       the source SVG's cone-toward-nav orientation. */
    transform: translateY(-50%) scaleX(-1);
    display: block;
    height: 3rem;
    line-height: 0;
}

.site-header-hero > .site-header-motif-link > .site-header-motif {
    display: block;
    height: 100%;
    width: auto;
}

.site-header-hero > * {
    position: relative;
    z-index: 1;
}

/* Burger toggle. Hidden by default; only shown on the mobile breakpoint
   defined further down. The three bars are absolutely-positioned children
   of a fixed-size span so the morph to an X cross-fades cleanly. */
.nav-toggle {
    display: none;
    background: none;
    border: none;
    padding: 0.4rem;
    margin: 0;
    cursor: pointer;
    color: var(--fg);
    align-self: center;
}

.nav-toggle-bars {
    position: relative;
    display: inline-block;
    width: 1.4rem;
    height: 1rem;
}

.nav-toggle-bars span {
    position: absolute;
    left: 0;
    right: 0;
    height: 2px;
    background: currentColor;
    border-radius: 2px;
    transition: transform 0.18s ease, opacity 0.18s ease, top 0.18s ease;
}

.nav-toggle-bars span:nth-child(1) { top: 0; }
.nav-toggle-bars span:nth-child(2) { top: 0.45rem; }
.nav-toggle-bars span:nth-child(3) { top: 0.9rem; }

.nav-toggle[aria-expanded="true"] .nav-toggle-bars span:nth-child(1) {
    top: 0.45rem;
    transform: rotate(45deg);
}
.nav-toggle[aria-expanded="true"] .nav-toggle-bars span:nth-child(2) {
    opacity: 0;
}
.nav-toggle[aria-expanded="true"] .nav-toggle-bars span:nth-child(3) {
    top: 0.45rem;
    transform: rotate(-45deg);
}

.site-header-floating {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    /* Above Leaflet controls (default z-index 1000) and the always-
       visible top rule (1099) so map zoom / layers / attribution
       slide under the sticky header rather than covering it on
       scroll, and so the bar overlays the rule when it slides in. */
    z-index: 1100;
    transform: translateY(-100%);
    visibility: hidden;
    will-change: transform;
    /* Soft drop-shadow lifts the pinned bar off the prose during
       scroll. Rides with the element: when the bar is off-screen
       (translateY(-100%)) the shadow goes with it, so it only
       appears once the bar slides into view. */
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}

.site-header-floating.is-visible {
    visibility: visible;
}

/* On touch devices, keep the floating bar pinned at the top from page
   load. At scroll 0 it overlays the in-flow .site-header-bar; the two
   are identical so it reads as a single bar. As you scroll, the in-
   flow copy moves away and the floating bar stays put - so the iOS
   status-bar tint always samples an opaque bar rather than scrolling
   prose, which is what the keeper noticed worked well. !important is
   needed to beat the inline transform set by the scroll-driven JS. */
@media (hover: none) and (pointer: coarse) {
    .site-header-floating {
        transform: none !important;
        visibility: visible !important;
    }
}

.site-header-image {
    display: block;
    width: 100%;
    height: clamp(160px, 28vw, 320px);
    object-fit: cover;
    object-position: center 35%;
    border-bottom: 1px solid var(--rule);
}

.site-header .brand,
.site-header-floating .brand {
    font-size: 1.4rem;
    text-decoration: none;
    letter-spacing: 0.02em;
    color: #1e3a5f;
}

.site-header nav a,
.site-header-floating nav a {
    margin-right: 1.25rem;
    font-size: 1.1rem;
    letter-spacing: 0.02em;
    text-decoration: none;
    /* Ink blue, matching the brand and h1/h2 - keeps the header to a
       two-colour palette (blue + red accent) rather than introducing
       a third tone for nav. */
    color: #1e3a5f;
}

.site-header nav a:hover,
.site-header-floating nav a:hover {
    text-decoration: underline;
    text-underline-offset: 0.25em;
}

.site-header nav a[aria-current="page"],
.site-header-floating nav a[aria-current="page"] {
    color: var(--accent);
    text-decoration: underline;
    text-underline-offset: 0.25em;
}

.page {
    padding-top: 2rem;
    padding-bottom: 3rem;
    /* Establish a size container so descendants like .pair can react to
       the actual prose-column width rather than the viewport, so the
       breakpoint stays correct when a TOC is present and trims the
       column down. */
    container-type: inline-size;
}

/* When a breadcrumb leads the page, it provides its own vertical anchor
   above the h1, so the 2rem of breathing room becomes redundant - and on
   document pages it pushes the sticky preview column below the fold. Let
   the breadcrumb stand in for most of that padding. */
.page:has(> .breadcrumbs) {
    padding-top: 0.75rem;
}

.page h1 {
    font-size: 2rem;
    margin: 0 0 1rem;
}

/* Opt-in breadcrumb trail. Pages set `breadcrumbs:` in frontmatter as a
   list of `{title, path}` items; the last item omits `path` to mark the
   current page. Sits above the page title, small and muted. */
.breadcrumbs {
    margin: 0 0 0.4rem;
    font-size: 0.85rem;
    color: var(--muted);
}
.breadcrumbs ol {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem 0;
}
.breadcrumbs li {
    display: inline;
}
.breadcrumbs li + li::before {
    content: "›";
    margin: 0 0.45rem;
    color: var(--muted);
}
.breadcrumbs a {
    color: var(--accent);
}
.breadcrumbs [aria-current="page"] {
    color: var(--fg);
}
/* Collapsible-trail toggle. The toggle button is hidden on wide screens
   where the full trail fits comfortably; the `.bc-mid` items only collapse
   below the narrow breakpoint. On narrow screens the middle items hide by
   default and a `…` button reveals them (toggling .is-expanded on the
   nav). When expanded, the toggle hides itself out of the way. */
.breadcrumbs.is-collapsible .bc-toggle button {
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    color: var(--accent);
    cursor: pointer;
    font: inherit;
    line-height: inherit;
}
.breadcrumbs.is-collapsible .bc-toggle button:hover {
    text-decoration: underline;
}
@media (min-width: 36rem) {
    .breadcrumbs.is-collapsible .bc-toggle {
        display: none;
    }
}
@media (max-width: 36rem) {
    .breadcrumbs.is-collapsible:not(.is-expanded) .bc-mid {
        display: none;
    }
    .breadcrumbs.is-collapsible.is-expanded .bc-toggle {
        display: none;
    }
}

.page h2 {
    font-size: 1.4rem;
    margin: 2rem 0 0.75rem;
}

/* In-page anchor links (e.g. TOC clicks, cross-references between
   sections of /links) should land the heading near the top of the
   viewport. On desktop the floating header is suppressed during the
   jump (see __suppressFloatingHeader), so a tiny breathing room is
   all that's needed; otherwise the previous section's content pokes
   into view above the target heading. On touch devices the floating
   header is pinned at the top from page load, so the larger value
   keeps the heading clear of it. */
.page h2,
.page h3 {
    scroll-margin-top: 1rem;
}

@media (hover: none) and (pointer: coarse) {
    .page h2,
    .page h3 {
        scroll-margin-top: 5rem;
    }
}

/* Constrain prose to a comfortable measure but anchor it to the left
   rather than centering - keeps the page feeling left-aligned even
   on wide viewports while still readable. Wide elements (figures,
   tables, iframes) opt out via .wide. The page title (h1) runs full
   width so it isn't visually clipped by the prose column. */
.page > p,
.page > ul,
.page > ol,
.page > blockquote,
.page > h2,
.page > h3,
.page > figure,
.page > aside.callout,
.page > .footnotes,
.page > table {
    max-width: var(--measure);
}

.page p {
    margin: 0 0 1rem;
}

/* Blockquote: vertical rule + faint background, like a quoted reply
 * in an email. Applies wherever page markdown is rendered (notebook,
 * document sidecar bodies, share pages). The left border picks up
 * the site's --rule colour so it ties to the page dividers; the
 * background is a soft tint of the same hue so it sits quietly
 * against the parchment field. */
.page blockquote {
    margin: 0 0 1rem 0;
    padding: 0.5rem 1rem;
    border-left: 3px solid var(--rule, #d8d4ca);
    border-radius: 0 0.25rem 0.25rem 0;
    background: rgba(216, 212, 202, 0.25);
    color: #444;
}

.page blockquote > :last-child {
    margin-bottom: 0;
}

/* Markdown tables. The bare <table> blocks that come out of GFM-style
   pipe syntax in notebook prose; document sidecar bodies render their
   markdown inside .body-col, so that case is matched explicitly. The
   xlsx-preview and zip-listing tables further down the file are inside
   .doc-layout .preview and don't match these selectors, so they keep
   their own spreadsheet/manifest chrome. Hairline rules use --rule so
   they tie to the rest of the page dividers; a slightly heavier rule
   under the header row marks the boundary without needing a fill.
   Cells left-align and top-align so a multi-line entry sits cleanly
   next to a short one in the same row - the browser default centres
   <th> and middles each cell, which reads as a spreadsheet rather
   than prose. Width is auto, capped at the prose measure via the
   .page > table rule above, so a small table doesn't stretch the full
   column width. */
.page > table,
.body-col table {
    margin: 0 0 1.5rem;
    border-collapse: collapse;
    border-bottom: 1px solid var(--rule);
}

.page > table thead th,
.body-col table thead th {
    text-align: left;
    vertical-align: bottom;
    padding: 0.5rem 0.75rem;
    border-bottom: 2px solid var(--rule);
    font-weight: 600;
}

.page > table tbody td,
.body-col table tbody td {
    text-align: left;
    vertical-align: top;
    padding: 0.55rem 0.75rem;
    border-top: 1px solid var(--rule);
}

.page img {
    max-width: 100%;
    height: auto;
    border-radius: 0.75rem;
}
/* Leaflet tiles are <img> elements, so any site-wide rule that styles
   <img> inside the page or document layout silently leaks onto every
   tile. The defences:
   - border:0 cancels the 1px var(--rule) border that the document
     preview's `.doc-layout .preview img` rule applies to images;
     without this reset every tile carries a beige hairline border
     and adjacent tiles' borders abut to draw a complete cream grid
     across the map at every tile boundary.
   - border-radius:0 cancels the 0.75rem corner-round from the global
     `.page img` rule, whose rounded corners at 4-tile intersections
     formed a cream-coloured star where the .map background showed
     through.
   - max-width:none defends against the same `.page img` rule's
     max-width:100% shrinking tiles when a parent column is narrower
     than 256px. */
.leaflet-container img.leaflet-tile,
.leaflet-tile {
    border: 0 !important;
    border-radius: 0 !important;
    max-width: none !important;
}

/* Inline call-out / banner. Used at the top of the index page to draw
   attention to a specific question or appeal. */
.page > aside.callout {
    margin: 1.5rem 0 2rem;
    padding: 1rem 1.25rem;
    background: #efe7d6;
    border-left: 3px solid var(--accent);
    border-radius: 0 0.5rem 0.5rem 0;
}

.page > aside.callout p {
    margin: 0;
}

.page > aside.callout p + p {
    margin-top: 0.6rem;
}

/* Captioned photo. The figure carries a hairline border and rounded
   corners; the image and (optional) figcaption clip to those corners
   via overflow:hidden, so image + caption together form one bordered
   card. The figcaption reads as the lower margin of a print mount
   rather than a free-floating caption. */
.page figure.photo {
    margin: 1.5rem 0;
    border: 1px solid var(--rule);
    border-radius: 0.75rem;
    overflow: hidden;
}

/* When the figure wraps an image, shrink the card to the image's
   natural width so small scans aren't stretched up to the prose
   measure. The prose-measure cap on .page > figure is still the
   upper bound. Map figures use a child-combinator match (`> img`)
   so Leaflet's tile <img> elements - nested inside `.map` once the
   adapter runs - don't trigger this rule and collapse the figure
   to zero width. */
.page figure.photo:has(> img),
.page figure.photo:has(> a > img) {
    width: fit-content;
}

.page figure.photo img {
    display: block;
    margin: 0;
    max-width: 100%;
    height: auto;
    border-radius: 0;
}

.page figure.photo figcaption {
    background: #efe7d6;
    color: var(--muted);
    font-size: 0.7rem;
    line-height: 1.4;
    /* Slightly heavier top, lighter bottom: IM Fell English has long
       descenders, so symmetric padding leaves the text body visually
       above centre - this nudges it back down to the eye. */
    padding: 0.55rem 0.7rem 0.3rem;
}

.page figure.photo figcaption a {
    color: var(--muted);
}

.page figure.photo figcaption a:hover {
    color: var(--accent);
}

/* Opt-out: transparent-background images that shouldn't sit inside a
   bordered card. Removes the frame and rounded corners; overflow:visible
   so nothing clips. */
.page figure.photo.no-border {
    border: none;
    border-radius: 0;
    overflow: visible;
}

/* Interactive Leaflet map. Sized to the prose column at a 3:2 aspect
   ratio, so it visually mirrors the standard photo size on the site.
   Only loaded into pages that set `map: true` in their frontmatter -
   pages without maps stay JS-free. */
.page figure.photo .map,
.page .map {
    display: block;
    width: 100%;
    aspect-ratio: 3 / 2;
    background: #f0e8d8; /* warm cream while tiles load */
}

/* Two figures side-by-side at the prose measure each. Works for any
   `figure.photo` pair - photographs, maps, diagrams. When the prose
   column is wide enough to fit both at full size (2 × measure + gap
   = 85rem) the pair becomes a two-column grid; below that they stack
   as ordinary single-column figures, identical to the unwrapped
   layout. The query is on `.page`'s container size, not the viewport,
   so the breakpoint stays correct whether or not a TOC is present. */
.page > .pair {
    margin: 1.5rem 0;
}

.page > .pair > figure.photo {
    margin: 0 0 1.5rem;
    max-width: var(--measure);
}

.page > .pair > figure.photo:last-child {
    margin-bottom: 0;
}

@container (min-width: 85rem) {
    .page > .pair {
        display: grid;
        grid-template-columns: var(--measure) var(--measure);
        gap: 1rem;
        align-items: start;
    }
    .page > .pair > figure.photo {
        margin: 0;
    }
}

/* Two reference cards side-by-side, sharing the prose measure (each
   roughly half-width). Unlike `.pair` - two full-measure photos that
   break out wide on large screens - this stays within the measure, so a
   pair of single-stone guide cards sits compactly beneath the photograph
   of the stone pair they annotate. Each figure fills its track (the
   default `fit-content` cap is lifted), and the pair collapses to a
   single column on narrow viewports. */
.page > .card-pair {
    margin: 1.5rem 0;
    max-width: var(--measure);
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0.75rem;
    align-items: start;
}

.page > .card-pair > figure.photo {
    width: auto;
    max-width: none;
    margin: 0;
}

@container (max-width: 28rem) {
    .page > .card-pair {
        grid-template-columns: 1fr;
    }
}

/* Marker spans inside .map are no-JS fallbacks (the JS removes them
   before Leaflet renders). If JS is disabled, render the markers as a
   small list under the figure rather than as a blank map area. */
.page .map .marker {
    display: block;
    font-size: 0.85rem;
    color: var(--muted);
    padding: 0.25rem 0.7rem;
}
.page .map .marker::before {
    content: "• ";
    color: var(--accent);
}

/* Photo thumbnail map markers and their popups. The .map-photo-marker
   class is applied by L.divIcon so Leaflet's default icon background is
   suppressed; the img inside is the visible pin. */
.map-photo-marker {
    background: transparent !important;
    border: none !important;
}
.map-photo-marker img {
    width: 80px;
    height: 60px;
    object-fit: cover;
    border: 2px solid #fff;
    border-radius: 2px;
    box-shadow: 0 1px 5px rgba(0, 0, 0, 0.5);
    display: block;
    cursor: pointer;
}
/* Geograph API thumbnail markers. The outer div (.map-geo-thumb) is left
   untouched so Leaflet can use its transform for marker positioning. The
   img inside carries the scale: the map container exposes --geo-scale and
   transform-origin: bottom center keeps the anchor point (bottom-centre of
   the 90×67 box = the geographic pin) fixed as zoom changes. */
.map-geo-thumb {
    background: transparent !important;
    border: none !important;
}
.map-geo-thumb img {
    width: 90px;
    height: 67px;
    object-fit: cover;
    border: 2px solid #fff;
    border-radius: 2px;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
    display: block;
    cursor: pointer;
    transform: scale(var(--geo-scale, 1));
    transform-origin: bottom center;
    transition: transform 0.15s ease-out;
}

.leaflet-popup-content .map-popup-photo {
    margin: 0;
    padding: 0;
}
.leaflet-popup-content .map-popup-photo img {
    display: block;
    width: 200px;
    max-width: 100%;
    height: auto;
}
.leaflet-popup-content .map-popup-photo figcaption {
    font-size: 0.85em;
    margin-top: 5px;
    color: #444;
}
.leaflet-popup-content .map-popup-photo a {
    display: block;
    margin-top: 4px;
    font-size: 0.85em;
}

/* Leaflet's attribution control: tighten typography to fit the site. */
.leaflet-container .leaflet-control-attribution {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    font-size: 11px;
    background: rgba(255, 255, 255, 0.88);
    color: #1a1a1a;
}

/* Round Leaflet's button-style controls (zoom +/-, layer switcher,
   our reset / fullscreen buttons) to match the figure.photo card
   radius - so the map's UI furniture reads as part of the same
   visual family as the photo cards. The first/last-child overrides
   clip the inner buttons of a stacked .leaflet-bar to the same
   outer radius. */
.leaflet-bar,
.leaflet-control-layers {
    border-radius: 0.75rem !important;
    overflow: hidden;
}
.leaflet-bar a:first-child {
    border-top-left-radius: 0.75rem;
    border-top-right-radius: 0.75rem;
}
.leaflet-bar a:last-child {
    border-bottom-left-radius: 0.75rem;
    border-bottom-right-radius: 0.75rem;
}

/* The attribution sits flush in the bottom-right of the map and
   reads as a strip along the edge. The bottom-right corner follows
   the map's outer curve; the top-left is the only interior corner
   exposed against the map content, so round it too. The remaining
   two corners stay square so the strip docks neatly to the map's
   edges. */
.leaflet-container .leaflet-control-attribution {
    border-radius: 0.25rem 0 0.75rem 0;
}

/* Compare-mode pairs the attribution: historical on the bottom-left,
   OS on the bottom-right. The bottom-left strip is the mirror image,
   so its rounded corners are top-right + bottom-left. */
.leaflet-container .leaflet-bottom.leaflet-left .leaflet-control-attribution {
    border-radius: 0 0.25rem 0 0.75rem;
}

/* A touch of extra padding on whichever edge carries the larger
   rounded corner, so the text doesn't crowd the curve. */
.leaflet-container .leaflet-bottom.leaflet-right .leaflet-control-attribution {
    padding-right: 0.5rem;
}
.leaflet-container .leaflet-bottom.leaflet-left .leaflet-control-attribution {
    padding-left: 0.5rem;
}

/* Custom Leaflet controls (reset-view, fullscreen). Match the
   Leaflet zoom-button visual idiom (.leaflet-bar treatment) so they
   sit beneath +/- as one top-right cluster. Override Leaflet's
   default `.leaflet-bar a` padding/border-bottom and use flex
   centring with an explicitly-sized SVG, so each icon ends up
   pixel-centred in both touch and non-touch Leaflet skins. */
.leaflet-control-reset-view,
.leaflet-control-fullscreen {
    display: flex !important;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: 30px;
    height: 30px;
    padding: 0;
    border-bottom: 0;
    text-decoration: none;
    color: #333;
    background: #fff;
}
.leaflet-control-reset-view:hover,
.leaflet-control-fullscreen:hover {
    background: #f4f4f4;
    color: #000;
}
.leaflet-control-reset-view svg,
.leaflet-control-fullscreen svg {
    display: block;
    width: 16px;
    height: 16px;
}
.leaflet-touch .leaflet-control-reset-view,
.leaflet-touch .leaflet-control-fullscreen {
    /* Match the touch-skin button size Leaflet uses for +/-. */
    width: 30px;
    height: 30px;
}

/* Static map (data-static="true"): no controls, no interaction.
   Override Leaflet's grab cursor since dragging is off, and forbid
   pointer events on tile panes so cursor stays default everywhere. */
.page .map.map-static .leaflet-grab,
.page .map.map-static .leaflet-interactive {
    cursor: default;
}

/* When the map div enters native browser fullscreen, drop the
   3:2 aspect-ratio cap so it fills the whole screen, and remove
   the figure border-radius so corners aren't visible at the edges. */
.page .map:fullscreen {
    width: 100%;
    height: 100%;
    aspect-ratio: auto;
    border-radius: 0;
}

/* CSS-based "fake fullscreen" fallback for browsers that don't
   support Element.requestFullscreen() on arbitrary divs - notably
   iOS Safari, which only allows the API on <video>. maps.js applies
   .map-fake-fullscreen to the map div (and .map-fake-fullscreen-host
   to <html>) when the real API rejects or is missing.

   100dvh handles iOS Safari's collapsing URL bar; the 100vh fallback
   is for browsers without dynamic-viewport support. */
.page .map.map-fake-fullscreen {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100vh;
    height: 100dvh;
    aspect-ratio: auto;
    z-index: 9999;
    border-radius: 0;
    margin: 0;
    padding: 0;
}

html.map-fake-fullscreen-host,
html.map-fake-fullscreen-host body {
    overflow: hidden;
}

/* Authoring debug panel: live readout of centre + zoom in the
   top-left of the map. Click to copy as data-attributes. Only
   appears when data-debug="true" is set on the map div, so it
   never lands on a published page. The .copied state flashes
   briefly after a successful clipboard write. */
.map-debug-panel {
    position: absolute;
    top: 10px;
    left: 10px;
    z-index: 600;
    padding: 6px 9px;
    background: rgba(255, 255, 255, 0.92);
    border: 1px solid rgba(0, 0, 0, 0.18);
    border-radius: 0.5rem;
    color: #1a1a1a;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 11px;
    line-height: 1.45;
    white-space: pre;
    cursor: pointer;
    user-select: text;
    transition: background 0.18s ease-out;
}
.map-debug-panel:hover {
    background: rgba(255, 255, 255, 1);
}
.map-debug-panel.copied {
    background: rgba(122, 196, 122, 0.35);
}

/* Compare-mode swipe divider: a thin vertical bar with a round
   handle at mid-height, dragged left/right to reveal more of the
   historical or modern layer. Sits above the tile panes (z-index
   400 in Leaflet's stack) but below the controls (z-index 800+)
   so the +/-, reset and fullscreen buttons can still be clicked
   when the divider is parked at one edge. */
.page .map .map-swipe-divider {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 4px;
    margin-left: -2px;
    background: rgba(255, 255, 255, 0.85);
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
    z-index: 500;
    cursor: ew-resize;
    touch-action: none;
}
.page .map .map-swipe-divider:focus-visible {
    outline: 2px solid var(--accent, #8a2e21);
    outline-offset: 2px;
}
.page .map .map-swipe-handle {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 36px;
    height: 36px;
    margin: -18px 0 0 -18px;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.96);
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
    color: #333;
    display: flex;
    align-items: center;
    justify-content: center;
    /* Take the whole circle as a hit target; pointerdown bubbles
       up to the divider's drag handlers. cursor inherits so the
       handle reads as part of the same draggable affordance. */
    cursor: inherit;
}
.page .map .map-swipe-divider.dragging .map-swipe-handle,
.page .map .map-swipe-divider:hover .map-swipe-handle {
    background: #fff;
    color: #000;
}

/* Photo comparison slider. Same visual language as the map swipe
   divider but operates on two stacked images rather than tile layers.
   The left image sits in normal flow (setting the container height);
   the right image is clipped with clip-path to reveal from the
   divider position rightward. */
.page figure.photo > .photo-compare {
    position: relative;
    overflow: hidden;
    touch-action: none;
    user-select: none;
    cursor: ew-resize;
    display: block;
    width: 100%;
}
.page figure.photo > .photo-compare .photo-compare-img--left {
    display: block;
    width: 100%;
    height: auto;
}
.page figure.photo > .photo-compare .photo-compare-img--right {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    max-width: none;
}
.page figure.photo > .photo-compare .photo-compare-divider {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 4px;
    margin-left: -2px;
    background: rgba(255, 255, 255, 0.85);
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
    z-index: 10;
    cursor: ew-resize;
    touch-action: none;
}
.page figure.photo > .photo-compare .photo-compare-divider:focus-visible {
    outline: 2px solid var(--accent, #8a2e21);
    outline-offset: 2px;
}
.page figure.photo > .photo-compare .photo-compare-handle {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 36px;
    height: 36px;
    margin: -18px 0 0 -18px;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.96);
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
    color: #333;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: inherit;
}
.page figure.photo > .photo-compare .photo-compare-divider.dragging .photo-compare-handle,
.page figure.photo > .photo-compare .photo-compare-divider:hover .photo-compare-handle {
    background: #fff;
    color: #000;
}
.page figure.photo > .photo-compare .photo-compare-label {
    position: absolute;
    bottom: 0.5rem;
    background: rgba(0, 0, 0, 0.5);
    color: #fff;
    font-size: 0.65rem;
    padding: 0.2rem 0.5rem;
    border-radius: 0.25rem;
    pointer-events: none;
}
.page figure.photo > .photo-compare .photo-compare-label--left  { left:  0.5rem; }
.page figure.photo > .photo-compare .photo-compare-label--right { right: 0.5rem; }

/* Cooperative-scroll hint. Briefly fades in over the map when the
   reader scrolls without Ctrl/Cmd held, telling them which key to
   hold to zoom. pointer-events:none so it doesn't block clicks. */
.map-scroll-hint {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.45);
    color: #fff;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    font-size: 1rem;
    letter-spacing: 0.02em;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.18s ease-out;
    z-index: 1000;
}
.map-scroll-hint.visible {
    opacity: 1;
}

/* GPX track overlay. The summary line and the optional elevation
   profile are inserted by maps.js as siblings of the .map div,
   inside the same <figure>, so they read as part of the map card.
   The summary line is a simple horizontal strip; the profile sits
   below it and shares its width. */
.map-gpx-summary {
    display: flex;
    flex-wrap: wrap;
    /* center (not baseline) so the strip looks visually centred at a
       glance - baseline alignment lands the text a hair above optical
       centre because the baseline sits below the line-box midpoint. */
    align-items: center;
    gap: 0.25rem 1.1rem;
    padding: 0.45rem 0.7rem;
    background: #fff;
    /* Warm sepia-toned mid-gray rather than the site's cooler --muted,
       so the strip sits in the same family as the parchment page and
       brick-red accent rather than reading as a cooler slate. */
    color: #6a5e52;
    font-size: 0.75rem;
    line-height: 1.45;
}
/* Dark sepia rather than `var(--fg)`: --fg is technically warm
   (#1c1a17) but at near-black brightness it's visually indistinguishable
   from pure black, so the warmth never reads. #3a2e22 is light enough
   that the brown tone is visible while still ~11:1 contrast on white. */
.map-gpx-summary .map-gpx-name {
    font-weight: 600;
    color: #3a2e22;
    margin-right: 0.2rem;
}
.map-gpx-summary .map-gpx-stat {
    white-space: nowrap;
}
.map-gpx-summary .map-gpx-stat strong {
    font-weight: 600;
    color: #3a2e22;
}
/* Same warm sepia mid-tone as the strip's base colour, so the labels
   sit visibly lighter than the bold stat values without falling back
   to the site's cooler --muted slate. */
.map-gpx-summary .map-gpx-stat-label {
    color: #6a5e52;
}

.map-gpx-profile {
    position: relative;
    background: #efe7d6;
    padding: 0;
    /* Clip the inner SVG so its corners follow the container's
       bottom-rounded card (matches the bottom-right map corner that
       carries the OS attribution strip). Without this, the SVG paints
       all the way to the sharp edge of its bounding box. */
    overflow: hidden;
}
.map-gpx-summary + .map-gpx-profile {
    /* Visual continuation of the summary strip - drop the top
       padding so the chart sits flush against it. */
    padding-top: 0;
    border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.map-gpx-profile-svg {
    display: block;
    width: 100%;
    /* Chart + axis band. The chart fills the top ~85% (no time)
       or ~78% (with time); the bottom carries the distance ticks
       and, when present, a per-tick time-into-ride label. We bump
       the rendered height when the GPX is timed so the mountains
       stay the same screen size in either case. */
    height: 96px;
    cursor: crosshair;
    touch-action: none;
}
.map-gpx-profile-timed .map-gpx-profile-svg {
    height: 104px;
}
.map-gpx-profile-tick {
    stroke: rgba(0, 0, 0, 0.4);
    stroke-width: 0.6;
    vector-effect: non-scaling-stroke;
}
.map-gpx-profile-tick-label {
    fill: rgba(26, 26, 26, 0.75);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    font-size: 7px;
    pointer-events: none;
}
.map-gpx-profile-tick-time {
    fill: rgba(26, 26, 26, 0.55);
    font-size: 6.5px;
}
.map-gpx-profile-axis-bg {
    fill: #fff;
}
.map-gpx-profile-fill {
    fill: rgba(138, 46, 33, 0.18);
    stroke: none;
}
.map-gpx-profile-line {
    fill: none;
    stroke: #8a2e21;
    stroke-width: 1.2;
    vector-effect: non-scaling-stroke;
}
.map-gpx-profile-cross {
    stroke: #1a1a1a;
    stroke-width: 1;
    vector-effect: non-scaling-stroke;
    opacity: 0.55;
    pointer-events: none;
}
.map-gpx-profile-dot {
    fill: #fff;
    stroke: #1a1a1a;
    stroke-width: 1.2;
    vector-effect: non-scaling-stroke;
    pointer-events: none;
}
.map-gpx-profile-readout {
    /* Reserved for a future numeric overlay; the summary strip
       already shows the at-cursor distance/elevation, so this
       stays empty by default and keeps no vertical space. */
    display: none;
}

/* Per-page created/updated dates. Rendered inside the global site-footer,
   on the same line as the Colophon link, pushed to the right. Inherits the
   site-footer's typography (body serif, 0.9rem, --muted) so the dates and
   the Colophon link sit in the same register. */
/* Three-column grid so the middle item (page-views counter) sits at the
   geometric centre of the row regardless of the widths of the Colophon
   link and the date string. With `space-between` flex, an asymmetric
   pair of side items pushes the middle off-centre; with grid the centre
   column is anchored to W/2. Each child is pinned to an explicit
   column so the date stays right-aligned even when `.page-views` is
   `hidden` (display: none) and drops out of the auto-flow - otherwise
   the remaining two items would pack into columns 1 and 2 and the
   date would render in the middle. */
.site-footer .footer-bottom {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: baseline;
    gap: 1.5rem;
}

.site-footer .footer-bottom > .footer-meta {
    grid-column: 1;
    justify-self: start;
}

.site-footer .footer-bottom > .page-views {
    grid-column: 2;
    justify-self: center;
}

.site-footer .footer-bottom > .page-dates {
    grid-column: 3;
    justify-self: end;
}

.site-footer .footer-top {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 1.5rem;
}

.site-footer .footer-top > * {
    min-width: 0;
}

.site-footer .header-credit {
    margin: 0;
    text-align: right;
    flex-shrink: 0;
}

.site-footer .page-dates {
    margin: 0.15rem 0;
    text-align: right;
}

/* Old-school odometer-style page-view counter. Sits in the centre
   column of the footer-bottom grid when present; `hidden` (display:
   none) when not, in which case the auto column collapses to zero and
   the row keeps its original Colophon-left / dates-right shape. */
.site-footer .page-views {
    margin: 0.15rem 0;
}

.visit-counter-link {
    text-decoration: none;
    color: inherit;
    display: inline-block;
    line-height: 0;
}

.visit-counter-link:focus-visible {
    outline: none;
}

.visit-counter {
    display: inline-flex;
    align-items: center;
    background: var(--bg);
    color: inherit;
    border: 1px solid #1e3a5f;
    border-radius: 6px;
    /* `overflow: hidden` clips the inner digit-cell `border-right`s so
       they don't poke past the rounded corners of the outer box. */
    overflow: hidden;
    padding: 1px 0;
    font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
    font-weight: 600;
    font-size: 0.8rem;
    letter-spacing: 0.04em;
    line-height: 1;
    vertical-align: middle;
}

.visit-counter-digit {
    display: inline-block;
    min-width: 0.7em;
    padding: 2px 5px;
    text-align: center;
    border-right: 1px solid #1e3a5f;
}

.visit-counter-digit:last-child {
    border-right: 0;
}

/* Share popover. A single "Share" button at the foot of <main>,
   collapsing the brand marks (Facebook, X, Bluesky, ...) into a menu
   that only appears on click - the keeper prefers them off the page at
   rest. Lives above the site footer on every content page that hasn't
   opted out via `noindex` or `share: false`. Uses native <details>, so
   the toggle keeps keyboard / ARIA semantics for free and opens under
   JS-off; a small script adds click-outside-to-close and Esc-handling
   on top. */
/* Sits within the prose column (same `--measure` the .footnotes block
   honours), so the short ornamental rule and the button line up with
   the page text rather than running edge-to-edge. The rule itself is
   drawn as a ::before in the same shape `.page .footnotes hr` uses -
   4rem wide, left-anchored, var(--rule) colour. */
.page-actions {
    margin: 1.25rem 0 0;
    max-width: var(--measure);
    color: var(--muted);
    font-size: 0.85rem;
}

.page-actions::before {
    content: "";
    display: block;
    border-top: 1px solid var(--rule);
    width: 4rem;
    margin: 0 0 1.25rem;
}

.page-actions-menu {
    position: relative;
    display: inline-block;
}

/* Hide the default <summary> disclosure triangle. The icon + label
   carries the affordance; the triangle would clash with the brand-mark
   icons inside the popover. */
.page-actions-button {
    list-style: none;
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.25rem 0.6rem;
    border: 1px solid var(--rule);
    border-radius: 0.3rem;
    background: transparent;
    color: var(--muted);
    cursor: pointer;
    user-select: none;
    transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}

.page-actions-button::-webkit-details-marker {
    display: none;
}

.page-actions-button:hover,
.page-actions-button:focus-visible,
.page-actions-menu[open] .page-actions-button {
    color: var(--accent);
    border-color: var(--accent);
    background: var(--bg);
    outline: none;
}

.page-actions-button svg {
    display: block;
    flex-shrink: 0;
}

/* Popover sits above the button, left-aligned with it. Absolute so
   it doesn't reflow the page when it opens. */
.page-actions-popover {
    position: absolute;
    bottom: calc(100% + 0.4rem);
    left: 0;
    min-width: 11rem;
    padding: 0.35rem;
    background: var(--bg);
    border: 1px solid var(--rule);
    border-radius: 0.4rem;
    box-shadow: 0 4px 14px rgba(28, 26, 23, 0.08);
    z-index: 10;
}

.page-actions-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
}

.page-actions-link {
    display: flex;
    align-items: center;
    gap: 0.55rem;
    padding: 0.35rem 0.55rem;
    border-radius: 0.3rem;
    color: var(--muted);
    text-decoration: none;
    background: transparent;
    border: 1px solid transparent;
    width: 100%;
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: color 0.15s ease, background 0.15s ease;
}

.page-actions-link:hover,
.page-actions-link:focus-visible,
.page-actions-link:active {
    color: var(--accent);
    background: var(--rule);
    outline: none;
}

/* Two-word labels like "Copy link" wrap inside the narrow popover on
   phones, leaving an item visibly taller than its neighbours. The
   popover already widens itself to fit, so keep the label on one line. */
.page-actions-name {
    white-space: nowrap;
}

.page-actions-link svg {
    display: block;
    flex-shrink: 0;
}

.page-actions-copy.is-copied {
    color: var(--accent);
}

@media (max-width: 30rem) {
    /* Slightly wider popover on narrow viewports - just enough that
       the destination labels don't wrap. Stays left-anchored. */
    .page-actions-popover {
        min-width: min(14rem, 100%);
    }
}

/* Graphviz diagrams. Authored as a fenced ```graphviz block in markdown,
   rendered to inline SVG client-side by /static/vendor/viz-standalone.js.
   The figure wrapper opts out of the prose measure so wider trees aren't
   crushed; the SVG itself scales down on narrow viewports. */
.page > figure.graphviz {
    margin: 1rem 0;
    max-width: none;
}

/* Viewport wrapper around the rendered SVG. The SVG is scaled down to the
   column width when wider; for full-size inspection use the full-screen
   pan/zoom dialog. */
.page figure.graphviz .graphviz-viewport {
    width: 100%;
    overflow: hidden;
}

.page figure.graphviz svg {
    display: block;
    max-width: 100%;
    height: auto;
}

/* Override Graphviz's hard-coded Helvetica font-family on label text so
   diagrams use the site's serif stack. Node boxes are auto-sized by
   Graphviz against its own metrics, so labels in a slightly wider serif
   may sit closer to the edges; tighten via DOT fontsize if it bothers.
   Both the in-page diagram and the cloned SVG inside the full-screen
   pan/zoom dialog get the same treatment - the dialog SVG is moved out
   of `.page figure.graphviz` so it needs its own selector. */
.page figure.graphviz svg text,
.graphviz-fullscreen-dialog .graphviz-fs-content svg text {
    font-family: "IM Fell English", "EB Garamond", Georgia,
        "Iowan Old Style", "Palatino Linotype", serif;
}

/* Style only the name line of a linked node as a hyperlink: accent fill
   plus underline. The rest of the label (dates, marriage, occupation,
   alias surname) stays in the link's click target but renders as
   plain attached facts in the default text colour. The `.name-link`
   class is added in JS by `tagLinkedNames` after render: the first
   <text> in each linked node's group gets the class, except on
   chieftain HTML-table labels where the badge cell's Roman numeral is
   the first text and the name is second. Same selectors duplicated
   for the full-screen dialog SVG, which sits outside .page.

   The `:not([fill])` exclusion on the fill rule is load-bearing: nodes
   with explicit fontcolor (e.g. dimmed back-link cards in muted
   parchment-grey) emit text with a `fill` attribute already set, and a
   blanket `fill: var(--accent)` would beat the presentation attribute.
   Skipping any text that already carries a `fill` attribute preserves
   those colours - the name in a back-link card stays grey-on-parchment
   but still picks up the underline below. */
.page figure.graphviz svg a text.name-link:not([fill]),
.graphviz-fullscreen-dialog .graphviz-fs-content svg a text.name-link:not([fill]) {
    fill: var(--accent);
}

.page figure.graphviz svg a text.name-link,
.graphviz-fullscreen-dialog .graphviz-fs-content svg a text.name-link {
    text-decoration: underline;
}

/* The "(details withheld)" marker line emitted below the name on any
   node whose record carries `living: true`. Tagged by `tagLivingMarker`
   in the post-render JS. Muted dark grey reads as a quiet attached fact
   - distinct from a missing-data node (which has nothing in its place)
   without the warning-flag effect of a coloured node fill. */
.page figure.graphviz svg text.living-marker,
.graphviz-fullscreen-dialog .graphviz-fs-content svg text.living-marker {
    fill: #5a5a5a;
}

/* The "(no recorded issue)" marker line emitted below the dates on any
   node whose record carries `endOfLine: true`. Tagged by
   `tagEndOfLineMarker` in the post-render JS. Same muted-grey styling
   as the living marker - kept as a separate class so the keeper can
   restyle the two independently if ever desired. */
.page figure.graphviz svg text.endofline-marker,
.graphviz-fullscreen-dialog .graphviz-fs-content svg text.endofline-marker {
    fill: #5a5a5a;
}

.page pre.graphviz-error {
    border-left: 3px solid var(--accent);
}

.page p.graphviz-error-msg {
    color: var(--accent);
    font-size: 0.85rem;
    font-style: italic;
}

/* Loading placeholder shown while the WASM library is fetched and the
   diagram is rendered. Replaces the original <pre> in the DOM, so no DOT
   source is visible during the load window. */
.page > .graphviz-loading {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin: 1.5rem 0;
    padding: 1.25rem 0;
    color: var(--muted);
    font-style: italic;
    font-size: 0.9rem;
}

.graphviz-spinner {
    display: inline-block;
    width: 1.1rem;
    height: 1.1rem;
    border: 2px solid var(--rule);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: graphviz-spin 0.8s linear infinite;
    flex: 0 0 auto;
}

@keyframes graphviz-spin {
    to {
        transform: rotate(360deg);
    }
}

@media (prefers-reduced-motion: reduce) {
    .graphviz-spinner {
        animation: none;
    }
}

/* "View source" toggle below each rendered diagram, plus the modal that
   shows the highlighted DOT. The toggle is a quiet text-link affordance;
   the dialog is styled to fit the warm cream palette. */
.page figure.graphviz .graphviz-actions {
    margin-top: 0.4rem;
    font-size: 0.85rem;
    display: flex;
    flex-wrap: wrap;
    gap: 0 1.25rem;
    row-gap: 0.25rem;
}

.graphviz-source-toggle,
.graphviz-fullscreen-toggle {
    background: none;
    border: none;
    padding: 0;
    font: inherit;
    color: var(--muted);
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 0.2em;
}

.graphviz-source-toggle:hover,
.graphviz-fullscreen-toggle:hover {
    color: var(--accent);
}

.graphviz-source-close {
    background: transparent;
    color: var(--muted, #6a665d);
    border: none;
    padding: 0;
    width: 1.75rem;
    height: 1.75rem;
    font: inherit;
    font-size: 1rem;
    line-height: 1;
    border-radius: 0.25rem;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

.graphviz-source-close:hover,
.graphviz-source-close:focus-visible {
    background: rgba(138, 46, 33, 0.1);
    color: var(--accent, #8a2e21);
    outline: none;
}

.graphviz-source-dialog {
    padding: 0;
    border: none;
    background: var(--bg);
    color: var(--fg);
    border-radius: 0.5rem;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
    width: min(90vw, 50rem);
    max-height: 85vh;
    overflow: hidden;
}

.graphviz-source-dialog[open] {
    display: flex;
    flex-direction: column;
}

html:has(.graphviz-source-dialog[open]),
body:has(.graphviz-source-dialog[open]),
html:has(.graphviz-fullscreen-dialog[open]),
body:has(.graphviz-fullscreen-dialog[open]) {
    overflow: hidden;
}

html:has(.graphviz-source-dialog[open]),
html:has(.graphviz-fullscreen-dialog[open]) {
    background: #969491;
}

.graphviz-source-dialog::backdrop {
    background: rgba(28, 26, 23, 0.45);
}

.graphviz-source-dialog pre {
    margin: 0;
    padding: 0;
    border-radius: 0;
    /* Faint horizontal rules across the whole popup, layered over the
       page paper colour. The rules are also re-applied on each
       .src-line::before so they cross the gutter as ruled-paper lines
       do. Pitch matches the line-height (font-size × 1.45) so each
       rule sits flush under one logical line of text. `local` so the
       rules scroll with the content rather than staying stuck when the
       source overflows. */
    background:
        repeating-linear-gradient(
            to bottom,
            transparent 0,
            transparent calc(1.2em - 1px),
            rgba(107, 103, 96, 0.09) calc(1.2em - 1px),
            rgba(107, 103, 96, 0.09) 1.2em,
            transparent 1.2em,
            transparent 1.45em
        ),
        var(--bg);
    background-attachment: local, local;
    overflow: auto;
    flex: 1 1 auto;
    min-height: 0;
    font-size: 0.8rem;
    line-height: 1.45;
    /* `pre`'s default `white-space: pre` would render the `\n`s sitting
       between block-level .src-line wrappers as visible blank lines.
       Reset to `normal` here; each .src-line restores `pre-wrap` so
       leading indentation and inner whitespace inside a source line are
       still preserved. */
    white-space: normal;
}

.graphviz-source-dialog pre code {
    counter-reset: srcline;
    display: block;
}

.graphviz-source-dialog .src-line {
    counter-increment: srcline;
    display: block;
    position: relative;
    padding: 0 0 0 3.2rem;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    min-height: 1.45em;
}

/* Gutter: solid cream background with the same horizontal rule pattern
   layered on top so the ruling crosses gutter and content uniformly,
   plus a real border-right for the divider line (more reliable than a
   gradient stop, which kept rendering invisibly). box-sizing: border-box
   keeps the gutter's overall width including the border at 3rem flat. */
.graphviz-source-dialog .src-line::before {
    content: counter(srcline);
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 3rem;
    padding: 0 0.3rem 0 0.5rem;
    box-sizing: border-box;
    text-align: right;
    color: rgba(107, 103, 96, 0.55);
    background:
        repeating-linear-gradient(
            to bottom,
            transparent 0,
            transparent calc(1.2em - 1px),
            rgba(107, 103, 96, 0.09) calc(1.2em - 1px),
            rgba(107, 103, 96, 0.09) 1.2em,
            transparent 1.2em,
            transparent 1.45em
        ),
        #efe7d6;
    border-right: 1px solid var(--rule);
    user-select: none;
    -webkit-user-select: none;
}

.graphviz-source-bar {
    margin: 0;
    padding: 0.55rem 0.75rem 0.55rem 1rem;
    border-bottom: 1px solid var(--rule);
    background: #f4ecdc;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    flex: 0 0 auto;
}

.graphviz-source-caption {
    font-size: 0.95rem;
    color: var(--fg);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
}

.graphviz-source-bar form {
    margin: 0;
    flex: 0 0 auto;
}

/* Full-screen pan/zoom dialog. Reuses the source-dialog header bar so the
   close button and caption look consistent with the dot-source view. The
   stage area below the bar is the pan/zoom canvas. */
.graphviz-fullscreen-dialog {
    padding: 0;
    border: none;
    background: var(--bg);
    color: var(--fg);
    border-radius: 0.5rem;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
    width: 95vw;
    height: 92vh;
    max-width: none;
    max-height: none;
    overflow: hidden;
}

.graphviz-fullscreen-dialog[open] {
    display: flex;
    flex-direction: column;
}

.graphviz-fullscreen-dialog::backdrop {
    background: rgba(28, 26, 23, 0.45);
}

.graphviz-fs-stage {
    flex: 1 1 auto;
    min-height: 0;
    position: relative;
    overflow: hidden;
    background: var(--bg);
    cursor: grab;
    touch-action: none;
    outline: none;
    user-select: none;
    -webkit-user-select: none;
}

.graphviz-fs-stage:active {
    cursor: grabbing;
}

.graphviz-fs-content {
    position: absolute;
    top: 0;
    left: 0;
    transform-origin: 0 0;
    will-change: transform;
}

.graphviz-fs-content svg {
    display: block;
}

/* Bird's-eye view in the bottom-right of the fullscreen stage. Mirrors
   the OpenSeadragon navigator's vocabulary (translucent card, accent-
   coloured viewport rectangle) so the two viewer types feel related.
   Hidden by default; graphviz.js shows it only when the diagram is
   larger than the stage at 1x, i.e. when there's actually somewhere to
   navigate to. */
.graphviz-fs-minimap {
    position: absolute;
    right: 0.75rem;
    bottom: 0.75rem;
    /* Parchment-toned card so the minimap sits in the same family as
       the document-preview cards and the figure.photo frames elsewhere
       on the site, rather than reading as a generic translucent-white
       panel. */
    background: var(--bg, #faf8f4);
    border: 2px solid var(--rule, #e6dfd3);
    border-radius: 0.25rem;
    overflow: hidden;
    /* Soft warm-toned drop shadow rather than a flat rgba(0,0,0,...)
       so the card lifts off the stage without introducing a cool grey
       tone against the parchment palette. */
    box-shadow:
        0 1px 2px rgba(58, 46, 34, 0.08),
        0 4px 12px rgba(58, 46, 34, 0.16);
    cursor: pointer;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    z-index: 10;
    /* Keep the parchment fill inside the border so the corner doesn't
       show the same fuzzy halo that the doc-preview maps had before
       moving to a 2px border. */
    background-clip: padding-box;
    display: none;
}

.graphviz-fs-minimap-inner {
    position: absolute;
    inset: 0;
    /* The thumbnail is for display only - pointer events go to the
       outer minimap so a drag works uniformly across the whole card,
       including over the viewport rectangle that sits on top. */
    pointer-events: none;
}

.graphviz-fs-minimap-inner svg {
    display: block;
    width: 100%;
    height: 100%;
}

.graphviz-fs-minimap-rect {
    position: absolute;
    /* Slightly thinner border (1.5px) and a touch more fill alpha than
       the original 2px / 8% combo - the thumbnail underneath is busy
       and a heavy rect competes with it for attention. The brick-red
       still reads clearly as a viewport indicator against the
       parchment-tinted card. */
    border: 1.5px solid var(--accent, #8a2e21);
    background: rgba(138, 46, 33, 0.12);
    pointer-events: none;
    box-sizing: border-box;
    border-radius: 0.2rem;
}

.graphviz-fs-controls {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    margin: 0 0.5rem 0 auto;
    flex: 0 0 auto;
}

.graphviz-fs-btn {
    background: transparent;
    color: var(--muted);
    border: 1px solid var(--rule);
    padding: 0.15rem 0.55rem;
    font: inherit;
    font-size: 0.85rem;
    line-height: 1.4;
    border-radius: 0.25rem;
    cursor: pointer;
    min-width: 1.75rem;
}

.graphviz-fs-btn:hover,
.graphviz-fs-btn:focus-visible {
    background: rgba(138, 46, 33, 0.08);
    color: var(--accent);
    border-color: var(--accent);
    outline: none;
}

.graphviz-fs-btn:disabled {
    opacity: 0.45;
    cursor: default;
    background: transparent;
    color: var(--muted);
    border-color: var(--rule);
}

/* Find-in-tree input + match counter, slotted into the controls strip
   alongside the prev/next step buttons. */
.graphviz-fs-search {
    background: var(--bg);
    color: var(--fg);
    border: 1px solid var(--rule);
    border-radius: 0.25rem;
    padding: 0.15rem 0.45rem;
    font: inherit;
    font-size: 0.85rem;
    line-height: 1.4;
    width: 9rem;
    min-width: 0;
}

.graphviz-fs-search:focus-visible {
    border-color: var(--accent);
    outline: none;
}

.graphviz-fs-search-count {
    color: var(--muted);
    font-size: 0.8rem;
    line-height: 1.4;
    min-width: 3.5rem;
    text-align: center;
}

/* Match highlighting on the cloned SVG inside the fullscreen stage. The
   class lives on .graphviz-fs-content so it never leaks to the inline
   diagram on the page. Dim every non-match; matches stay at full opacity
   with no extra glow (the dim alone is enough to pick them out). The
   current step gets a thicker accent-coloured border on its outer shape
   so the eye lands on it once the stage centres there. */
.graphviz-fs-content.gv-fs-searching g.node {
    opacity: 0.22;
    transition: opacity 120ms ease;
}

.graphviz-fs-content.gv-fs-searching g.node.gv-fs-match {
    opacity: 1;
}

/* Two selector pairs: one for plain nodes (shape is a direct child of
   g.node), one for nodes with a URL — Graphviz wraps the shape in
   <g><a>...</a></g> for those, so the direct-child selector misses them. */
.graphviz-fs-content.gv-fs-searching g.node.gv-fs-current > polygon:first-of-type,
.graphviz-fs-content.gv-fs-searching g.node.gv-fs-current > path:first-of-type,
.graphviz-fs-content.gv-fs-searching g.node.gv-fs-current a > polygon:first-of-type,
.graphviz-fs-content.gv-fs-searching g.node.gv-fs-current a > path:first-of-type {
    stroke: var(--accent);
    stroke-width: 2;
}

/* Alias-surname nodes have a cool-grey fill where burgundy doesn't pop;
   give them a clear red focus border instead so the current match still
   lands the eye. Matches the polygon's `fill` attribute carried straight
   from the dot source. */
.graphviz-fs-content.gv-fs-searching g.node.gv-fs-current > polygon[fill="#d4d4d4" i]:first-of-type {
    stroke: #d62828;
}

/* Minimal DOT/Graphviz syntax tones for fenced source blocks. Visible only
   during the load window before the WASM renderer replaces the <pre> with
   inline SVG, and as the graceful-degradation view if rendering is off. */
.page pre code .dot-kw {
    color: var(--accent);
    font-weight: 600;
}

.page pre code .dot-str,
.page pre code .dot-num {
    color: #6a4232;
}

.page pre code .dot-com {
    color: var(--muted);
    font-style: italic;
}

/* Notebook listing on /notebook/. Each li is one entry: title link, then a
   small italic date, then an optional description paragraph. Hairline rule
   between entries (none after the last) keeps the rhythm without boxing
   anything in. */
.page > ul.notebook-list {
    list-style: none;
    padding: 0;
    margin: 1.5rem 0 0;
    max-width: var(--measure);
}

.notebook-list > li {
    margin: 0;
    padding: 1rem 0 1.25rem;
    border-bottom: 1px solid var(--rule);
}

.notebook-list > li:first-child {
    padding-top: 0;
}

.notebook-list > li:last-child {
    border-bottom: none;
}

.notebook-list > li > a {
    font-size: 1.15rem;
    text-decoration: none;
}

.notebook-list > li > a:hover {
    text-decoration: underline;
}

.notebook-list > li > time {
    display: block;
    color: var(--muted);
    font-size: 0.85rem;
    font-style: italic;
    margin: 0.2rem 0 0.4rem;
}

.notebook-list > li > p {
    margin: 0;
}

.page > p.notebook-empty {
    color: var(--muted);
    margin-top: 1.5rem;
}

/* Tags. Three contexts share the .tag chip: page-header chips on a single
   notebook entry, per-entry chips inside the notebook listing, and the
   all-tags index. Chips are small, muted-outline by default, accent on
   hover; .is-current is the chip for the page you're already viewing. */
.tag-list {
    list-style: none;
    padding: 0;
    margin: 0.5rem 0 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}

.page-title-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 0.5rem 0.75rem;
    margin: 0 0 1rem;
}

.page-title-row h1 {
    margin: 0;
}

.page-title-row .page-tags {
    margin: 0;
}

.notebook-list .tag-list {
    margin-top: 0.4rem;
}

a.tag,
span.tag {
    display: inline-block;
    font-size: 0.78rem;
    line-height: 1.2;
    padding: 0.15rem 0.5rem;
    border: 1px solid var(--rule);
    border-radius: 999px;
    color: var(--muted);
    text-decoration: none;
    background: var(--bg);
}

a.tag:hover {
    color: var(--accent);
    border-color: var(--accent);
}

a.tag.is-current {
    color: var(--accent);
    border-color: var(--accent);
}

/* All-tags index on /notebook/tags/. Same chip, plus a count beside it. */
.page > ul.tag-index {
    list-style: none;
    padding: 0;
    margin: 1.5rem 0 0;
    max-width: var(--measure);
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem 0.75rem;
}

.tag-index li {
    margin: 0;
    display: inline-flex;
    align-items: baseline;
    gap: 0.3rem;
}

.tag-count {
    color: var(--muted);
    font-size: 0.78rem;
}

/* Small navigation line above the notebook listing and below the per-tag
   listings. Italic muted, sits in the same rhythm as the listing. */
.page > p.notebook-tags-link {
    color: var(--muted);
    font-size: 0.9rem;
    font-style: italic;
    margin: 1.25rem 0 0;
}

.page > p.notebook-tags-link a {
    color: var(--muted);
}

.page > p.notebook-tags-link a:hover {
    color: var(--accent);
}

/* Timeline events listing on /timeline. The TimelineJS iframe sits at the
   top of the page; this list sits below it as a server-rendered, chronologically
   sorted transcript. It's the SEO surface for the timeline data and the
   non-fiddly mobile fallback for the JS control. Each <li> is one event:
   small italic date, headline, then the body text with its inline HTML
   preserved. Hairline rule between entries, no boxes - same rhythm as the
   notebook listing. */
.timeline-list-section {
    margin-top: 3rem;
    max-width: var(--measure);
}

.timeline-list-section > h2 {
    margin: 0 0 0.25rem;
}

.timeline-list-intro {
    color: var(--muted);
    font-style: italic;
    margin: 0 0 0.5rem;
}

.page > .timeline-list-section ol.timeline-list {
    list-style: none;
    padding: 0;
    margin: 1rem 0 0;
    counter-reset: timeline-event;
}

.timeline-list li {
    margin: 0;
    padding: 1.25rem 0 1.5rem;
    border-bottom: 1px solid var(--rule);
    counter-increment: timeline-event;
}

.timeline-list li:first-child {
    padding-top: 0.25rem;
}

.timeline-list li:last-child {
    border-bottom: none;
}

.timeline-list-date {
    display: block;
    color: var(--muted);
    font-size: 0.85rem;
    font-style: italic;
    letter-spacing: 0.02em;
    margin: 0 0 0.15rem;
}

.timeline-list-headline {
    margin: 0 0 0.4rem;
    font-size: 1.2rem;
    line-height: 1.3;
}

/* TOC links anchor on the event headline; the global `.page h3` rule
   sets scroll-margin-top: 5rem (intended for prose pages with a header
   to clear), which lands the click well below the date and leaves a
   gap above it. Override here so the scroll target sits just above
   the date - covers the li's top padding plus the date line plus a
   hair of breathing room. The `.page` qualifier gives this rule the
   specificity needed to outrank `.page h3`. */
.page .timeline-list .timeline-list-headline {
    scroll-margin-top: 2.5rem;
}

.timeline-list-text p {
    margin: 0.5rem 0 0;
}

.timeline-list-text p:first-child {
    margin-top: 0;
}

.timeline-list-text a {
    color: var(--accent);
}

/* Each event's media is rendered as the same `figure.photo` card used in
   prose pages, inheriting the rounded frame and the warm-cream caption
   band - but the timeline-list flavour fits the frame to the image rather
   than stretching the image to a fixed prose measure. Width and height
   are both capped at the prose measure (42rem) so a landscape figure
   tops out at roughly 42rem × 28rem and a 2:3 portrait at roughly 28rem ×
   42rem - the same overall area, transposed. Smaller-natural-size images
   stay at their natural size; the figure shrinks to fit so there's no
   cream gap to the right of a narrow image. Left-aligned in the prose
   column. The video variant carries a faint play badge centred on the
   YouTube thumbnail. */
/* The figure holds the image only - caption is rendered as a separate
   sibling element below. That separation means the figure's `fit-content`
   sizing measures the image alone (whose max-content is its natural
   width) rather than the long caption text, so the rounded frame really
   does hug the image at its rendered size. Width and height are both
   capped at the prose measure (42rem) so a landscape figure tops out at
   roughly 42rem × 28rem and a 2:3 portrait at roughly 28rem × 42rem -
   same overall area, transposed. Selectors are anchored on `.page` to
   outrank the front-page rule `.page figure.photo img { width: 100% }`
   which would otherwise force `width: 100%` and distort portraits when
   combined with `max-height`. */
.page .timeline-list .timeline-list-figure {
    margin: 0.75rem 0 0;
    width: fit-content;
    max-width: 100%;
}

.page .timeline-list .timeline-list-figure img {
    display: block;
    width: auto;
    height: auto;
    max-width: 100%;
    max-height: 42rem;
}

/* Caption block - its own rounded cream pill, sits beneath the image
   card with a small gap so the two read as a stacked pair rather than a
   single bordered card. Caption flows at the prose measure regardless
   of image width, since it's no longer tied to the figure's frame. */
.page .timeline-list .timeline-list-caption {
    margin: 0.2rem 0 0;
    max-width: var(--measure);
    background: #efe7d6;
    color: var(--muted);
    font-size: 0.75rem;
    line-height: 1.45;
    padding: 0.5rem 0.75rem 0.4rem;
    border-radius: 0.5rem;
}

/* YouTube videos are always 16:9 landscape, so the video card overrides
   the per-image fit-content sizing and spans the full prose measure -
   matching the caption pill below it. The img is forced to a 16:9 cell
   with object-fit: cover so the high-resolution maxresdefault.jpg fills
   the cell cleanly, and the older hqdefault.jpg fallback gets its
   letterbox bars cropped rather than displayed. */
.page .timeline-list .timeline-list-video {
    width: 100%;
    max-width: var(--measure);
}

.page .timeline-list .timeline-list-video img {
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 9;
    object-fit: cover;
    max-height: none;
    border-radius: 0.75rem;
}

.timeline-list .timeline-list-video a {
    position: relative;
    display: block;
    line-height: 0;
}

.timeline-list .timeline-list-video a::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 3rem;
    height: 3rem;
    border-radius: 50%;
    background: rgba(28, 26, 23, 0.65)
        url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><polygon points='9,7 18,12 9,17' fill='%23faf8f4'/></svg>")
        center / 1.6rem 1.6rem no-repeat;
    pointer-events: none;
}

/* Web Links cards. Each entry on /links is an <article class="link-card">
   with the link + description on the left and a screenshot thumbnail on
   the right, dressed as a small macOS-style browser window so the
   screenshots read as previews rather than inline figures. Thumbnails are
   generated by tools/link-thumbs and live under /assets/images/links/.
   The card opts out of the prose measure so the thumb column is added to
   the text width rather than carved out of it; on narrow viewports the
   layout collapses to stacked thumb-above-text. */
.page > article.link-card {
    display: flex;
    flex-direction: row-reverse;
    align-items: flex-start;
    gap: 1.25rem;
    margin: 1.5rem 0;
    padding-bottom: 1.5rem;
    border-bottom: 1px solid var(--rule);
    max-width: calc(var(--measure) + 13.25rem);
}

.page > article.link-card:last-of-type,
.page > article.link-card:has(+ h2) {
    border-bottom: none;
}

/* Section headings on the links page get the same hairline rule as the
   cards beneath them, so the section breaks read at the same weight as
   the dividers between entries. Width matches the card max-width so the
   rules line up. The second selector handles sections that introduce
   themselves with a short paragraph between the heading and the first
   card; without it the `+ article` adjacent-sibling check would fail
   and the heading would lose its underline. */
.page > h2:has(+ article.link-card),
.page > h2:has(+ p + article.link-card) {
    max-width: calc(var(--measure) + 13.25rem);
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--rule);
}

/* Faux macOS browser frame: rounded outer card, a thin title bar drawn by
   ::before with three traffic-light dots, then the screenshot below at
   square corners so the chrome reads as one piece. All chrome dimensions
   are in cqi (container-inline-size %) so the frame stays in proportion
   to the thumb at any width - the same card looks right both in the
   12rem desktop column and at full width on a narrow viewport. */
.link-card .link-thumb {
    flex: 0 0 12rem;
    display: block;
    line-height: 0;
    /* container-type lets the chrome bar inside ::before size itself in
       cqi (% of this element's width). Properties on .link-thumb itself
       cannot use cqi here - they would resolve against the next ancestor
       with container-type, not this element - so border-radius stays in
       a fixed unit. */
    container-type: inline-size;
    border-radius: 0.4rem;
    border: 1px solid #c8c8c8;
    overflow: hidden;
    background: #ececec;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07);
}

.link-card .link-thumb::before {
    content: "";
    display: block;
    height: 5.5cqi;
    background-color: #ececec;
    background-image:
        radial-gradient(circle at 2.5cqi 50%, #ff5f56 0 1cqi, transparent 1.15cqi),
        radial-gradient(circle at 5.5cqi 50%, #ffbd2e 0 1cqi, transparent 1.15cqi),
        radial-gradient(circle at 8.5cqi 50%, #27c93f 0 1cqi, transparent 1.15cqi);
    background-repeat: no-repeat;
    border-bottom: 1px solid #d6d6d6;
}

.link-card .link-thumb img {
    display: block;
    width: 100%;
    height: auto;
    border-radius: 0;
    border: none;
    background: #efe7d6;
    aspect-ratio: 640 / 400;
    object-fit: cover;
}

/* Empty-state: link-cards.js adds .is-empty to a .link-thumb whose
   img failed to load (the .webp doesn't exist yet at the referenced
   /assets/images/links/<slug>.webp path). Keep the browser-window
   chrome - frame, drop-shadow, traffic-light strip - and only swap
   the cream inner content for plain white, so a missing thumb reads
   as a blank page inside the window rather than a recessed cream
   void. */
.link-card .link-thumb.is-empty img {
    background: #fff;
}

.link-card .link-body {
    flex: 1 1 auto;
    min-width: 0;
}

.link-card .link-body h3 {
    margin: 0 0 0.4rem;
    font-size: 1.2rem;
}

.link-card .link-body h3 a {
    text-decoration: none;
}

.link-card .link-body h3 a:hover {
    text-decoration: underline;
}

.link-card .link-body p {
    margin: 0;
}

@media (max-width: 36rem) {
    .page > article.link-card {
        flex-direction: column;
    }
    .link-card .link-thumb {
        flex: 0 0 auto;
        width: 100%;
    }

    /* Deeply-nested lists (e.g. the genealogy index Pages outline, ~7
       levels) eat the viewport with the browser default ~40px indent
       per level. Tighten nested levels so the hierarchy is still
       legible but the link text has room to wrap. */
    .page ul ul,
    .page ol ol,
    .page ul ol,
    .page ol ul {
        padding-inline-start: 1.1rem;
    }
}

/* Tree-list with expand/collapse toggles. Applied by JS to the <ul>
   that follows the "Pages" heading on the genealogy index. Without
   JS the list renders as a plain bulleted outline (the rules below
   are class-scoped, so they only kick in once the class is set), so
   the no-script fallback is exactly what the markdown produced. The
   markers are unified at every level - a small dot for leaves, a
   rotating triangle for branches - and the depth of nesting is
   conveyed by indent alone. */
.page ul.tree-list,
.page ul.tree-list ul {
    list-style: none;
    padding-inline-start: 0;
    margin: 0;
}

.page ul.tree-list {
    margin: 0.6rem 0 1rem;
}

.page ul.tree-list ul {
    padding-inline-start: 0.5rem;
}

.page ul.tree-list li {
    position: relative;
    padding-left: 1.4rem;
    margin: 0.2rem 0;
}

/* Both the leaf dot and the toggle button are anchored to the same
   centre point (left + 0.6rem, top + 0.7em) so a sibling list with a
   mix of leaves and branches lines its markers up vertically. The dot
   uses translate(-50%,-50%) to centre on the anchor; the button's flex
   centring puts the SVG on the same anchor by way of equal width/
   height around it. */
.page ul.tree-list li::before {
    content: "";
    position: absolute;
    left: 0.6rem;
    top: 0.7em;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--muted);
    transform: translate(-50%, -50%);
}

.page ul.tree-list li.has-children::before {
    display: none;
}

.page ul.tree-list .tree-toggle {
    position: absolute;
    left: 0;
    top: 0;
    width: 1.2rem;
    height: 1.4em;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
    color: var(--muted);
    line-height: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 0.2rem;
}

.page ul.tree-list .tree-toggle:hover,
.page ul.tree-list .tree-toggle:focus-visible {
    color: var(--accent);
}

.page ul.tree-list .tree-toggle svg {
    width: 0.85rem;
    height: 0.85rem;
    display: block;
    transform-origin: 50% 50%;
    transition: transform 0.15s ease;
}

.page ul.tree-list li.is-expanded > .tree-toggle svg {
    transform: rotate(90deg);
}

.page ul.tree-list li.has-children:not(.is-expanded) > ul {
    display: none;
}

.page iframe,
.page .wide {
    display: block;
    width: 100%;
    max-width: none;
}

.page iframe {
    border-radius: 0.75rem;
    overflow: hidden;
    background: #fff;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}

.page video {
    border-radius: 0.75rem;
    overflow: hidden;
    display: block;
}

/* Footnotes. Markdown's footnote extension renders sup/anchor references
   in the body and collects definitions into a <div class="footnotes"> at
   the end, preceded by an <hr> we restyle as a short centred ornamental
   rule. The sup is repositioned with position/top rather than the default
   vertical-align: super, so it doesn't enlarge the line box and shift the
   line down relative to its neighbours. */
.page sup {
    font-size: 0.75em;
    line-height: 0;
    position: relative;
    top: -0.5em;
    vertical-align: baseline;
}

.page sup a.footnote-ref {
    text-decoration: none;
    color: var(--accent);
    padding: 0 0.1em;
}

.page sup a.footnote-ref:hover {
    text-decoration: underline;
}

.page .footnotes {
    font-size: 0.85em;
    color: var(--muted);
    margin-top: 1rem;
}

.page .footnotes hr {
    border: 0;
    border-top: 1px solid var(--rule);
    width: 4rem;
    margin: 1.25rem 0 1.25rem;
}

.page .footnotes ol {
    padding-left: 1.5rem;
}

.page .footnotes li {
    margin-bottom: 0.4rem;
}

/* Hide footnote back-arrows. They produced an asymmetry once Graphviz
   nodes started carrying URL references to footnote anchors - the auto
   back-arrow only points at the prose reference, never at a clicked-from
   node. Books don't paginate back from the foot of a citation; we won't
   either. The forward link from prose `[^label]` to the footnote still
   works; only the return arrow is suppressed. */
.page .footnotes a.footnote-backref {
    display: none;
}

/* Sticky table of contents on wide viewports. Populated client-side from
   h2/h3 headings (footnotes excluded). Hidden until the script flips
   `hidden` off, and hidden again on narrower viewports where the prose
   column would crowd it.

   The TOC is a flex sibling of <main> inside .layout, so it starts in
   normal flow at the top of the prose column (below the header image
   if one is present) and then sticks to top: 1.5rem as the user scrolls. */
.layout {
    display: flex;
    align-items: flex-start;
}

.layout > .page {
    flex: 1 1 auto;
    min-width: 0;
}

.toc {
    position: sticky;
    top: calc(2rem + var(--floating-header-offset, 0px));
    align-self: flex-start;
    flex: 0 0 14rem;
    width: 14rem;
    margin: 2rem 1.5rem 0 0;
    padding: 0.4rem 0 0.4rem 0.9rem;
    border-left: 1px solid var(--rule);
    font-size: 0.85rem;
    line-height: 1.4;
    color: var(--muted);
    /* Keep the TOC on its own compositing layer so the text raster is
       identical whether sticky positioning is active or not - otherwise
       lines re-snap to the device pixel grid as it transitions and the
       spacing appears to shift by a fraction of a pixel. */
    transform: translateZ(0);
}

.toc[hidden] {
    display: none;
}

/* The header row holds the "On this page" label and the collapse
   toggle. Both children are inserted by the layout's TOC script - the
   static markup in _layout.html only carries the bare label and ol;
   the script wraps them so the bare-bones HTML still reads correctly
   without JS. */
.toc-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.4rem;
    /* Pin the header to the same height as the toggle button so the
       chevron sits at the same vertical position whether the label is
       visible (expanded) or hidden (collapsed). */
    min-height: 1.1rem;
}

.toc-label {
    font-style: italic;
    font-size: 0.8rem;
    color: var(--muted);
    letter-spacing: 0.02em;
}

.toc-toggle {
    background: none;
    border: none;
    padding: 0;
    margin: 0;
    cursor: pointer;
    color: var(--muted);
    /* Fixed-size square keeps the chevron pixel-locked at the same
       position whether the rail is expanded or collapsed. */
    flex: 0 0 1.1rem;
    width: 1.1rem;
    height: 1.1rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
}

.toc-toggle svg {
    width: 0.9rem;
    height: 0.9rem;
    display: block;
    transform-origin: 50% 50%;
    transition: transform 0.2s ease;
}

.toc.collapsed .toc-toggle svg {
    transform: rotate(180deg);
}

.toc-toggle:hover {
    color: var(--accent);
}

/* Collapsed state: TOC shrinks to a slim rail with just the toggle
   visible at the top. Click expands. State is persisted across pages
   in localStorage. */
.toc.collapsed {
    flex: 0 0 1.5rem;
    width: 1.5rem;
    padding: 0.4rem 0 0.4rem 0.25rem;
}

.toc.collapsed > .toc-header {
    margin-bottom: 0;
    /* Keep the chevron flush with the right edge so its on-screen
       position is identical to the expanded state - the toggle stays
       put under the cursor as the rest of the rail collapses around
       it. */
    justify-content: flex-end;
}

.toc.collapsed .toc-label,
.toc.collapsed > ol {
    display: none;
}

.toc ol {
    list-style: none;
    margin: 0;
    padding: 0;
}

.toc ol ol {
    padding-left: 0.9rem;
}

.toc li {
    margin: 0.25rem 0;
}

.toc-h3,
.toc-h4,
.toc-h5,
.toc-h6 {
    font-size: 0.95em;
}

.toc a {
    color: var(--muted);
    text-decoration: none;
    display: block;
    border-left: 2px solid transparent;
    padding-left: 0.4rem;
    margin-left: -0.4rem;
}

.toc a:hover {
    color: var(--accent);
}

.toc a.current {
    color: var(--accent);
    border-left-color: var(--accent);
}

@media (max-width: 70rem) {
    .toc {
        display: none;
    }
}

.scroll-buttons {
    position: fixed;
    right: 0.4rem;
    bottom: 0.4rem;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    /* Above Leaflet's controls (default 1000), below the sticky
       header (1100) so a map's bottom-right attribution doesn't
       eclipse the scroll-to-top button. */
    z-index: 1050;
}

.scroll-buttons[hidden] {
    display: none;
}

.scroll-btn {
    width: 1.75rem;
    height: 1.75rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: rgba(247, 243, 234, 0.7);
    color: var(--accent);
    border: 1px solid var(--rule);
    border-radius: 999px;
    cursor: pointer;
    padding: 0;
    box-shadow: 0 1px 2px rgba(28, 26, 23, 0.08);
    transition: opacity 0.2s ease, transform 0.2s ease, background-color 0.2s ease;
}

/* Gate hover styling to real pointers - on touch devices :hover
   latches after a tap and the button stays in its filled-accent
   state until something else is tapped, which reads as "stuck on". */
@media (hover: hover) {
    .scroll-btn:hover {
        background: var(--accent);
        color: #faf8f4;
    }
}

.scroll-btn:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

.scroll-btn.is-hidden {
    opacity: 0;
    pointer-events: none;
    transform: scale(0.85);
}

/* Footer bookends the header at a quieter register: the full-
   saturation tartan tile sits beneath a translucent layer of the
   page's own paper colour, so the weave reads like a tartan glimpsed
   through tracing paper - pattern visible, contrast softened. Text
   reverts to the site's normal dark/muted/accent palette against
   the resulting pale ground.

   The tartan is the pre-rotated diagonal asset (a 396 px square
   crop of the orthogonal pattern rotated 45deg, hand-built to tile
   seamlessly - see static/images/tartan-pattern-realistic-diagonal
   .png), painted directly on .site-footer. Previously this was a
   rotated ::before pseudo, but the diagonal tile makes the rotation
   redundant and lets the mobile-touch rubber-band rule below paint
   .site-footer and html with byte-identical viewport-fixed layers,
   so the pattern stays aligned across the footer / rubber-band
   boundary while the user drags. */
.site-footer {
    position: relative;
    isolation: isolate;
    overflow: hidden;
    margin-top: 3rem;
    padding-top: 1.5rem;
    padding-bottom: 1.75rem;
    border-top: 1px solid var(--rule);
    background-color: var(--bg);
    background-image: url("/static/images/tartan-pattern-realistic-diagonal.png");
    background-repeat: repeat;
    background-size: 396px 396px;
    background-position: bottom center;
    /* Ink blue, matching the header brand and nav so the two
       tartan-tinted bookends share the same text colour. */
    color: #1e3a5f;
    font-size: 0.9rem;
}

/* Warm-paper wash on top of the diagonal tartan, matching the
   header's continuous fade (just flipped): faint tartan tinting
   through near-opaque paper at the top where the footer meets the
   prose, easing down to a bolder ribbon of weave at the bottom
   edge. The two bookends now share an identical alpha curve, just
   mirrored - tartan strongest at the outer page edges, quietest
   at the prose side. Tone is a fraction deeper than the prose
   cream so the band reads as a distinct paper panel. */
.site-footer::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(
        to bottom,
        rgba(247, 243, 234, 0.98) 0%,
        rgba(247, 243, 234, 0.96) 20%,
        rgba(247, 243, 234, 0.92) 40%,
        rgba(247, 243, 234, 0.88) 55%,
        rgba(247, 243, 234, 0.82) 70%,
        rgba(247, 243, 234, 0.74) 85%,
        rgba(247, 243, 234, 0.65) 100%
    );
    pointer-events: none;
    z-index: 0;
}

.site-footer > * {
    position: relative;
    z-index: 1;
}

.site-footer p {
    margin: 0.15rem 0;
}

.site-footer .footer-tagline {
    font-style: italic;
}

.site-footer .footer-meta a {
    color: inherit;
    text-decoration: none;
}

.site-footer .footer-meta a:hover {
    text-decoration: underline;
    text-underline-offset: 0.25em;
}

/* Mobile-only TOC. A separate, collapsed-by-default disclosure that sits
   at the top of the prose column on narrow viewports where the desktop
   sticky rail is hidden. It is generated alongside the desktop TOC by
   the layout script (the same headings populate both); CSS picks which
   one is visible at the current viewport width. */
.toc-mobile {
    display: none;
    margin: 0 0 1.25rem;
    border: 1px solid var(--rule);
    border-radius: 0.5rem;
    background: #efe7d6;
    font-size: 0.9rem;
}

.toc-mobile[hidden] {
    display: none !important;
}

.toc-mobile-summary {
    cursor: pointer;
    list-style: none;
    padding: 0.6rem 0.85rem;
    color: var(--muted);
    font-style: italic;
    user-select: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.toc-mobile-summary::-webkit-details-marker {
    display: none;
}

.toc-mobile-summary::after {
    content: "";
    display: inline-block;
    width: 0.55rem;
    height: 0.55rem;
    border-right: 2px solid currentColor;
    border-bottom: 2px solid currentColor;
    transform: rotate(45deg);
    margin-right: 0.2rem;
    transition: transform 0.18s ease;
}

.toc-mobile[open] .toc-mobile-summary::after {
    transform: rotate(-135deg);
    margin-bottom: -0.25rem;
}

.toc-mobile ol {
    list-style: none;
    margin: 0;
    padding: 0 0.85rem 0.7rem;
}

.toc-mobile ol ol {
    padding: 0 0 0 1rem;
}

.toc-mobile li {
    margin: 0.4rem 0;
}

.toc-mobile .toc-h3,
.toc-mobile .toc-h4,
.toc-mobile .toc-h5,
.toc-mobile .toc-h6 {
    font-size: 0.95em;
}

.toc-mobile a {
    color: var(--accent);
    text-decoration: none;
}

.toc-mobile a:hover {
    text-decoration: underline;
}

/* Burger-menu breakpoint is 88rem; narrow-screen breakpoint (prose
   gutter, font scaling, footer stacking, dialogs) is 48rem.
   The mobile TOC is shown below 70rem to match where the desktop
   sticky rail hides. */
@media (max-width: 70rem) {
    .toc-mobile {
        display: block;
    }
}

/* On touch viewports the floating bar is pinned from page load
   (see the earlier @media (hover: none) and (pointer: coarse) rule),
   so the in-flow .site-header-hero directly under it is redundant
   chrome: invisible at rest, revealed only during rubber-band over-
   scroll. Hide its visible content but keep its layout space so the
   floating bar still has room and the banner / page content don't
   slide under it. html and .site-header keep their default paper
   background, so over-scroll reveals a clean strip below the
   floating bar - which keeps its own hairline border-bottom. Gated
   on touch too, so a desktop browser resized narrow (where the
   floating bar stays hidden) keeps the in-flow header visible as
   its only header. */
@media (max-width: 88rem) and (hover: none) and (pointer: coarse) {
    .site-header .site-header-hero {
        visibility: hidden;
    }

    /* Paint html with the diagonal tartan tile so rubber-band over-
       scroll (top and bottom) reveals tartan rather than baring the
       canvas. The body covers html during normal scrolling so this
       paint is only ever visible in the over-scroll reveal areas.
       Scroll-attached (the default) because iOS Safari has a long-
       standing bug where `background-attachment: fixed` fails to
       paint bg images. */
    html {
        background-color: var(--bg);
        background-image: url("/static/images/tartan-pattern-realistic-diagonal.png");
        background-size: 396px 396px;
        background-position: bottom center;
    }
}

@media (max-width: 88rem) {
    .site-header-bar {
        gap: 0 1rem;
        flex-wrap: wrap;
    }

    .site-header-bar .brand {
        flex: 1 1 auto;
        min-width: 0;
    }

    /* Hide the motif so brand + burger get the full width.
       Double-scoped to match the base rule's specificity. */
    .site-header-hero > .site-header-motif-link {
        display: none;
    }

    .nav-toggle {
        display: inline-flex;
        align-items: center;
        flex: 0 0 auto;
        order: 2;
    }

    /* Full-width dropdown. flex-basis is set to the full hero width
       (content box + both gutters) so the background runs edge-to-edge;
       matching negative horizontal margins keep the flex algorithm happy
       and restore internal text alignment to the gutter column.
       Negative margin-bottom absorbs the bar's own bottom padding so the
       fill extends flush to the hero's border-bottom. */
    .site-header-bar > nav {
        flex: 0 0 calc(100% + 2 * var(--gutter));
        order: 3;
        display: none;
        flex-direction: column;
        margin-left: calc(-1 * var(--gutter));
        margin-right: calc(-1 * var(--gutter));
        margin-bottom: calc(-1 * (0.95rem + 2px));
        padding: 0.5rem var(--gutter) calc(0.95rem + 2px + 0.5rem);
        background-color: var(--bg);
        border-top: 1px solid var(--rule);
    }

    .site-header-bar > nav.is-open {
        display: flex;
    }

    .site-header-bar > nav a {
        margin-right: 0;
        padding: 0.55rem 0;
        color: var(--fg);
    }
}

@media (max-width: 48rem) {
    :root {
        --gutter: 1rem;
    }

    .site-header-bar .brand {
        font-size: 1rem;
    }

    .page {
        padding-top: 1.25rem;
        padding-bottom: 2rem;
    }

    .page h1 {
        font-size: 1.6rem;
    }

    .page h2 {
        font-size: 1.25rem;
    }

    .site-header-image {
        height: clamp(140px, 38vw, 240px);
    }

    /* Stack the footer top row (tagline + header credit) vertically on
       narrow viewports, so the credit doesn't crowd the tagline. Both
       lines left-align; the credit drops to the same register as the
       tagline rather than being pushed to the right. */
    .site-footer .footer-top {
        flex-direction: column;
        align-items: flex-start;
        gap: 0.35rem;
    }

    .site-footer .header-credit {
        text-align: left;
    }

    /* Bottom row (Colophon + counter + last-edited date) stacks - on a
       phone the date sitting at the right of a wrapped row reads as
       orphaned, and the centred counter from the desktop 3-col grid
       collapses to a left-aligned line in the stack. */
    .site-footer .footer-bottom {
        grid-template-columns: 1fr;
        gap: 0.25rem;
    }

    .site-footer .footer-bottom > .footer-meta,
    .site-footer .footer-bottom > .page-views,
    .site-footer .footer-bottom > .page-dates {
        grid-column: 1;
        justify-self: start;
        text-align: left;
    }

    /* Dialogs go edge-to-edge: full viewport, no rounding, no shadow. */
    .graphviz-source-dialog,
    .graphviz-fullscreen-dialog {
        width: 100vw;
        height: 100vh;
        max-width: 100vw;
        max-height: 100vh;
        border-radius: 0;
        margin: 0;
        box-shadow: none;
    }

    .graphviz-source-bar {
        padding: 0.5rem 0.6rem;
    }
}

/* Documents archive
 *
 * /documents/<path>(.<ext>) renders a per-document landing page with
 * the original (PDF / image) sitting beside its metadata + body.
 * Sticky preview on wide viewports so the original stays in view as
 * the reader scrolls a transcription. Single column, meta-first on
 * narrow viewports.
 */

.doc-layout {
    display: grid;
    /* Narrow viewport stacking: preview first (the document itself),
     * then the info column (meta + body wrapped together). info-col
     * is a single grid area so meta and body always sit flush against
     * each other - the earlier separate "meta" / "body" areas in a
     * spanning-preview grid let body drift to the bottom of row 2
     * whenever a tall preview pushed row 1 to share its height. */
    grid-template-areas: "preview" "info";
    /* minmax(0, 1fr) explicitly allows the column to shrink below
     * its content's min-content size. Without this, the default 1fr
     * track has an implicit min-content minimum, and a wide
     * BookReader / OpenSeadragon child can push the column - and the
     * whole grid - past the viewport on narrow screens. iOS Safari
     * is especially strict about this. */
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
    margin-top: 1rem;
}

.doc-layout > .preview-col { grid-area: preview; min-width: 0; max-width: 100%; }
.doc-layout > .book-col    { grid-area: preview; min-width: 0; max-width: 100%; }
.doc-layout > .map-col     { grid-area: preview; min-width: 0; max-width: 100%; }
.doc-layout > .info-col    { grid-area: info; min-width: 0; max-width: 100%; }
.doc-layout .meta-col      { margin: 0 0 1.5rem 0; }
.doc-layout .meta-col:last-child { margin-bottom: 0; }

/* Book viewer mode: BookReader needs the full content width and is
 * tall enough to act as the page's primary visual element. The grid
 * collapses to a single column with book on top, then meta, then
 * body. */

.doc-layout.book-mode,
.doc-layout.map-mode {
    grid-template-areas: "preview" "info";
    gap: 1.5rem;
}

.doc-layout.book-mode > .book-col,
.doc-layout.map-mode > .map-col {
    width: 100%;
}

/* OpenSeadragon viewer. Same height clamp as BookReader so embedded
 * deep-zoom maps and book spreads sit at a comparable visual size on
 * every viewport. The dark background shows through during pan beyond
 * image bounds; round the corners to match the book viewer. */
#MapViewer {
    /* position: relative gives the absolutely-positioned .osd-controls
     * cluster a containing block so the buttons sit inside the
     * viewer's top-right corner, not the viewport's. OpenSeadragon
     * normally sets this inline once it initialises, but pinning it
     * in CSS guarantees the controls position correctly even before
     * OSD has run (e.g. brief layout thrash on mobile). */
    position: relative;
    width: 100%;
    max-width: 100%;
    height: clamp(450px, calc(100vh - 12rem), 720px);
    background: #1a1a1a;
    /* Confine the dark letterbox fill to inside the border. Without
       this it paints under the border as well, and the rounded corner
       shows a thin dark stripe between the var(--rule) border and the
       OSD canvas content - reads as an unintended second inner border
       at each corner. With padding-box the border's inner edge sits
       directly against the canvas. */
    background-clip: padding-box;
    /* 2px (not 1px) for the same reason as the Leaflet map preview:
       a 1px border on a `border-radius`d element anti-aliases entirely
       inside the curve and reads as a fuzzy halo, while 2px lets the
       inner pixel sit firmly on the curve so the corner looks like an
       intentional hairline frame. */
    border: 2px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    overflow: hidden;
}

/* Native fullscreen via the viewer's home/fullpage button: fill the
 * viewport and drop the rounded corners. The :fullscreen / :has()
 * rules higher up handle dark scrollbars across the page. */
#MapViewer:fullscreen {
    width: 100%;
    height: 100%;
    border-radius: 0;
}

/* CSS-based "fake fullscreen" fallback for browsers that don't
 * support Element.requestFullscreen() on arbitrary divs - notably
 * iOS Safari, which only allows the API on <video>. The map button
 * applies these classes when the API throws or is missing.
 *
 * 100dvh handles iOS Safari's collapsing URL bar; the 100vh fallback
 * is for browsers without dynamic-viewport support. */
#MapViewer.osd-fake-fullscreen {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100vh;
    height: 100dvh;
    z-index: 9999;
    border-radius: 0;
    margin: 0;
    padding: 0;
}

html.osd-fake-fullscreen-host,
html.osd-fake-fullscreen-host body {
    overflow: hidden;
}

/* Custom Leaflet-style control cluster for the OpenSeadragon map
 * viewer. Replaces OSD's default PNG-sprite buttons (hidden via
 * showZoomControl / showHomeControl / showFullPageControl: false in
 * openseadragon-init.js). The visual idiom mirrors maps.js's reset
 * and fullscreen Leaflet controls so the two viewer types read as
 * part of the same family. */
.osd-controls {
    position: absolute;
    top: 10px;
    right: 10px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    z-index: 100;
}

.osd-controls-bar {
    display: flex;
    flex-direction: column;
    background: #fff;
    /* Match Leaflet's `.leaflet-touch .leaflet-bar`: 2px translucent
       border, no drop shadow. Leaflet's default `.leaflet-bar` carries
       a `box-shadow: 0 1px 5px rgba(0,0,0,0.65)` but the touch profile
       reset it to none; this cluster should read the same. */
    border: 2px solid rgba(0, 0, 0, 0.2);
    border-radius: 0.75rem;
    overflow: hidden;
    background-clip: padding-box;
}

.osd-control-button {
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: 30px;
    height: 30px;
    color: #333;
    background: #fff;
    text-decoration: none;
    cursor: pointer;
    border-bottom: 1px solid #ccc;
}

.osd-control-button:last-child {
    border-bottom: 0;
}

.osd-control-button:hover,
.osd-control-button:focus {
    background: #f4f4f4;
    color: #000;
    /* Leaflet's controls indicate focus with the same light-grey
       background as hover, with no outline ring - match that so the
       two viewer types feel identical. */
    outline: none;
}

.osd-control-button svg {
    display: block;
    width: 16px;
    height: 16px;
}

/* OpenSeadragon navigator (the bird's-eye inset in the bottom-right
   corner showing the current viewport position on a thumbnail of the
   full image). OSD draws .navigator as the outer dark frame and
   .displayregion as the red viewport-bounds rectangle inside; both
   default to square corners. Match the surrounding card vocabulary by
   rounding both with a small radius (scaled down from the viewer's
   own 0.75rem to suit the 120x80 inset). overflow:hidden on the
   navigator clips the thumbnail tile content to the rounded frame. */
#MapViewer .navigator {
    border-radius: 0.25rem;
    overflow: hidden;
}
#MapViewer .displayregion {
    border-radius: 0.2rem;
}


/* BookReader rewrites the className on its host element when it
 * initialises (it adds .BookReader, .ui-full, etc. and drops anything
 * that was there before), so we can't rely on a static class. The id
 * survives, so target that. The height has to be explicit because
 * BookReader adds `contain: strict` to the host; under strict
 * containment the element's contents don't contribute to its size,
 * and without an explicit height the box collapses to 0. */
#BookReader {
    width: 100%;
    /* max-width pins the host to its parent width on narrow viewports
     * - BookReader's internal layout sometimes computes a fixed pixel
     * width from the page-spread dimensions that exceeds a phone
     * screen, and contain:strict on the host means the element's own
     * size still falls back to its CSS width. */
    max-width: 100%;
    /* clamp(min, ideal, max): never below 450px (phones), never above
     * 720px (huge desktops), otherwise viewport minus 12rem so site
     * header + page title + download button still fit on screen. */
    height: clamp(450px, calc(100vh - 12rem), 720px);
    background: #1a1a1a;
    border-radius: 0.75rem;
    overflow: hidden;
}

/* When BookReader switches to fullscreen it sets position: fixed +
 * height: 100% on .BookReader.fullscreenActive. Our #BookReader rule
 * above has higher specificity, so the clamped height wins and leaves
 * a gap at the bottom of the screen. Re-state the fullscreen geometry
 * with an even more specific selector so BookReader actually fills
 * the viewport. */
#BookReader.fullscreenActive {
    width: 100%;
    height: 100%;
    min-height: 100%;
    max-height: 100%;
    border-radius: 0;
}

/* Generic fullscreen treatment - applies to anything the browser
 * puts in native fullscreen (BookReader, the Leaflet maps, future
 * widgets). Three things together get rid of "parchment scrollbar"
 * on Linux Chrome and across other engines:
 *
 *   color-scheme: dark - tells GTK / Windows / macOS scrollbars to
 *     render in their dark variant; this is the load-bearing fix on
 *     Linux Chrome where OS-themed scrollbars ignore CSS pseudo-
 *     element rules.
 *   scrollbar-color: black black - Firefox / spec-compliant browsers.
 *   ::-webkit-scrollbar background: black - older webkit fallback.
 *
 * Lock html / body overflow so any document-level scrollbar can't
 * leak through behind the fullscreen element, and paint them black
 * so a sliver showing at the edge can't read as parchment. */
html:has(:fullscreen),
body:has(:fullscreen) {
    overflow: hidden;
    color-scheme: dark;
    scrollbar-color: black black;
    background: black;
}

:fullscreen,
:fullscreen * {
    color-scheme: dark;
    scrollbar-color: black black;
}

:fullscreen::-webkit-scrollbar,
:fullscreen *::-webkit-scrollbar,
:fullscreen::-webkit-scrollbar-track,
:fullscreen *::-webkit-scrollbar-track,
:fullscreen::-webkit-scrollbar-thumb,
:fullscreen *::-webkit-scrollbar-thumb,
:fullscreen::-webkit-scrollbar-corner,
:fullscreen *::-webkit-scrollbar-corner {
    background: black;
}

/* BookReader fake-fullscreen fallback. Browsers without the
 * Fullscreen API never enter the :fullscreen state above; instead
 * BookReader sets `.fullscreenActive` on its host element. Mirror
 * the dark / black treatment so the experience matches. */
html:has(#BookReader.fullscreenActive),
body:has(#BookReader.fullscreenActive) {
    overflow: hidden;
    color-scheme: dark;
    scrollbar-color: black black;
    background: black;
}

#BookReader.fullscreenActive,
#BookReader.fullscreenActive * {
    color-scheme: dark;
    scrollbar-color: black black;
}

#BookReader.fullscreenActive::-webkit-scrollbar,
#BookReader.fullscreenActive *::-webkit-scrollbar,
#BookReader.fullscreenActive::-webkit-scrollbar-track,
#BookReader.fullscreenActive *::-webkit-scrollbar-track,
#BookReader.fullscreenActive::-webkit-scrollbar-thumb,
#BookReader.fullscreenActive *::-webkit-scrollbar-thumb,
#BookReader.fullscreenActive::-webkit-scrollbar-corner,
#BookReader.fullscreenActive *::-webkit-scrollbar-corner {
    background: black;
}

/* Hide individual chrome elements within BookReader's top toolbar.
 * The toolbar itself stays visible because the search plugin's menu
 * trigger lives there; the title section is rendered empty (see the
 * `bookTitle: ""` option in bookreader-init.js) and these rules
 * suppress the IA logo, info, and share buttons. */
#BookReader .BRtoolbarSectionLogo,
#BookReader .BRtoolbarSectionInfo,
#BookReader .share-button,
#BookReader [class*="info-button"],
#BookReader [data-event-click-tracking*="Info"],
#BookReader [data-event-click-tracking*="Share"] {
    display: none !important;
}

/* Suppress focus rings on click-only interactions inside BookReader.
 * Keyboard focus (`:focus-visible`) keeps the browser default so a
 * user tabbing through controls still sees what's focused. */
#BookReader :focus:not(:focus-visible) {
    outline: none;
}

/* Search box in BookReader's top toolbar (desktop variant). The
 * input is plain light-DOM `<input type="search" class="BRsearchInput">`,
 * so we can target it directly. Three things at once:
 *   1. Match the site's serif (EB Garamond) for input + placeholder.
 *   2. Replace the browser default yellow / blue focus ring with a
 *      subtle white inner glow so the dark toolbar background still
 *      reads cleanly.
 *   3. Roomier minimum width so a typical query doesn't immediately
 *      truncate, and centre the magnifier-glass submit button
 *      vertically inside the rounded outer pill. */
#BookReader .BRbooksearch.desktop .BRsearchInput {
    font-family:
        "EB Garamond", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
    font-size: 18px;
    width: auto;
    min-width: 200px;
}
#BookReader .BRbooksearch.desktop .BRsearchInput::placeholder {
    font-family: inherit;
    font-style: italic;
    color: rgba(255, 255, 255, 0.85);
    opacity: 1;
}
/* No internal focus indicator: the surrounding white-pill border
 * already reads as a search field, and the typing itself shows the
 * box is active. Suppresses both the default browser focus ring
 * (yellow dotted on Firefox / blue glow on Chrome) and any
 * inherited box-shadow rules. */
#BookReader .BRbooksearch.desktop .BRsearchInput:focus,
#BookReader .BRbooksearch.desktop .BRsearchInput:focus-visible {
    outline: none;
    box-shadow: none;
}
#BookReader .BRbooksearch.desktop .BRsearchSubmit {
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0 8px;
}
#BookReader .BRbooksearch.desktop .BRsearchSubmit img {
    display: block;
}

/* Search-match highlights. Recolour BookReader's blue highlight as a
 * highlighter-pen yellow. The pulse animation comes from the upstream
 * keyframe (highlightFocus animates stroke-width from 20px down to
 * the rest value); we don't touch animation or stroke-width here, so
 * the staggered pulse keeps running. The ID-based selector beats
 * BookReader's `.BookReader .searchHiliteLayer rect` rule on
 * specificity without needing !important - and that matters,
 * because !important on a property kills any keyframe that would
 * otherwise drive it. */
#BookReader .searchHiliteLayer rect {
    fill: rgba(255, 225, 0, 0.35);
    /* Saturated near-opaque yellow so the 20px-wide opening frame
     * of the pulse is clearly visible; the stroke-width animation
     * then shrinks it down to BookReader's 4px rest state. */
    stroke: rgb(245, 200, 0);
    stroke-opacity: 0.85;
}

/* Search progress popup. BookReader's stock styling is white with a
 * sans-serif body and a striped-GIF progress bar - both read as
 * dated against the rest of the site. Restyle to match the
 * parchment / serif idiom and replace the GIF with a CSS-animated
 * indeterminate bar. */
#BookReader .BRprogresspopup {
    background: var(--bg, #faf8f4);
    color: var(--fg, #1a1a1a);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
    padding: 1.5rem 2rem 1.25rem;
    font-family:
        "EB Garamond", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
    font-size: 1.05rem;
    line-height: 1.4;
    width: auto;
    min-width: 280px;
    max-width: 380px;
    margin: 80px auto auto;
}

/* Replace the striped progressbar.gif with a sliding accent-colour
 * gradient. Indeterminate by intent: BookReader doesn't expose a
 * real percentage, so the visual cue is "something is in flight". */
#BookReader .BRprogressbar {
    background-image: none !important;
    height: 6px;
    border-radius: 3px;
    background-color: rgba(0, 0, 0, 0.08);
    position: relative;
    overflow: hidden;
    margin: 0 0 1rem;
}
#BookReader .BRprogressbar::before {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(
        90deg,
        transparent 0%,
        var(--accent, #6a3a23) 50%,
        transparent 100%
    );
    animation: br-progress-slide 1.2s linear infinite;
}
@keyframes br-progress-slide {
    from { transform: translateX(-100%); }
    to { transform: translateX(100%); }
}

/* Modernise the close (×) button: subtle round target, accent on
 * hover. The existing icon-close-dark image inside it is left to
 * BookReader's own styling. */
#BookReader .BRprogresspopup .close-popup {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    cursor: pointer;
    transition: background-color 0.15s ease;
}
#BookReader .BRprogresspopup .close-popup:hover {
    background-color: rgba(0, 0, 0, 0.08);
}

/* Custom zoom-reset button injected by bookreader-init.js as its own
 * <li> in the navbar. Sized and coloured to match BookReader's
 * built-in BRicon buttons (white SVG strokes against the dark navbar
 * background). */
#BookReader .BRicon.zoom_reset {
    cursor: pointer;
    color: #fff;
}
#BookReader .BRicon.zoom_reset svg {
    width: 22px;
    height: 22px;
    stroke: #fff;
    fill: none;
}

/* Match BookReader's UI text (page counter, slider tooltip, button
 * labels) to the site's serif. EB Garamond's conventional lower-case
 * forms read better at the small sizes BookReader uses than the
 * default sans-serif and stay in family with the page around it.
 * Garamond has a smaller x-height than the default sans-serif, so
 * step the size up a notch to compensate visually. The descendant
 * + !important rule is needed because BookReader's footer has
 * children with their own pixel-based font-size declarations
 * (e.g. .BRpager) that would otherwise win on specificity. */
#BookReader,
#BookReader .BRfooter,
#BookReader .BRnav,
#BookReader .BRtooltip {
    font-family:
        "EB Garamond", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
    font-size: 18px;
}

#BookReader .BRfooter,
#BookReader .BRfooter *,
#BookReader .BRnav,
#BookReader .BRnav *,
#BookReader .BRtooltip,
#BookReader .BRtooltip * {
    font-size: 18px !important;
    font-family:
        "EB Garamond", Georgia, "Iowan Old Style", "Palatino Linotype", serif !important;
}

/* BookReader rounds the outer corners of each leaf (4px radius) to
 * mimic a physical book. The source PDFs we serve have squared pages,
 * so the rounding misrepresents the original. Square every wrapper
 * BookReader uses for a leaf in either viewing mode. !important is
 * needed because BookReader.css is loaded after our stylesheet, so
 * equal-specificity vendor rules would otherwise win. */
#BookReader .br-mode-2up__leafs,
#BookReader .br-mode-2up__leafs[side=left],
#BookReader .br-mode-2up__leafs[side=right],
#BookReader .br-mode-2up__leafs .br-leaf-edges__label,
#BookReader .BRpagecontainer,
#BookReader .BRpagecontainer img {
    border-radius: 0 !important;
}

@media (min-width: 60rem) {
    /* Wide viewports: book / map / preview takes the left column,
       metadata + body stack on the right. Same grid for all three
       viewer modes; the embedded viewers manage their own scrolling
       and gestures, so the column is sticky at the top. */
    .doc-layout.has-preview,
    .doc-layout.book-mode,
    .doc-layout.map-mode {
        grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
        grid-template-areas: "preview info";
        gap: 2rem;
        align-items: start;
    }

    .doc-layout.has-preview > .preview-col,
    .doc-layout.book-mode > .book-col,
    .doc-layout.map-mode > .map-col {
        position: sticky;
        top: calc(1rem + var(--floating-header-offset, 0px));
        max-height: calc(100vh - 2rem - var(--floating-header-offset, 0px));
        display: flex;
        flex-direction: column;
    }

    /* .eml previews auto-size to the email's full content - the page
     * is meant to grow vertically to fit. Sticky + max-height would
     * clip a long email's body iframe. Opt out of both for any
     * preview-col holding an .eml viewer. */
    .doc-layout.has-preview > .preview-col:has(.preview-eml) {
        position: static;
        max-height: none;
        display: block;
    }
}

.doc-layout .preview {
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    min-height: 0;
}

.doc-layout .preview img {
    display: block;
    max-width: 100%;
    height: auto;
    width: auto;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    background: white;
    /* Stop the flex container from stretching narrow / portrait
     * images to the full column width. Without this, the image's
     * box fills the column and object-fit centres the picture
     * inside with white margins on either side. */
    align-self: flex-start;
}

.doc-layout .preview object {
    display: block;
    width: 100%;
    height: 80vh;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    background: white;
}

.doc-layout .preview .preview-pdf {
    display: block;
    width: 100%;
    height: 80vh;
    flex-shrink: 0;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    background: white;
}

/* Map preview for .gpx documents. The map div is the entry point for
   maps.js (same code path as inline maps on notebook pages), so the
   sibling summary strip + profile chart are emitted by JS underneath.

   The .preview wrapper is a flex column. For a GPX document we cap
   it at roughly one viewport height and let the map flex within so
   the map, summary strip and elevation profile all fit on screen
   together without scrolling. `dvh` handles iOS Safari's collapsing
   URL bar; the `vh` fallback covers browsers without dynamic-
   viewport support. Plain (non-GPX) maps in a document preview keep
   the aspect-ratio sizing - they have no sibling chart underneath. */
.doc-layout .preview .map {
    display: block;
    width: 100%;
    aspect-ratio: 3 / 2;
    max-height: 80vh;
    background: #f0e8d8;
    /* 2px (not 1px) so the border reads as a definite edge at the
       rounded corner. A 1px border combined with `border-radius` ends
       up entirely inside the anti-aliased curve and renders as a fuzzy
       beige rim. With 2px the inner pixel sits firmly on the curve and
       the outer pixel takes the anti-aliasing, so the corner looks
       like an intentional hairline frame instead of a halo. */
    border: 2px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    overflow: hidden;
}
.doc-layout .preview:has(.map[data-gpx]) {
    /* Subtract the height of the site header, page title and
       padding above the preview so the map + summary + profile
       trio fits in what's left of the viewport without scrolling.
       The subtracted amount is approximate (header ~5.5rem +
       title ~5rem + breathing room ~3rem) but conservative
       enough to keep the elevation chart on-screen at common
       viewport heights. */
    height: calc(100vh - 14rem);
    height: calc(100dvh - 14rem);
    max-height: calc(100vh - 14rem);
    max-height: calc(100dvh - 14rem);
    min-height: 360px;
}
.doc-layout .preview .map[data-gpx] {
    aspect-ratio: auto;
    flex: 1 1 0;
    min-height: 240px;
    max-height: none;
}
.doc-layout .preview .map-gpx-summary,
.doc-layout .preview .map-gpx-profile {
    width: 100%;
    border: 1px solid var(--rule, #d8d4ca);
    border-top: 0;
}
.doc-layout .preview .map-gpx-summary {
    border-radius: 0;
}
.doc-layout .preview .map + .map-gpx-summary {
    /* Detach the summary from the map's bottom rounded edge so the
       two read as separate but stacked rows. */
    margin-top: -1px;
}
/* The .map preview's last DIV child carries the rounded bottom-card
   edge. We match on :last-of-type rather than :last-child because the
   template emits a sibling <noscript> fallback after the .map div,
   which would otherwise win :last-child and leave the profile / summary
   with square bottom corners regardless of stacking order. */
.doc-layout .preview .map-gpx-profile:last-of-type,
.doc-layout .preview .map-gpx-summary:last-of-type {
    border-bottom-left-radius: 0.75rem;
    border-bottom-right-radius: 0.75rem;
}
.doc-layout .preview .map:has(+ .map-gpx-summary),
.doc-layout .preview .map:has(+ .map-gpx-profile) {
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
}
/* The attribution strip's bottom-right corner normally follows the
   map's outer 0.75rem curve. With a summary / profile abutting the
   map's bottom edge, that edge is squared off (rule above), so the
   attribution should square off to match - otherwise the strip ends
   in a curve that no longer matches anything around it. */
.doc-layout .preview .map:has(+ .map-gpx-summary) .leaflet-control-attribution,
.doc-layout .preview .map:has(+ .map-gpx-profile) .leaflet-control-attribution {
    border-bottom-right-radius: 0;
}

.doc-layout .preview .preview-video {
    display: block;
    max-width: 100%;
    width: auto;
    height: auto;
    max-height: 80vh;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    background: black;
    align-self: flex-start;
}

.doc-layout .preview .preview-audio {
    display: block;
    width: 100%;
    align-self: flex-start;
}

@media (min-width: 60rem) {
    .doc-layout.has-preview .preview .preview-video {
        max-height: calc(100vh - 5rem);
    }
}

/* Soft honey/amber so the message reads as a notice panel against the
 * cream page and the white preview frames it sits inside (.preview-zip
 * / .preview-xlsx). Warm enough to draw the eye without alarming - sits
 * in the same family as the parchment palette and complements --accent
 * rather than clashing with it. Font is pinned to the body serif so
 * messages look the same regardless of the surrounding viewer (the zip
 * frame is monospace, the xlsx frame inherits sans - both would
 * otherwise leak through to the message text). */
.doc-layout .preview .preview-fallback,
.doc-layout .preview .preview-loading {
    padding: 1rem;
    background: #fff3c4;
    border: 1px solid #e3c97e;
    color: #5a4416;
    border-radius: 0.75rem;
    margin: 0;
    font-family: "EB Garamond", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
    font-size: 1rem;
    font-style: italic;
}

/* Loading state: progress ring + message side-by-side. The ring is
 * the same SVG element across every preview type (book, map, eml,
 * docx, gedcom, xlsx, zip, psd) so the site has one consistent
 * loading visual. preview-progress.js streams the response body and
 * sets stroke-dashoffset on the bar when Content-Length is known;
 * without that signal the bar stays in its indeterminate spin. */
.doc-layout .preview .preview-loading {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}

.doc-layout .preview .preview-progress {
    display: inline-block;
    width: 1.5rem;
    height: 1.5rem;
    flex: 0 0 auto;
}

.doc-layout .preview .preview-progress svg {
    width: 100%;
    height: 100%;
    /* Rotate so the bar starts at 12 o'clock and progresses clockwise. */
    transform: rotate(-90deg);
}

.doc-layout .preview .preview-progress-track {
    fill: none;
    stroke: var(--rule, #d8d4ca);
    stroke-width: 3;
}

.doc-layout .preview .preview-progress-bar {
    fill: none;
    stroke: var(--accent);
    stroke-width: 3;
    stroke-linecap: round;
    /* Indeterminate default: a short arc that slides around the ring
     * until either the response stream supplies a total or the
     * loader finishes. */
    stroke-dasharray: 25 100;
    stroke-dashoffset: 0;
    animation: preview-progress-spin 1.2s linear infinite;
    transition: stroke-dashoffset 0.15s linear;
}

.doc-layout .preview .preview-progress-determinate .preview-progress-bar {
    animation: none;
    /* Full circumference dash (pathLength=100 on the circle); the JS
     * tweaks stroke-dashoffset from 100 down to 0 as bytes arrive. */
    stroke-dasharray: 100;
    stroke-dashoffset: 100;
}

@keyframes preview-progress-spin {
    to { stroke-dashoffset: -100; }
}

@media (prefers-reduced-motion: reduce) {
    .doc-layout .preview .preview-progress-bar {
        animation: none;
    }
}

.doc-layout .preview .preview-text {
    margin: 0;
    padding: 1rem;
    background: rgba(255, 255, 255, 0.85);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.85rem;
    line-height: 1.45;
    /* No soft-wrap: long lines scroll horizontally inside the frame
     * rather than reflow, so column-aligned text (tables, logs,
     * GEDCOM-ish records) stays readable. */
    white-space: pre;
    overflow: auto;
    /* Same height envelope as the BookReader / GEDCOM viewer: never
     * below 450px on phones, never above 720px on huge desktops,
     * otherwise viewport-minus-chrome. */
    height: clamp(450px, calc(100vh - 12rem), 720px);
}

/* docx-preview renders fixed-width page sheets with their own white
 * background and drop shadow. The frame is just a scrollable window:
 * neutral background, rounded border to match the other preview
 * surfaces. Page width is scaled to fit the column by docx-init.js
 * (via `zoom`) so horizontal scroll is normally avoided; the
 * `overflow: auto` is the safety net for documents wider than the
 * viewport even at maximum scale-down. */
.doc-layout .preview .preview-docx {
    margin: 0;
    padding: 0.5rem;
    /* Opaque white so the reading area is one continuous sheet.
     * docx-preview sets white backgrounds on individual paragraph
     * blocks; against a translucent frame those would show through
     * as horizontal stripes between paragraphs. */
    background: #ffffff;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    overflow: auto;
    /* Match the GEDCOM viewer's height envelope so the two embedded
     * viewers feel like the same kind of thing: never below 450px on
     * phones, never above 900px on huge desktops, otherwise
     * viewport-minus-chrome. */
    max-height: clamp(450px, calc(100vh - 12rem), 900px);
}

/* docx-preview's outer wrapper centres the pages in the available
 * width; let it do that, just trim its default chrome so the rendered
 * pages sit against the frame edge. */
.doc-layout .preview .preview-docx .docx-wrapper {
    padding: 0;
    background: transparent;
}

/* Continuous flow: strip docx-preview's per-page chrome (white sheet
 * background, drop shadow, gap between pages) so the document reads
 * as one stream rather than a stack of facsimile pages. A thin rule
 * marks each page break. Page-internal padding is left untouched -
 * that's the document's own margins, not chrome. */
.doc-layout .preview .preview-docx .docx-wrapper > section.docx {
    margin: 0 auto;
    background: transparent;
    box-shadow: none;
    border: 0;
}

.doc-layout .preview .preview-docx .docx-wrapper > section.docx + section.docx {
    border-top: 1px solid var(--rule, #d8d4ca);
}

/* Transient highlight for the match the search deep-link jumped to.
 * docx-init.js wraps the matched run in a <mark.docx-search-hit>
 * after a ?q= match, then ~4s later adds .docx-search-hit-fade
 * which transitions the highlight away. The mark element is
 * styled directly rather than via the default <mark> styling so it
 * sits cleanly inside the docx-preview output regardless of which
 * inline / block parent it lands in. */
.doc-layout .preview .preview-docx mark.docx-search-hit {
    background: #fff3b8;
    color: inherit;
    padding: 0 0.1em;
    border-radius: 2px;
    box-shadow: 0 0 0 2px #fff3b8;
    transition: background 0.6s ease, box-shadow 0.6s ease;
}

.doc-layout .preview .preview-docx mark.docx-search-hit-fade {
    background: transparent;
    box-shadow: none;
}

/* .eml preview. Bordered rounded frame matching the other preview
 * surfaces. Headers, body, and attachments stack vertically; the body
 * iframe auto-sizes to its content (via eml-init.js) so the whole
 * email shows in full and the page grows to accommodate it - no
 * internal scrolling, no fade overlays. */
.doc-layout .preview .preview-eml {
    margin: 0;
    padding: 1rem;
    background: rgba(255, 255, 255, 0.85);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
}

.doc-layout .preview .preview-eml .eml-headers {
    margin: 0 0 1rem 0;
    padding: 0 0 0.75rem 0;
    border-bottom: 1px solid var(--rule, #d8d4ca);
    display: grid;
    grid-template-columns: max-content 1fr;
    column-gap: 0.75rem;
    row-gap: 0.25rem;
    font-size: 0.9rem;
}

.doc-layout .preview .preview-eml .eml-headers dt {
    font-weight: 600;
    color: #555;
}

.doc-layout .preview .preview-eml .eml-headers dd {
    margin: 0;
    color: #222;
    word-break: break-word;
}

.doc-layout .preview .preview-eml .eml-body {
    margin: 0;
}

.doc-layout .preview .preview-eml .eml-html {
    display: block;
    width: 100%;
    /* `border: 0` strips the user-agent's 2px inset iframe border;
     * `outline: 0` and `box-shadow: none` clear the additional
     * focus / chrome shading some browsers paint around an iframe,
     * which otherwise reads as a 3-sided drop shadow inside the
     * outer .preview-eml frame. `color-scheme: light` keeps the
     * scrollbar from picking up a platform-dark gradient. The
     * height is set inline by eml-init.js to the email body's
     * scrollHeight; 1px default keeps the iframe from briefly
     * showing the user-agent's default 150px box before the
     * measurement lands. */
    border: 0;
    border-radius: 0;
    outline: 0;
    box-shadow: none;
    color-scheme: light;
    background: #ffffff;
    height: 1px;
}

.doc-layout .preview .preview-eml .eml-text {
    /* Plain-text body: wrap soft so long lines reflow naturally.
     * No fixed height - the <pre> grows with its content so the
     * whole email shows inline and the page grows to fit, matching
     * the html iframe path. Resets the height clamp and overflow
     * inherited from .preview-text. */
    white-space: pre-wrap;
    word-break: break-word;
    background: transparent;
    border: 0;
    margin: 0;
    padding: 0;
    font-size: 0.9rem;
    height: auto;
    overflow: visible;
}

.doc-layout .preview .preview-eml .eml-attachments-heading {
    flex: 0 0 auto;
    margin: 1rem 0 0.5rem 0;
    padding-top: 0.75rem;
    border-top: 1px solid var(--rule, #d8d4ca);
    font-size: 0.95rem;
    font-weight: 600;
}

.doc-layout .preview .preview-eml .eml-attachments {
    flex: 0 0 auto;
    list-style: none;
    margin: 0;
    padding: 0;
    font-size: 0.9rem;
}

.doc-layout .preview .preview-eml .eml-attachments li {
    margin: 0.25rem 0;
}

.doc-layout .preview .preview-eml .eml-attachment-meta {
    color: #777;
    font-size: 0.85rem;
}

/* Spreadsheet (.xlsx / .xls) preview. Scrollable frame wrapping a
 * SheetJS-rendered <table>; multi-sheet workbooks get a tab strip
 * across the top. Same height envelope as the other embedded viewers.
 * Tables can be very wide (lots of columns) so horizontal scroll is
 * expected and not styled out. */
.doc-layout .preview .preview-xlsx {
    margin: 0;
    padding: 0;
    background: rgba(255, 255, 255, 0.95);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    overflow: hidden;
    height: clamp(450px, calc(100vh - 12rem), 720px);
    display: flex;
    flex-direction: column;
    font-size: 0.85rem;
}

.doc-layout .preview .preview-xlsx .preview-fallback,
.doc-layout .preview .preview-xlsx > .preview-loading {
    margin: 0.75rem;
}

.doc-layout .preview .preview-xlsx .preview-xlsx-wrapper {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
}

.doc-layout .preview .preview-xlsx .preview-xlsx-tabs {
    flex: 0 0 auto;
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    padding: 0.5rem 0.5rem 0;
    border-bottom: 1px solid var(--rule, #d8d4ca);
    background: rgba(0, 0, 0, 0.02);
}

.doc-layout .preview .preview-xlsx .preview-xlsx-tab {
    appearance: none;
    -webkit-appearance: none;
    background: transparent;
    border: 1px solid transparent;
    border-bottom: none;
    border-radius: 0.4rem 0.4rem 0 0;
    padding: 0.3rem 0.7rem;
    font: inherit;
    font-size: 0.8rem;
    color: #555;
    cursor: pointer;
}

.doc-layout .preview .preview-xlsx .preview-xlsx-tab:hover {
    color: #222;
    background: rgba(0, 0, 0, 0.04);
}

.doc-layout .preview .preview-xlsx .preview-xlsx-tab[aria-selected="true"] {
    color: #222;
    background: #ffffff;
    border-color: var(--rule, #d8d4ca);
    /* Pull the tab down 1px to overlap the strip's border so the
     * active tab visually merges with the sheet beneath. */
    margin-bottom: -1px;
    padding-bottom: calc(0.3rem + 1px);
}

.doc-layout .preview .preview-xlsx .preview-xlsx-sheet {
    flex: 1 1 auto;
    min-height: 0;
    overflow: auto;
    background: #ffffff;
}

/* SheetJS's sheet_to_html emits a plain <table> with td/th cells. The
 * rules below give it spreadsheet-shaped chrome: thin grid lines,
 * compact padding, monospace for cells so columns of numbers line up.
 * No fixed widths - the table sizes to its content and the frame
 * scrolls horizontally when needed. */
.doc-layout .preview .preview-xlsx .preview-xlsx-sheet table {
    border-collapse: collapse;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.8rem;
    line-height: 1.4;
}

.doc-layout .preview .preview-xlsx .preview-xlsx-sheet td,
.doc-layout .preview .preview-xlsx .preview-xlsx-sheet th {
    border: 1px solid #e3e0d8;
    padding: 0.2rem 0.45rem;
    vertical-align: top;
    white-space: nowrap;
}

.doc-layout .preview .preview-xlsx .preview-xlsx-sheet th {
    background: #f3f0ea;
    font-weight: 600;
}

/* .zip listing. Same scrollable-frame shape as .preview-text: bordered
 * rounded box, monospace, fixed height envelope matching the other
 * embedded viewers. Renders a 3-column table (name, size, modified).
 * No row striping or hover - kept deliberately flat so the listing
 * reads as a manifest rather than an interactive file browser. */
.doc-layout .preview .preview-zip {
    margin: 0;
    padding: 1rem;
    background: rgba(255, 255, 255, 0.85);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    overflow: auto;
    height: clamp(450px, calc(100vh - 12rem), 720px);
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.85rem;
    line-height: 1.5;
}

.doc-layout .preview .preview-zip .preview-zip-summary {
    margin: 0 0 0.75rem 0;
    color: #555;
    font-size: 0.8rem;
}

.doc-layout .preview .preview-zip table {
    border-collapse: collapse;
    width: 100%;
}

.doc-layout .preview .preview-zip th,
.doc-layout .preview .preview-zip td {
    text-align: left;
    padding: 0.25rem 0.5rem;
    border-bottom: 1px solid rgba(0, 0, 0, 0.06);
    vertical-align: top;
}

.doc-layout .preview .preview-zip th {
    font-weight: 600;
    color: #444;
    position: sticky;
    top: 0;
    background: rgba(255, 255, 255, 0.95);
}

.doc-layout .preview .preview-zip td.name {
    word-break: break-all;
}

.doc-layout .preview .preview-zip td.name.dir {
    color: #555;
}

.doc-layout .preview .preview-zip td.size {
    text-align: right;
    white-space: nowrap;
}

.doc-layout .preview .preview-zip td.date {
    white-space: nowrap;
    color: #666;
}

/* .psd preview. The composite canvas sits in a bordered cream frame
 * matching the image preview chrome; a layer panel underneath lists
 * every layer (groups nested) with a checkbox per row. Toggling a
 * checkbox redraws the canvas from the visible leaves. */
.doc-layout .preview .preview-psd {
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: 0.75rem;
    align-self: flex-start;
    max-width: 100%;
}

.doc-layout .preview .preview-psd .preview-psd-stage {
    background: white;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    overflow: hidden;
    max-width: 100%;
    /* Take the available width minus the layer panel, but never grow
     * past the canvas's natural size (handled by the canvas's
     * max-width on the element itself). */
    flex: 1 1 60%;
    min-width: 0;
}

/* Viewport clips the transformed canvas so wheel-zoom and click-drag
 * panning stay inside the cream frame. `touch-action: none` keeps the
 * browser from interpreting a vertical wheel as page scroll over the
 * preview - the wheel handler is the one that should respond. */
.doc-layout .preview .preview-psd .preview-psd-viewport {
    overflow: hidden;
    max-width: 100%;
    touch-action: none;
    cursor: zoom-in;
}

.doc-layout .preview .preview-psd .preview-psd-viewport.is-panning {
    cursor: grabbing;
}

.doc-layout .preview .preview-psd .preview-psd-canvas {
    display: block;
    max-width: 100%;
    height: auto;
    transform-origin: center center;
    /* The wheel handler runs every frame; the browser should not also
     * tween the transform between handler ticks. */
    transition: none;
    will-change: transform;
}

.doc-layout .preview .preview-psd .preview-psd-layers {
    background: rgba(255, 255, 255, 0.85);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    padding: 0.75rem 1rem;
    font-size: 0.85rem;
    max-height: 40rem;
    overflow: auto;
    /* Fixed-ish panel beside the image. Wraps below the image on
     * narrow viewports via flex-wrap above. */
    flex: 0 1 10rem;
    min-width: 8rem;
}

.doc-layout .preview .preview-psd .preview-psd-layers-heading {
    margin: 0 0 0.5rem 0;
    font-size: 0.8rem;
    font-weight: 600;
    color: #555;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.doc-layout .preview .preview-psd .preview-psd-layer-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.doc-layout .preview .preview-psd .preview-psd-layer-list .preview-psd-layer-list {
    padding-left: 1rem;
}

.doc-layout .preview .preview-psd .preview-psd-layer-item > label {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.15rem 0;
    cursor: pointer;
    /* Stop double-clicks selecting the layer name - the label is a
     * click target, not a text run. */
    user-select: none;
    -webkit-user-select: none;
}

.doc-layout .preview .preview-psd .preview-psd-layer-name {
    /* Let long layer names run to their natural width; the parent
     * panel handles overflow with a horizontal scrollbar, so names
     * don't get truncated or forced to wrap mid-word. */
    white-space: nowrap;
}

.doc-layout .preview .preview-psd .preview-psd-layer-name.preview-psd-group {
    font-weight: 600;
}

/* Radio-marked group: the heading row has no checkbox, just the
 * (cleaned) group name; options sit under it. The left rule makes
 * the radio set read as one unit. */
.doc-layout .preview .preview-psd .preview-psd-radio-group > .preview-psd-layer-name {
    padding: 0.15rem 0;
}

.doc-layout .preview .preview-psd .preview-psd-radio-list {
    border-left: 2px solid var(--rule, #d8d4ca);
    padding-left: 0.5rem;
    margin-left: 0.25rem;
}

/* .epub viewer. Vertical stack of [paginated stage][nav row] inside
 * a single bordered frame. Chapter navigation lives in a popover off
 * the burger button at the left of the nav row, so the stage takes
 * the whole height the frame offers. epub.js renders the spine into
 * an iframe inside .epub-stage; the stage keeps a clamped height so
 * the page stays usable on every viewport (matching the BookReader /
 * GEDCOM height envelope). */
.doc-layout .preview .preview-epub {
    margin: 0;
    padding: 0.75rem;
    background: rgba(255, 255, 255, 0.95);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.doc-layout .preview .preview-epub .epub-stage {
    width: 100%;
    /* Account for the page chrome above the stage (tartan header,
     * breadcrumbs, H1) and the controls row beneath. Roughly 16rem is
     * reserved for those on a typical viewport - the clamp keeps the
     * stage usable on small screens (min 400px) and stretches up to
     * 1100px on huge desktops so the reading column has the room it
     * needs. */
    height: clamp(400px, calc(100vh - 16rem), 1100px);
    background: #fff;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.4rem;
    overflow: hidden;
}

/* Chapter (TOC) popover. The wrap is the anchor for the absolutely
 * positioned panel; the panel floats above the nav row so it doesn't
 * push other elements around. role=menu / role=menuitem cover the
 * a11y semantics; aria-expanded on the burger button reflects open
 * state. */
.doc-layout .preview .preview-epub .epub-toc-wrap {
    position: relative;
}

.doc-layout .preview .preview-epub .epub-toc-panel {
    position: absolute;
    left: 0;
    bottom: calc(100% + 0.35rem);
    z-index: 5;
    min-width: 16rem;
    max-width: min(24rem, 90vw);
    max-height: 60vh;
    overflow-y: auto;
    background: #fdfaf3;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.5rem;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}

.doc-layout .preview .preview-epub .epub-toc-panel ul {
    list-style: none;
    margin: 0;
    padding: 0.25rem 0;
}

.doc-layout .preview .preview-epub .epub-toc-panel li {
    margin: 0;
}

/* Items in the popover are <button> elements, so they otherwise pick
 * up the `.epub-nav button` shape (rounded, padded rectangle with
 * centered icon and line-height: 0). The extra `.epub-toc-panel`
 * ancestor bumps specificity over that rule so the list rows render
 * as full-width left-aligned text instead. */
.doc-layout .preview .preview-epub .epub-toc-panel .epub-toc-item {
    display: block;
    width: 100%;
    text-align: left;
    padding: 0.4rem 0.85rem;
    font: inherit;
    color: inherit;
    background: transparent;
    border: 0;
    border-radius: 0;
    cursor: pointer;
    line-height: 1.3;
    /* Override the inline-flex/center layout the nav-button rule
     * inherits from its parent. */
    justify-content: flex-start;
    align-items: flex-start;
}

.doc-layout .preview .preview-epub .epub-toc-panel .epub-toc-item:hover,
.doc-layout .preview .preview-epub .epub-toc-panel .epub-toc-item:focus {
    background: #f1ead8;
    outline: 0;
}

/* Indent nested chapters so the hierarchy reads at a glance. Capped
 * at three depths - real-world TOCs almost never go deeper, and a
 * runaway tree shouldn't shove text off the right edge. */
.doc-layout .preview .preview-epub .epub-toc-panel .epub-toc-depth-1 { padding-left: 1.75rem; }
.doc-layout .preview .preview-epub .epub-toc-panel .epub-toc-depth-2 { padding-left: 2.6rem; }
.doc-layout .preview .preview-epub .epub-toc-panel .epub-toc-depth-3 { padding-left: 3.4rem; }

/* Fullscreen mode. The browser's Fullscreen API drops the element
 * onto a black backdrop with no other UI; we stretch the inner stage
 * to fill the viewport and let the nav row sit at the bottom. */
.doc-layout .preview .preview-epub:fullscreen {
    background: #111;
    padding: 1rem;
    border-radius: 0;
    border: 0;
}

.doc-layout .preview .preview-epub:fullscreen .epub-stage {
    height: calc(100vh - 4.5rem);
    max-height: none;
}

.doc-layout .preview .preview-epub:fullscreen .epub-nav button {
    background: #1d1d1d;
    color: #f0e9d8;
    border-color: #3a3a3a;
}

.doc-layout .preview .preview-epub:fullscreen .epub-nav button:hover {
    background: #2a2a2a;
}

.doc-layout .preview .preview-epub:fullscreen .epub-status {
    color: #d0c9b8;
}

.doc-layout .preview .preview-epub:fullscreen .epub-toc-panel {
    background: #1d1d1d;
    color: #f0e9d8;
    border-color: #3a3a3a;
}

.doc-layout .preview .preview-epub:fullscreen .epub-toc-item:hover,
.doc-layout .preview .preview-epub:fullscreen .epub-toc-item:focus {
    background: #2a2a2a;
}

.doc-layout .preview .preview-epub .epub-stage iframe {
    border: 0;
    width: 100%;
    height: 100%;
}

.doc-layout .preview .preview-epub .epub-nav {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.doc-layout .preview .preview-epub .epub-nav button {
    padding: 0.4rem 0.55rem;
    font: inherit;
    background: #fdfaf3;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.4rem;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    line-height: 0;
    color: inherit;
}

.doc-layout .preview .preview-epub .epub-nav button svg {
    display: block;
}

/* The font-size controls use a text label ("A-" / "A+") inside the
 * same nav-button shape as the SVG-icon buttons. The parent button
 * sets line-height: 0 to centre its SVG; this span re-enables a
 * normal line-height so the text actually renders, and the slightly
 * larger font-size matches the visual weight of the 18px icons. */
.doc-layout .preview .preview-epub .epub-nav button .epub-font-label {
    display: inline-block;
    line-height: 1;
    font-size: 0.95rem;
    font-weight: 600;
    letter-spacing: 0.02em;
}

.doc-layout .preview .preview-epub .epub-nav button:hover {
    background: #f5efdf;
}

.doc-layout .preview .preview-epub .epub-nav button:disabled {
    opacity: 0.45;
    cursor: default;
    background: #fdfaf3;
}

.doc-layout .preview .preview-epub .epub-status {
    color: #555;
    font-size: 0.85rem;
    flex: 1;
    text-align: center;
}

.doc-layout .preview .preview-gedcom {
    display: block;
    width: 100%;
    /* Match BookReader's height clamp so the two embedded viewers
     * sit at a similar vertical size on every viewport: never below
     * 450px on phones, never above 720px on huge desktops, otherwise
     * viewport-minus-chrome. */
    height: clamp(450px, calc(100vh - 12rem), 720px);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    background: white;
}

/* Iframe loading state. The iframe doesn't expose the browser's
 * native loading indicator inside its frame, so the wrapper overlays
 * a .preview-loading message centred over the (white) iframe. Once
 * Topola's SPA inside the iframe fires its load event the
 * gedcom-init.js helper adds `.loaded` to the wrapper and the
 * placeholder is hidden. */
.doc-layout .preview .preview-gedcom-wrap {
    position: relative;
}

.doc-layout .preview .preview-gedcom-loading {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 1;
    pointer-events: none;
}

.doc-layout .preview .preview-gedcom-wrap.loaded .preview-gedcom-loading {
    display: none;
}

@media (min-width: 60rem) {
    .doc-layout.has-preview .preview img {
        max-height: calc(100vh - 5rem);
        width: auto;
        margin: 0;
        object-fit: contain;
    }

    .doc-layout.has-preview .preview object {
        height: calc(100vh - 5rem);
    }
}

/* Document title: the download button sits inline with the title
 * text, immediately after it, so the heading wraps as one unit
 * regardless of viewport width and the row never has trailing
 * empty space. */

.doc-download-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    vertical-align: middle;
    margin-left: 0.5rem;
    width: 2rem;
    height: 2rem;
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 4px;
    background: rgba(255, 255, 255, 0.6);
    color: var(--ink, #1a1a1a);
    text-decoration: none;
    line-height: 1;
    /* Don't inherit the h1's font size for the icon container. */
    font-size: 1rem;
}

.doc-download-btn:hover,
.doc-download-btn:focus-visible {
    background: rgba(255, 255, 255, 0.95);
    text-decoration: none;
}

.doc-download-icon {
    flex-shrink: 0;
}

.doc-layout .doc-meta {
    display: grid;
    /* minmax(0, 1fr) on the value column so a long unbreakable token
     * (e.g. an archive.org/... reference URL) can't force the grid
     * past the viewport on narrow screens. The label column uses
     * max-content so labels stay on one line where they fit. */
    grid-template-columns: max-content minmax(0, 1fr);
    gap: 0.25rem 1rem;
    margin: 0;
    padding: 0.75rem 1rem;
    background: rgba(255, 255, 255, 0.6);
    border: 1px solid var(--rule, #d8d4ca);
    border-radius: 0.75rem;
    font-size: 0.95rem;
}

.doc-layout .doc-meta dt {
    color: var(--muted, #666);
}

.doc-layout .doc-meta dd {
    margin: 0;
    /* Long URLs / identifiers in metadata values must be allowed to
     * wrap mid-token; otherwise they push the grid wider than the
     * page on mobile. */
    overflow-wrap: anywhere;
}

/* Auto-listing appended to /documents - nested lists, one block per
 * category folder, with sidecar entries shown beneath. */

.document-list {
    list-style: none;
    padding-left: 0;
    margin: 0 0 1rem;
}

.document-list .document-list {
    margin-left: 1rem;
    border-left: 1px solid var(--rule, #d8d4ca);
    padding-left: 1rem;
    margin-top: 0.5rem;
}

/* On phones the per-level indent and rule-line stack up across the
   levels of folder nesting and crush entry text against the right
   edge. Drop the indent on the nested <ul> so item rows use the full
   content column, then restore a per-level indent on the subsection
   headings only - the heading sizes are uniform, so without indent
   the structure flattens visually. */
@media (max-width: 36rem) {
    .document-list .document-list {
        margin-left: 0;
        padding-left: 0;
        border-left: 0;
    }
    .document-list .document-list .document-list-section > :is(h3, h4, h5, h6) {
        margin-left: 1rem;
    }
    .document-list .document-list .document-list .document-list-section > :is(h3, h4, h5, h6) {
        margin-left: 2rem;
    }
    .document-list
        .document-list
        .document-list
        .document-list-section
        > :is(h3, h4, h5, h6) {
        margin-left: 3rem;
    }
}

.document-list-section {
    margin-bottom: 1rem;
}

.document-list-section > :is(h3, h4, h5, h6) {
    font-size: 1.05rem;
    margin: 0.5rem 0 0.25rem;
}

.document-list-item {
    margin-bottom: 0.5rem;
}

.document-list-item > a {
    color: var(--accent);
}

.document-list-date {
    color: var(--muted, #666);
    font-size: 0.85rem;
    margin-left: 0.5rem;
}

.document-list-item > p {
    margin: 0.15rem 0 0;
    color: var(--muted, #666);
    font-size: 0.9rem;
}

.document-list-empty {
    color: var(--muted, #666);
    font-style: italic;
}

/* Bibliography. Fenced ```bibtex``` blocks are replaced at render time by
   citation.js with a formatted reference list. The loading placeholder
   reuses the Graphviz spinner; on parse or load failure the raw BibTeX
   source is shown unchanged. */
.page > .bibliography-loading {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin: 1.5rem 0;
    padding: 1.25rem 0;
    color: var(--muted);
    font-style: italic;
    font-size: 0.9rem;
}

.page pre.bibliography-error {
    border-left: 3px solid var(--accent);
}

.page p.bibliography-error-msg {
    color: var(--accent);
    font-size: 0.85rem;
    font-style: italic;
}

.page .bibliography-wrapper {
    margin: 1.25rem 0;
}

.page ol.bibliography {
    list-style: none;
    padding: 0;
    margin: 0;
    counter-reset: bibitem;
}

.page ol.bibliography > li.bibliography-entry {
    counter-increment: bibitem;
    position: relative;
    padding: 0.75rem 0 0.75rem 2.5rem;
    border-bottom: 1px solid var(--rule);
}

.page ol.bibliography > li.bibliography-entry:first-child {
    border-top: 1px solid var(--rule);
}

/* Drop the closing rule on the last entry so it doesn't stack with the
   short ornamental rule that `.page-actions::before` draws above the
   Share button. The block ends "open" and the page-actions ornament
   handles the visual close. */
.page ol.bibliography > li.bibliography-entry:last-child {
    border-bottom: none;
}

.page ol.bibliography > li.bibliography-entry::before {
    content: counter(bibitem) ".";
    position: absolute;
    left: 0.25rem;
    top: 0.75rem;
    width: 2rem;
    color: var(--muted);
    font-size: 0.85rem;
    text-align: right;
}

/* Anchored entries get a quiet highlight when linked to via #bib-<citekey>. */
.page ol.bibliography > li.bibliography-entry:target {
    background: rgba(138, 46, 33, 0.04);
}

/* citation.js wraps each formatted entry in div.csl-bib-body > div.csl-entry.
   Flatten the wrapping so it reads as one paragraph alongside the keeper-
   note that follows. */
.page .bibliography-citation,
.page .bibliography-citation .csl-bib-body,
.page .bibliography-citation .csl-entry {
    display: block;
    margin: 0;
}

.page .bibliography-citation .csl-entry {
    line-height: 1.5;
}

.page .bibliography-citation a {
    word-break: break-word;
}

.page p.bibliography-note {
    margin: 0.4rem 0 0;
    color: var(--muted);
    font-size: 0.9rem;
    line-height: 1.5;
}

.page p.bibliography-links {
    margin: 0.4rem 0 0;
    font-size: 0.9rem;
}

.page p.bibliography-links a {
    color: var(--accent);
}

/* Weather forecast widget */
.weather-section {
    margin: 0 0 1.75rem;
}

.weather-section h3 {
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--muted);
    margin: 0 0 0.5rem;
    font-weight: 600;
}

.weather-summary,
.weather-hourly {
    border-collapse: collapse;
    border-bottom: 1px solid var(--rule);
    margin: 0 0 1rem;
    font-variant-numeric: tabular-nums;
}

.weather-summary thead th,
.weather-hourly thead th {
    text-align: center;
    vertical-align: bottom;
    padding: 0.3rem 0.6rem 0.3rem 0;
    border-bottom: 2px solid var(--rule);
    font-weight: 600;
    font-size: 0.875rem;
    white-space: nowrap;
}

.weather-summary thead th:first-child,
.weather-hourly thead th:first-child {
    text-align: left;
    min-width: 7rem;
}

.weather-summary tbody td,
.weather-hourly tbody td {
    text-align: center;
    padding: 0.45rem 0.6rem 0.45rem 0;
    border-top: 1px solid var(--rule);
    vertical-align: middle;
    white-space: nowrap;
    font-size: 0.9rem;
}

.weather-summary tbody td:first-child,
.weather-hourly tbody td:first-child {
    text-align: left;
}

.weather-icon {
    font-size: 1.1rem;
    padding-right: 0.35rem !important;
    width: 1.8rem;
}

.weather-num {
    text-align: center;
}

.weather-muted {
    color: var(--muted);
}

p.weather-loading,
p.weather-error {
    color: var(--muted);
    font-style: italic;
}

p.weather-credit {
    font-size: 0.8rem;
    color: var(--muted);
    margin-top: 0.5rem;
}

/* Weather map pin markers (Leaflet divIcon).
   iconSize/iconAnchor are [0,0] in JS; CSS transform centres the card. */
.weather-map-marker {
    background: none !important;
    border: none !important;
    overflow: visible !important;
}

.weather-map-pin {
    position: absolute;
    left: 0;
    top: 0;
    transform: translate(-50%, -50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.92);
    border: 1px solid rgba(0, 0, 0, 0.15);
    border-radius: 6px;
    padding: 3px 6px 4px;
    line-height: 1.15;
    box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
    pointer-events: none;
    white-space: nowrap;
}

.wmp-icon {
    font-size: 1.3rem;
    line-height: 1;
    display: block;
}

.wmp-temp {
    font-size: 0.8rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: #1a1a1a;
    display: block;
}

/* ── Site-wide search page ──────────────────────────────────────────── */

.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

.search-form-row {
    display: flex;
    gap: 0.5rem;
    max-width: var(--measure);
    margin: 1.5rem 0 1.25rem;
}

.search-form-row input[type="search"] {
    flex: 1;
    min-width: 0;
    font: inherit;
    font-size: 1rem;
    padding: 0.45rem 0.75rem;
    border: 1px solid var(--rule);
    border-radius: 0.3rem;
    background: #fff;
    color: var(--fg);
    appearance: none;
    -webkit-appearance: none;
}

.search-form-row input[type="search"]:focus {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
    border-color: transparent;
}

.search-form-row button[type="submit"] {
    font: inherit;
    font-size: 1rem;
    padding: 0.45rem 1.1rem;
    background: var(--accent);
    color: #fff;
    border: none;
    border-radius: 0.3rem;
    cursor: pointer;
    white-space: nowrap;
}

.search-form-row button[type="submit"]:hover {
    opacity: 0.88;
}

.search-filters {
    display: flex;
    gap: 1.1rem;
    flex-wrap: wrap;
    margin: -0.6rem 0 1.25rem;
}

.search-filter-label {
    display: flex;
    align-items: center;
    gap: 0.3rem;
    font-size: 0.875rem;
    color: var(--muted);
    cursor: pointer;
    user-select: none;
}

.search-filter-label input[type="radio"] {
    accent-color: var(--accent);
    cursor: pointer;
}

p#search-status {
    color: var(--muted);
    font-style: italic;
    margin: 0 0 1rem;
    min-height: 1.4em;
}

.search-ellipsis > span {
    animation: search-dot 1.4s infinite;
    opacity: 0;
}
.search-ellipsis > span:nth-child(2) { animation-delay: 0.2s; }
.search-ellipsis > span:nth-child(3) { animation-delay: 0.4s; }

@keyframes search-dot {
    0%, 100% { opacity: 0; }
    40%       { opacity: 1; }
}

.search-empty {
    color: var(--muted);
}

.search-section {
    max-width: var(--measure);
    margin: 0 0 2rem;
}

.search-section h2 {
    font-size: 0.85rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
    border-bottom: 1px solid var(--rule);
    padding-bottom: 0.4rem;
    margin: 0 0 0.5rem;
}

.search-section-count {
    font-weight: 400;
    text-transform: none;
    letter-spacing: 0;
}

.search-list {
    list-style: none;
    padding: 0;
    margin: 0;
}

.search-item {
    padding: 1rem 0;
    border-bottom: 1px solid var(--rule);
}

.search-item:last-child {
    border-bottom: none;
}

.search-result-title {
    font-size: 1.05rem;
    font-weight: 600;
    text-decoration: none;
    color: var(--accent);
}

.search-result-title:hover {
    text-decoration: underline;
}

.search-result-url {
    font-size: 0.72rem;
    font-family: "Courier Prime", monospace;
    color: var(--muted);
    margin-top: 0.1rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.search-item .search-result-snippet {
    margin: 0.15rem 0 0;
    font-size: 0.9rem;
    color: var(--muted);
    line-height: 1.45;
}

.search-result-label {
    display: inline-block;
    font-size: 0.72rem;
    color: var(--muted);
    border: 1px solid var(--rule);
    border-radius: 999px;
    padding: 0.05rem 0.45rem;
    margin-left: 0.5rem;
    vertical-align: middle;
    white-space: nowrap;
    text-decoration: none;
}

a.search-result-label:hover {
    color: var(--accent);
    border-color: var(--accent);
}

.search-result-newtab {
    display: inline-block;
    vertical-align: middle;
    margin-left: 0.25em;
}

.search-result-newtab svg {
    width: 16px;
    height: 16px;
}

.search-item .tag-list {
    margin-top: 0.3rem;
}

.search-item .tag-list .search-result-label {
    margin-left: 0;
}

.search-result-snippet mark,
.search-result-title mark {
    background: rgba(245, 200, 0, 0.4);
    color: inherit;
    border-radius: 2px;
    padding: 0 1px;
}

.search-pagination {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-top: 0.75rem;
    padding-top: 0.6rem;
    border-top: 1px solid var(--rule);
    font-size: 0.82rem;
}

.search-pagination-info {
    color: var(--muted);
    margin-right: auto;
}

.search-page-btn {
    display: inline-block;
    background: transparent;
    border: 1px solid var(--rule);
    border-radius: 0.25rem;
    padding: 0.2rem 0.65rem;
    font: inherit;
    font-size: 0.82rem;
    color: var(--muted);
    cursor: pointer;
    white-space: nowrap;
    text-decoration: none;
}

.search-page-btn:hover {
    border-color: var(--accent);
    color: var(--accent);
}

.search-help-btn {
    flex-shrink: 0;
    align-self: center;
    width: 1.8rem;
    height: 1.8rem;
    padding: 0;
    background: transparent;
    border: 1px solid var(--rule);
    border-radius: 50%;
    font: inherit;
    font-size: 0.85rem;
    color: var(--muted);
    cursor: pointer;
    user-select: none;
    line-height: 1;
}

.search-help-btn:hover,
.search-help-btn[aria-expanded="true"] {
    color: var(--accent);
    border-color: var(--accent);
}

.search-help-dialog {
    position: fixed;
    z-index: 200;
    background: var(--bg);
    border: 1px solid var(--rule);
    border-radius: 0.4rem;
    padding: 0;
    max-width: 34rem;
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.13);
    margin: 0;
}

.search-help-dialog:not([open]) {
    display: none;
}

.search-help-inner {
    padding: 1rem 1.1rem 0.9rem;
}

.search-help-header {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    margin-bottom: 0.65rem;
}

.search-help-header strong {
    font-size: 0.9rem;
    color: var(--fg);
}

.search-help-close {
    background: transparent;
    border: none;
    font: inherit;
    font-size: 1.2rem;
    line-height: 1;
    color: var(--muted);
    cursor: pointer;
    padding: 0 0 0 0.75rem;
}

.search-help-close:hover {
    color: var(--fg);
}

.search-help-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.85rem;
    margin-bottom: 0.75rem;
}

.search-help-table th {
    text-align: left;
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--muted);
    border-bottom: 1px solid var(--rule);
    padding: 0.2rem 0.5rem 0.3rem 0;
}

.search-help-table td {
    padding: 0.25rem 0.5rem 0.25rem 0;
    vertical-align: top;
    border-bottom: 1px solid var(--rule);
}

.search-help-table tr:last-child td {
    border-bottom: none;
}

.search-help-table td:first-child {
    white-space: nowrap;
    padding-right: 1rem;
}

.search-help-note {
    font-size: 0.82rem;
    color: var(--muted);
    margin: 0;
}

.search-help-dialog:focus {
    outline: none;
}

@media (max-width: 36rem) {
    .search-form-row {
        flex-wrap: wrap;
    }
    .search-form-row input[type="search"] {
        flex-basis: 100%;
    }
    .search-form-row button[type="submit"] {
        flex: 1;
    }
    /* Full-screen help on phones. Inline top/left/right/width set by
       the open-help handler are overridden here so the dialog fills
       the viewport rather than being anchored under the form. */
    .search-help-dialog {
        top: 0 !important;
        left: 0 !important;
        right: 0 !important;
        bottom: 0;
        width: auto !important;
        max-width: none;
        height: auto;
        border: none;
        border-radius: 0;
        box-shadow: none;
    }
    .search-help-inner {
        height: 100%;
        overflow-y: auto;
        padding: 1.1rem 1.1rem 1.25rem;
    }
    .search-help-header {
        position: sticky;
        top: 0;
        background: var(--bg);
        padding-bottom: 0.65rem;
        margin: -0.25rem 0 0.65rem;
    }
    .search-help-close {
        font-size: 1.6rem;
        padding: 0 0 0 0.75rem;
    }
}

/* Person pages (/people/<id>). The H1 above (title row) carries the
   name; this block styles the structured header (Born / Died vitals,
   aliases, notes) and the relations section (parents + partnerships
   with children grouped beneath each partner). The site's heritage
   palette is reused throughout; cards are flat parchment with thin
   rules rather than Ancestry's white drop-shadow style. */
.person-header {
    max-width: var(--measure);
    margin: 0 0 1.5rem;
}

.person-header .person-vitals {
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: max-content 1fr;
    column-gap: 0.75rem;
    row-gap: 0.25rem;
    font-size: 0.95rem;
}

.person-header .person-vitals > div {
    display: contents;
}

.person-header .person-vitals dt {
    color: var(--muted);
    font-variant: small-caps;
    letter-spacing: 0.04em;
    text-transform: lowercase;
}

.person-header .person-vitals dd {
    margin: 0;
}

.person-header .person-place {
    color: var(--muted);
    font-style: italic;
}

.person-header .person-place::before {
    content: "· ";
}

.person-aliases {
    list-style: none;
    padding: 0;
    margin: 0.75rem 0 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem;
}

.person-aliases li {
    display: inline-block;
    font-size: 0.78rem;
    line-height: 1.2;
    padding: 0.15rem 0.5rem;
    border: 1px solid var(--rule);
    border-radius: 999px;
    color: var(--muted);
}

.person-header .person-note {
    margin: 0.75rem 0 0;
    color: var(--muted);
    font-style: italic;
}

.person-relations {
    max-width: var(--measure);
    margin: 0;
}

.person-relations h2 {
    margin: 1.75rem 0 0.5rem;
    font-size: 1.05rem;
    color: var(--muted);
    font-variant: small-caps;
    letter-spacing: 0.04em;
    text-transform: lowercase;
    font-weight: 600;
}

.person-parents,
.person-children {
    list-style: none;
    padding: 0;
    margin: 0;
}

.person-parents > li,
.person-children > li {
    padding: 0.4rem 0;
    border-bottom: 1px solid var(--rule);
}

.person-parents > li:last-child,
.person-children > li:last-child {
    border-bottom: none;
}

.person-parents > li a,
.person-children > li a,
.person-partnership h3 a {
    text-decoration: none;
}

.person-parents > li a:hover,
.person-children > li a:hover,
.person-partnership h3 a:hover {
    text-decoration: underline;
}

.person-lifespan {
    color: var(--muted);
    font-size: 0.85rem;
    margin-left: 0.25rem;
}

.person-partnership {
    margin: 1rem 0 0;
    padding: 0.75rem 0 0;
    border-top: 1px solid var(--rule);
}

.person-partnership:first-of-type {
    border-top: none;
    padding-top: 0;
}

.person-partnership h3 {
    margin: 0 0 0.25rem;
    font-size: 1rem;
    font-weight: 600;
}

.person-marriage {
    margin: 0 0 0.5rem;
    color: var(--muted);
    font-size: 0.85rem;
    font-style: italic;
}

.person-partnership .person-children {
    margin-left: 1rem;
    margin-top: 0.25rem;
    border-left: 1px solid var(--rule);
    padding-left: 0.75rem;
}

/* Timeline. A vertical event line down the left. Each row has a year
   column, then the event card. Events with no recoverable year are
   marked with "?" and sit at the bottom. */
.person-timeline {
    max-width: var(--measure);
    margin: 0;
}

.person-timeline h2 {
    margin: 1.75rem 0 0.5rem;
    font-size: 1.05rem;
    color: var(--muted);
    font-variant: small-caps;
    letter-spacing: 0.04em;
    text-transform: lowercase;
    font-weight: 600;
}

.person-events {
    list-style: none;
    padding: 0;
    margin: 0;
    position: relative;
}

.person-events::before {
    content: "";
    position: absolute;
    top: 0.4rem;
    bottom: 0.4rem;
    left: 3.25rem;
    width: 1px;
    background: var(--rule);
}

.person-events > li {
    position: relative;
    display: grid;
    grid-template-columns: 3rem 1fr;
    column-gap: 1.25rem;
    padding: 0.5rem 0;
}

.person-events > li::before {
    content: "";
    position: absolute;
    left: 3.25rem;
    top: 1.45rem;
    width: 0.4rem;
    height: 0.4rem;
    border-radius: 999px;
    background: var(--muted);
    transform: translateX(-50%);
}

.person-event-year {
    text-align: right;
    color: var(--fg);
    font-variant-numeric: tabular-nums;
    font-size: 0.95rem;
    font-weight: 600;
    padding-top: 0.6rem;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    line-height: 1.15;
}

.person-event-age {
    font-size: 0.75rem;
    font-weight: 400;
    color: var(--muted);
    margin-top: 0.1rem;
}

.person-event-body {
    background: rgba(0, 0, 0, 0.035);
    border-radius: 6px;
    padding: 0.6rem 0.85rem;
}

.person-event-label {
    font-weight: 600;
    font-size: 0.95rem;
    line-height: 1.15;
}

.person-event-meta {
    color: var(--muted);
    font-size: 0.85rem;
    margin-top: 0.2rem;
}
