@brandon_m_behring/book-scaffold-astro 4.24.0 → 4.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -100,7 +100,15 @@ Two callout families coexist. Authors import what they need.
100
100
 
101
101
  **Pedagogy family** (v4.1.0+, any profile, 4 components): `Pitfall` (rose; "common mistake" — distinct from `WarnBox`'s preemptive warning), `WorkedExample` (plum; collapsible `<details>` block with `#worked-example-{id}` anchor for deep links), `YouWillLearn` (gold; chapter-opener with optional `prerequisites` prop), `Diagnostic` (v4.19.0, #110; teal; pre-reading "Do I Know This Already?" DIKTA self-check — a slotted question list + a skip/skim/read routing rubric via `skimTo`, plus an optional collapsible answer key via `slot="answers"`). Slot bullets/code freely; render at any preset.
102
102
 
103
- **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginNote`, `Sidenote`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
103
+ **Utility components** (`src/components/`, any profile): `Cite`, `XRef`, `Figure`, `MarginFigure`, `MarginNote`, `Sidenote`, `EvidenceTag`, `Newthought`, `Epigraph`, `WeekRef`, `CodeRef`, `CodeBlock`, `Tag`, `StatusBadge`, `BookLink` (v4.16.0+; cross-book link — `<BookLink book="design" to="…"/>` resolves `book` against `defineBookConfig({ siblingBooks })` and throws on an unknown book; `<XRef>` is in-book only — #96), `PocLayout` (v4.1.0+; wraps slot in a per-`kind` layout shell — 5 closed-union kinds; see `recipes/15-defining-styles.md`).
104
+
105
+ **`MarginNote` vs `Sidenote` (don't let the names mislead).** `MarginNote` renders **inline** — a colored callout in the running text column; despite the name it does **not** go in the margin. It's for a load-bearing aside the reader must see. `Sidenote` is the one that **floats into the right gutter** (auto-numbered Tufte marginalia, reflowing inline on mobile). Reach for `Sidenote` for footnote-like asides; `MarginNote` for an inline colored callout.
106
+
107
+ **`MarginFigure` + figure/content placement (1d).** `MarginFigure` is a `<Figure>` that **floats into the right gutter** (the same Tufte float + negative-margin technique `Sidenote`/SectionMap use — *not* a grid), shown at ≥64rem and reflowing inline below on mobile. Props mirror `<Figure>` (`src`/`caption`/`alt`/`desc`/`id`/`width`); rendering is delegated to `Figure`, and `width` defaults to `100%` (of the ~28ch gutter column). Two additive placement classes back it: **`.column-margin`** floats any block into the gutter (the un-figure version), and **`.column-page`** is a full-width breakout — an alias of the canonical **`.wide`** escape (max-width override, spans the gutter column too). For a full-bleed figure use `<Figure class="wide" …/>`; for the margin use `<MarginFigure …/>`. **Per-page width knob:** an optional `layout: wide` frontmatter field (closed enum `default`|`wide`, every profile) widens the main text measure for figure-/table-heavy chapters — `Chapter.astro` threads it as `data-layout="wide"` on `<article class="prose">` and `layout.css` maps `.prose[data-layout="wide"] { --measure-main: 80ch }`. Omitting it (or `layout: default`) emits no attribute, so existing chapters are byte-identical. All of these live in the always-loaded `layout.css` (token-only; additive — `.prose`'s block layout and `.sidenote`'s float are unchanged).
108
+
109
+ **`EvidenceTag`** (v4.25.0): inline claim-confidence pill placed right after a claim — `kind` is a closed union `verified | inference | audit-corrected` (fail-loud via `assertEnumProp`; colors green/blue/rose, token-only). Sibling of `Tag` (which flags claim *provenance*); `EvidenceTag` flags how well a claim has been *checked*. An optional `source` prop is reserved (rendered as `data-source`, no behavior yet) for a future fail-loud `validate.mjs` upgrade. Usage: `<EvidenceTag kind="verified" /> … <EvidenceTag kind="inference" />`.
110
+
111
+ **`Newthought` + `Epigraph`** (v4.25.0): Tufte typographic openers ported from the LaTeX book. `Newthought` is a run-in small-caps section opener (TRUE small-caps via `font-feature-settings: "smcp"`) — `<p><Newthought>In practice</Newthought>, …</p>`. `Epigraph` is a chapter-opening italic quotation with a right-aligned attribution (named `attribution` slot) — `<Epigraph>…<Fragment slot="attribution">Hamming</Fragment></Epigraph>`. Their CSS ships in the always-loaded `typography.css`; an opt-in `.heading-accent` (warm-blue italic) class is available there too (global headings are unchanged).
104
112
 
105
113
  **Provenance** (v4.8.0, any profile, **auto-injected by the chapter route — not author-imported**): per-chapter "How this was made" audit-trail block, rendered from the optional `provenance` frontmatter (`ai_tools`, `prompts_archive`, `decisions_log`, `audit_history`, `citation_backstop`). **Opt-out**: a chapter with no `provenance` shows a fallback ("Audit history not yet recorded"). Distinct from `AICollaborationDisclosure` (book-level, manual model+role disclosure). Repo-relative path fields render as `<code>`; only `http(s)` values link.
106
114
 
@@ -8,15 +8,22 @@
8
8
  *
9
9
  * If the chapter has fewer than 3 matching headings, the TOC renders
10
10
  * nothing — an anchor list for a short chapter is clutter.
11
+ *
12
+ * The h2/h3 filter is the shared `tocHeadings` (lib/section-map.ts) — the SAME
13
+ * one the right-gutter SectionMap island uses, so this collapsed fallback and
14
+ * the gutter map carry identical entries (#section-map). On desktop (>= 64rem)
15
+ * this <details> is CSS-hidden and the gutter map takes over; below it, this is
16
+ * the fallback.
11
17
  */
12
18
  import type { MarkdownHeading } from 'astro';
19
+ import { tocHeadings } from '../src/lib/section-map';
13
20
 
14
21
  interface Props {
15
22
  headings: MarkdownHeading[];
16
23
  }
17
24
  const { headings } = Astro.props;
18
25
 
19
- const toc = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
26
+ const toc = tocHeadings(headings);
20
27
  const showToc = toc.length >= 3;
21
28
  ---
22
29
  {showToc && (
@@ -0,0 +1,28 @@
1
+ ---
2
+ /**
3
+ * Epigraph — a chapter-opening quotation in the Tufte style: an italic block
4
+ * quote with a right-aligned attribution. Maps the LaTeX `epigraph`-style
5
+ * opener from interview-preamble-tufte.sty.
6
+ *
7
+ * The quote text goes in the default slot; the attribution goes in the named
8
+ * `attribution` slot (rendered in a <footer>, right-aligned). The measure is
9
+ * inherited from the running text tokens (--measure-main / .prose) rather than
10
+ * a fixed % width, so it doesn't fight the Tufte measure. CSS lives in the
11
+ * shared typography.css (always loaded). The footer renders only when the
12
+ * attribution slot is filled.
13
+ *
14
+ * Usage:
15
+ * <Epigraph>
16
+ * The purpose of computing is insight, not numbers.
17
+ * <Fragment slot="attribution">Richard Hamming</Fragment>
18
+ * </Epigraph>
19
+ */
20
+ ---
21
+ <div class="epigraph">
22
+ <blockquote>
23
+ <slot />
24
+ {Astro.slots.has('attribution') && (
25
+ <footer class="epigraph-attribution"><slot name="attribution" /></footer>
26
+ )}
27
+ </blockquote>
28
+ </div>
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * EvidenceTag — inline claim-confidence marker, placed immediately after a
4
+ * stated claim to calibrate how far the reader should trust it. Ported from
5
+ * the user's Tufte-LaTeX evidence tags (interview-preamble-tufte.sty).
6
+ *
7
+ * Sibling of <Tag> (practice provenance): Tag flags WHERE a claim comes from
8
+ * (official / practitioner / convergence); EvidenceTag flags HOW WELL it has
9
+ * been checked. Three closed-union states:
10
+ *
11
+ * verified Confirmed against a primary source / first-hand check.
12
+ * inference Reasoned conclusion, not directly verified.
13
+ * audit-corrected A prior claim that an audit pass overturned/repaired.
14
+ *
15
+ * Colors are token-only so they recolor with [data-theme] automatically:
16
+ * verified → --warm-green, inference → --warm-blue, audit-corrected →
17
+ * --warm-rose (tinted background + matching text/border), matching the
18
+ * scoped-<style> + data-attribute idiom of <Tag>.
19
+ *
20
+ * Usage:
21
+ * The kernel is diagonal-plus-low-rank <EvidenceTag kind="verified" />.
22
+ * This generalizes to higher orders <EvidenceTag kind="inference" />.
23
+ */
24
+ import { assertEnumProp } from '../src/lib/assert-prop';
25
+
26
+ const KINDS = ['verified', 'inference', 'audit-corrected'] as const;
27
+ type Kind = (typeof KINDS)[number];
28
+
29
+ interface Props {
30
+ kind: Kind;
31
+ /**
32
+ * Provenance pointer for the claim (citekey, URL, audit-file path). RESERVED
33
+ * (#section-map plan NQ3): rendered as a `data-source` attribute today with
34
+ * no behavior. A future fail-loud upgrade — additive, designed-for here — is
35
+ * a `validate.mjs` check requiring `source` on kind="verified" (an unverified
36
+ * "verified" tag is the silent-degradation class assertEnumProp guards). Do
37
+ * NOT wire that validation now; reserving the prop keeps the upgrade additive.
38
+ */
39
+ source?: string;
40
+ }
41
+
42
+ // #109 sweep: fail loud on an unknown kind rather than rendering a blank pill
43
+ // (LABEL[undefined] -> undefined). A closed union with no valid value is an
44
+ // authoring error that should stop the build.
45
+ const kind = assertEnumProp(Astro.props.kind, KINDS, {
46
+ component: 'EvidenceTag',
47
+ prop: 'kind',
48
+ });
49
+ const { source } = Astro.props;
50
+
51
+ const LABEL: Record<Kind, string> = {
52
+ verified: '[V] Verified',
53
+ inference: '[I] Inference',
54
+ 'audit-corrected': 'Audit-corrected',
55
+ };
56
+
57
+ const TITLE: Record<Kind, string> = {
58
+ verified: 'Confirmed against a primary source or first-hand check',
59
+ inference: 'Reasoned conclusion — not directly verified',
60
+ 'audit-corrected': 'A prior claim an audit pass overturned or repaired',
61
+ };
62
+ ---
63
+ <span class="evidence-tag" data-kind={kind} data-source={source} title={TITLE[kind]}>
64
+ {LABEL[kind]}
65
+ </span>
66
+
67
+ <style>
68
+ .evidence-tag {
69
+ display: inline-block;
70
+ font-family: var(--font-code);
71
+ font-size: 0.75em;
72
+ font-weight: 500;
73
+ padding: 0 0.4em;
74
+ margin: 0 0.1em;
75
+ border-radius: var(--radius-sm);
76
+ border: 1px solid var(--color-border);
77
+ background: var(--color-bg-subtle);
78
+ color: var(--color-text-muted);
79
+ vertical-align: 0.1em;
80
+ letter-spacing: 0.02em;
81
+ /* Inline pill: don't let it stretch the line box awkwardly. */
82
+ line-height: 1.4;
83
+ white-space: nowrap;
84
+ }
85
+ /* Tinted background + matching text/border per kind. Tokens only, so each
86
+ * recolors in the [data-theme="dark"] scope without a dark-block edit. */
87
+ .evidence-tag[data-kind="verified"] {
88
+ border-color: var(--warm-green);
89
+ color: var(--warm-green);
90
+ background: var(--warm-green-tint);
91
+ }
92
+ .evidence-tag[data-kind="inference"] {
93
+ border-color: var(--warm-blue);
94
+ color: var(--warm-blue);
95
+ background: var(--warm-blue-tint);
96
+ }
97
+ .evidence-tag[data-kind="audit-corrected"] {
98
+ border-color: var(--warm-rose);
99
+ color: var(--warm-rose);
100
+ background: var(--warm-rose-tint);
101
+ }
102
+ </style>
@@ -0,0 +1,49 @@
1
+ ---
2
+ /**
3
+ * MarginFigure — a Figure pulled into the RIGHT margin (Tufte margin figure).
4
+ *
5
+ * Floats into the .prose right gutter using the SAME float + negative
6
+ * margin-right technique as `.sidenote` and SectionMap (layout.css,
7
+ * `.margin-figure`) — NOT a grid. Shown at >= 64rem (the gutter breakpoint);
8
+ * below that the float rule doesn't apply, so it reflows INLINE in document
9
+ * flow right where it appears (mobile gets the figure, just full-width).
10
+ *
11
+ * Rendering is DELEGATED to <Figure> — this component does not reinvent the
12
+ * inline-SVG / <img> / caption / a11y logic; it only wraps Figure in the
13
+ * gutter-floated <aside> and forwards every prop. The wrapper is an <aside>
14
+ * because a margin figure is supplementary to the running text.
15
+ *
16
+ * `width` defaults to "100%" (of the narrow gutter column, ~28ch) rather than
17
+ * Figure's page-width default — a margin figure should fill its column, not
18
+ * the page. Caption is shrunk to --text-xs in layout.css (margin context).
19
+ *
20
+ * Usage (same props as <Figure>):
21
+ * <MarginFigure
22
+ * src="/figures/week04/ex2_hippo_eigenvalues.svg"
23
+ * caption="HiPPO-LegS eigenvalue structure."
24
+ * alt="Two overlaid eigenvalue spectra forming nested arcs."
25
+ * id="w4-fig-hippo-eigenvalues"
26
+ * />
27
+ *
28
+ * For a full-width (not margin) figure, use <Figure> with class="wide" (or
29
+ * class="column-page"). For a generic non-figure block in the gutter, use a
30
+ * plain element with class="column-margin".
31
+ */
32
+ import Figure from './Figure.astro';
33
+
34
+ interface Props {
35
+ src: string;
36
+ caption?: string;
37
+ /** Defaults to 100% of the gutter column (not Figure's page-width default). */
38
+ width?: string;
39
+ id?: string;
40
+ alt?: string;
41
+ /** Long-form description → SVG <desc> (alt stays the short accessible name). */
42
+ desc?: string;
43
+ }
44
+
45
+ const { src, caption, width = '100%', id, alt, desc } = Astro.props;
46
+ ---
47
+ <aside class="margin-figure" role="group">
48
+ <Figure src={src} caption={caption} width={width} id={id} alt={alt} desc={desc} />
49
+ </aside>
@@ -0,0 +1,18 @@
1
+ ---
2
+ /**
3
+ * Newthought — Tufte run-in section opener. Maps LaTeX `\newthought{…}`:
4
+ * the first few words of a new line of thought set in small-caps, used in
5
+ * place of (or alongside) a subheading to mark a shift without breaking the
6
+ * paragraph flow.
7
+ *
8
+ * TRUE small-caps via `font-feature-settings: "smcp"` (NOT
9
+ * `font-variant: small-caps`, which synthesizes faux small-caps by scaling
10
+ * the capitals) — the Roboto variable face ships the smcp feature. CSS lives
11
+ * in the shared typography.css (always loaded via every profile's styles
12
+ * array), so this component is markup-only.
13
+ *
14
+ * Usage (inline at the start of a paragraph):
15
+ * <p><Newthought>In practice</Newthought>, the kernel is computed once …</p>
16
+ */
17
+ ---
18
+ <span class="newthought"><slot /></span>
@@ -0,0 +1,56 @@
1
+ ---
2
+ /**
3
+ * SectionMap.astro — the right-gutter sticky "On this page" section map
4
+ * (#section-map).
5
+ *
6
+ * Renders a real anchor list (h2/h3 slug links) into the .prose right gutter and
7
+ * mounts the SectionMap island (client:idle) as a controller over it. The island
8
+ * observes the heading elements and lights the active link as the reader scrolls
9
+ * (scrollspy). The collapsed top ChapterTOC remains the mobile/no-JS fallback —
10
+ * section-map.css hides THIS nav below 64rem (the gutter breakpoint) and hides
11
+ * ChapterTOC at/above it, so the two never double-show.
12
+ *
13
+ * Gated identically to ChapterTOC: renders nothing below 3 matching headings
14
+ * (an anchor list for a 1–2 heading chapter is clutter). Uses the shared
15
+ * `tocHeadings` filter so the gutter map and the fallback carry IDENTICAL
16
+ * entries. The island is imported via the package exports map (consumers must
17
+ * not compile raw .tsx out of node_modules — same as practice-exam.astro's
18
+ * ExamRunner import).
19
+ *
20
+ * DOM contract (see SectionMap.tsx): the <nav> is [data-section-map-root]; each
21
+ * link is a[data-section-id="<slug>"]; the island mounts INSIDE the nav so its
22
+ * closest() finds the root.
23
+ */
24
+ import type { MarkdownHeading } from 'astro';
25
+ import { tocHeadings } from '../src/lib/section-map';
26
+ // Pre-compiled island via the exports map — consumers must not compile raw
27
+ // .tsx out of node_modules (same as practice-exam.astro's ExamRunner import).
28
+ import SectionMap from '@brandon_m_behring/book-scaffold-astro/components/SectionMap';
29
+ import '../styles/section-map.css';
30
+
31
+ interface Props {
32
+ headings: MarkdownHeading[];
33
+ /** Optional context label, e.g. "Part II · Ch 4". Omitted when not derivable. */
34
+ label?: string;
35
+ }
36
+ const { headings, label } = Astro.props;
37
+
38
+ const toc = tocHeadings(headings);
39
+ const showMap = toc.length >= 3;
40
+ ---
41
+ {showMap && (
42
+ <nav class="section-map" data-section-map-root aria-label="On this page">
43
+ {label && <p class="section-map-label">{label}</p>}
44
+ <p class="section-map-title">On this page</p>
45
+ <ol class="section-map-list">
46
+ {toc.map((h) => (
47
+ <li class={`toc-h${h.depth}`}>
48
+ <a data-section-id={h.slug} href={`#${h.slug}`} class={`toc-h${h.depth}`}>
49
+ {h.text}
50
+ </a>
51
+ </li>
52
+ ))}
53
+ </ol>
54
+ <SectionMap client:idle headings={toc} />
55
+ </nav>
56
+ )}
@@ -0,0 +1,170 @@
1
+ /**
2
+ * SectionMap — Preact island driving the right-gutter "On this page" scrollspy
3
+ * (#section-map).
4
+ *
5
+ * Architecture: a CONTROLLER over server-rendered nav links, not a renderer.
6
+ * SectionMap.astro renders the <nav data-section-map-root> with one
7
+ * `a[data-section-id="<slug>"]` per h2/h3 (so the nav is a real anchor list with
8
+ * no JS — but on mobile/no-JS the collapsed ChapterTOC is the fallback and this
9
+ * gutter nav is CSS-hidden below 64rem). This island receives only the pure
10
+ * heading manifest (slug/depth/text), observes the matching heading ELEMENTS
11
+ * (`#<slug>`) in the document via IntersectionObserver, computes the active slug
12
+ * with the pure `pickActive`, and sets `aria-current="page"` + an `active` class
13
+ * on the corresponding link (clearing the others). It NEVER injects markup, so
14
+ * there's zero layout shift — only a class/attribute toggle on existing links.
15
+ *
16
+ * DOM contract with SectionMap.astro:
17
+ * [data-section-map-root] the SSR <nav> wrapper (ancestor of this
18
+ * island's mount point)
19
+ * a[data-section-id="<slug>"] one link per heading; gains/loses
20
+ * aria-current="page" + class "active"
21
+ * #<slug> the heading element in the article body
22
+ * (Astro's MDX slug) — observed, not owned
23
+ *
24
+ * Fail-loud (house invariant, like ExamRunner/Flashcards): a missing wrapper
25
+ * throws. A heading id with no element in the document is skipped gracefully
26
+ * (the TOC filter can include a heading Astro slugged differently / a deferred
27
+ * section), BUT if NONE of the manifest's headings resolve to an element, that's
28
+ * manifest/DOM drift and we throw rather than mount a silently dead nav.
29
+ *
30
+ * No animation → no prefers-reduced-motion handling needed (instant class
31
+ * toggle). No canvas → no book:theme:change listener (CSS tokens recolor the
32
+ * nav from [data-theme] automatically). Hydrated with `client:idle`.
33
+ */
34
+ import { useEffect, useRef } from 'preact/hooks';
35
+ import { pickActive, type VisibleHeading } from '../src/lib/section-map';
36
+ import type { MarkdownHeading } from 'astro';
37
+
38
+ interface Props {
39
+ /** TOC headings (tocHeadings output) — slug/depth/text only, serializable. */
40
+ headings: MarkdownHeading[];
41
+ }
42
+
43
+ export default function SectionMap({ headings }: Props) {
44
+ const ref = useRef<HTMLSpanElement>(null);
45
+
46
+ useEffect(() => {
47
+ const root = ref.current?.closest<HTMLElement>('[data-section-map-root]');
48
+ if (!root) {
49
+ // Fail loud: a silently dead scrollspy (no active highlight, ever) is the
50
+ // worst failure mode — surface it as an uncaught console error.
51
+ throw new Error(
52
+ 'SectionMap: no [data-section-map-root] ancestor — mount the island inside ' +
53
+ 'the <nav> that contains its links (see the DOM contract in SectionMap.tsx).',
54
+ );
55
+ }
56
+
57
+ // Resolve each manifest heading to its body element. CSS.escape: a slug with
58
+ // an unusual char would otherwise break the selector and throw a DOMException
59
+ // mid-observe (a frozen scrollspy).
60
+ type Tracked = { slug: string; el: Element };
61
+ const tracked: Tracked[] = [];
62
+ for (const h of headings) {
63
+ const el = document.getElementById(h.slug);
64
+ if (el) tracked.push({ slug: h.slug, el });
65
+ // else: skip gracefully — a filtered heading Astro slugged differently or
66
+ // a section not in this document. The drift guard below catches a TOTAL miss.
67
+ }
68
+ if (tracked.length === 0) {
69
+ // None resolved → manifest/DOM drift (mirror ExamRunner). A nav whose every
70
+ // link points at a missing anchor must not pretend to work.
71
+ throw new Error(
72
+ `SectionMap: manifest/DOM drift — none of the ${headings.length} heading id(s) ` +
73
+ `resolve to an element (#${headings.map((h) => h.slug).join(', #')}).`,
74
+ );
75
+ }
76
+ // The last tracked heading in document order (bound here, where the length
77
+ // guard above proves it exists — `at(-1)`'s type is still `Tracked | undefined`
78
+ // under noUncheckedIndexedAccess, so narrow it once). Used by the
79
+ // scrolled-to-bottom path to force the final section active.
80
+ const lastTracked = tracked[tracked.length - 1] as Tracked;
81
+
82
+ // Link lookup by slug (CSS.escape on every slug used in a selector).
83
+ function linkFor(slug: string): HTMLElement | null {
84
+ return root!.querySelector<HTMLElement>(
85
+ `a[data-section-id="${CSS.escape(slug)}"]`,
86
+ );
87
+ }
88
+
89
+ // Which tracked headings are currently intersecting; recompute reads `tracked`
90
+ // (document order) and filters by membership so `visible` is document-ordered
91
+ // too — pickActive's "ties resolve to document order" then actually holds.
92
+ const inView = new Set<Element>();
93
+ let active: string | null = null;
94
+
95
+ // Set the active slug: clear the previous link, light the next one. Toggle
96
+ // ONLY class/attr on existing SSR links → no layout shift. Shared by the
97
+ // observer path (via recompute) and the scrolled-to-bottom path below.
98
+ function setActive(next: string | null): void {
99
+ if (next === active) return;
100
+ if (active !== null) {
101
+ const prevLink = linkFor(active);
102
+ if (prevLink) {
103
+ prevLink.classList.remove('active');
104
+ prevLink.removeAttribute('aria-current');
105
+ }
106
+ }
107
+ if (next !== null) {
108
+ const nextLink = linkFor(next);
109
+ if (nextLink) {
110
+ nextLink.classList.add('active');
111
+ nextLink.setAttribute('aria-current', 'page');
112
+ }
113
+ }
114
+ active = next;
115
+ }
116
+
117
+ function recompute(): void {
118
+ // Iterate `tracked` (document order) filtering by inView — NOT the Set
119
+ // (insertion order) — so equal-top ties resolve to document order.
120
+ const visible: VisibleHeading[] = [];
121
+ for (const t of tracked) {
122
+ if (inView.has(t.el)) {
123
+ visible.push({ slug: t.slug, top: t.el.getBoundingClientRect().top });
124
+ }
125
+ }
126
+ setActive(pickActive(visible, active));
127
+ }
128
+
129
+ const observer = new IntersectionObserver(
130
+ (entries) => {
131
+ for (const entry of entries) {
132
+ if (entry.isIntersecting) inView.add(entry.target);
133
+ else inView.delete(entry.target);
134
+ }
135
+ recompute();
136
+ },
137
+ // A negative bottom margin biases "active" toward the heading nearest the
138
+ // top of the viewport (the section you're reading INTO), not the last one
139
+ // peeking in at the bottom.
140
+ { rootMargin: '0px 0px -70% 0px', threshold: 0 },
141
+ );
142
+ for (const t of tracked) observer.observe(t.el);
143
+
144
+ // Last-section reachability: the -70% bottom margin confines "active" to the
145
+ // top 30% of the viewport, so a final heading with little content below it
146
+ // can never scroll INTO that zone — it would never light. When the page is
147
+ // scrolled to the bottom, force the LAST tracked heading active so the foot
148
+ // of the chapter always highlights its own section. Passive listeners (no
149
+ // preventDefault) keep scrolling smooth.
150
+ function onScroll(): void {
151
+ const atBottom =
152
+ window.innerHeight + window.scrollY >=
153
+ document.documentElement.scrollHeight - 2;
154
+ if (atBottom) setActive(lastTracked.slug);
155
+ }
156
+ window.addEventListener('scroll', onScroll, { passive: true });
157
+ window.addEventListener('resize', onScroll, { passive: true });
158
+
159
+ return () => {
160
+ observer.disconnect();
161
+ window.removeEventListener('scroll', onScroll);
162
+ window.removeEventListener('resize', onScroll);
163
+ };
164
+ }, [headings]);
165
+
166
+ // A zero-footprint anchor: the island renders only this <span> so it can find
167
+ // [data-section-map-root] via closest(); all visible nav links are SSR'd by
168
+ // SectionMap.astro. No markup injected → no layout shift.
169
+ return <span ref={ref} class="section-map-mount" hidden aria-hidden="true" />;
170
+ }
@@ -1,6 +1,6 @@
1
1
  import * as preact from 'preact';
2
- import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-DbTHo90M.js';
3
- import '../schemas-DDWDRUxs.js';
2
+ import { a as ExamQuestion, R as RoutingChapter } from '../exam-manifest-DnqyzZ9I.js';
3
+ import '../schemas-CKipJ5Ie.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  interface Props {
@@ -1,6 +1,6 @@
1
1
  import * as preact from 'preact';
2
- import { F as FlashcardRef } from '../flashcards-DPFFQhP8.js';
3
- import '../schemas-DDWDRUxs.js';
2
+ import { F as FlashcardRef } from '../flashcards-okekZcl8.js';
3
+ import '../schemas-CKipJ5Ie.js';
4
4
  import 'astro/zod';
5
5
 
6
6
  interface Props {
@@ -0,0 +1,10 @@
1
+ import * as preact from 'preact';
2
+ import { MarkdownHeading } from 'astro';
3
+
4
+ interface Props {
5
+ /** TOC headings (tocHeadings output) — slug/depth/text only, serializable. */
6
+ headings: MarkdownHeading[];
7
+ }
8
+ declare function SectionMap({ headings }: Props): preact.JSX.Element;
9
+
10
+ export { SectionMap as default };
@@ -0,0 +1,107 @@
1
+ // components/SectionMap.tsx
2
+ import { useEffect, useRef } from "preact/hooks";
3
+
4
+ // src/lib/section-map.ts
5
+ function pickActive(visible, prev) {
6
+ if (visible.length === 0) return prev;
7
+ let bestNonNeg = null;
8
+ let bestAbove = null;
9
+ for (const h of visible) {
10
+ if (h.top >= 0) {
11
+ if (bestNonNeg === null || h.top < bestNonNeg.top) bestNonNeg = h;
12
+ } else {
13
+ if (bestAbove === null || h.top > bestAbove.top) bestAbove = h;
14
+ }
15
+ }
16
+ if (bestNonNeg !== null) return bestNonNeg.slug;
17
+ if (bestAbove !== null) return bestAbove.slug;
18
+ return prev;
19
+ }
20
+
21
+ // components/SectionMap.tsx
22
+ import { jsx } from "preact/jsx-runtime";
23
+ function SectionMap({ headings }) {
24
+ const ref = useRef(null);
25
+ useEffect(() => {
26
+ const root = ref.current?.closest("[data-section-map-root]");
27
+ if (!root) {
28
+ throw new Error(
29
+ "SectionMap: no [data-section-map-root] ancestor \u2014 mount the island inside the <nav> that contains its links (see the DOM contract in SectionMap.tsx)."
30
+ );
31
+ }
32
+ const tracked = [];
33
+ for (const h of headings) {
34
+ const el = document.getElementById(h.slug);
35
+ if (el) tracked.push({ slug: h.slug, el });
36
+ }
37
+ if (tracked.length === 0) {
38
+ throw new Error(
39
+ `SectionMap: manifest/DOM drift \u2014 none of the ${headings.length} heading id(s) resolve to an element (#${headings.map((h) => h.slug).join(", #")}).`
40
+ );
41
+ }
42
+ const lastTracked = tracked[tracked.length - 1];
43
+ function linkFor(slug) {
44
+ return root.querySelector(
45
+ `a[data-section-id="${CSS.escape(slug)}"]`
46
+ );
47
+ }
48
+ const inView = /* @__PURE__ */ new Set();
49
+ let active = null;
50
+ function setActive(next) {
51
+ if (next === active) return;
52
+ if (active !== null) {
53
+ const prevLink = linkFor(active);
54
+ if (prevLink) {
55
+ prevLink.classList.remove("active");
56
+ prevLink.removeAttribute("aria-current");
57
+ }
58
+ }
59
+ if (next !== null) {
60
+ const nextLink = linkFor(next);
61
+ if (nextLink) {
62
+ nextLink.classList.add("active");
63
+ nextLink.setAttribute("aria-current", "page");
64
+ }
65
+ }
66
+ active = next;
67
+ }
68
+ function recompute() {
69
+ const visible = [];
70
+ for (const t of tracked) {
71
+ if (inView.has(t.el)) {
72
+ visible.push({ slug: t.slug, top: t.el.getBoundingClientRect().top });
73
+ }
74
+ }
75
+ setActive(pickActive(visible, active));
76
+ }
77
+ const observer = new IntersectionObserver(
78
+ (entries) => {
79
+ for (const entry of entries) {
80
+ if (entry.isIntersecting) inView.add(entry.target);
81
+ else inView.delete(entry.target);
82
+ }
83
+ recompute();
84
+ },
85
+ // A negative bottom margin biases "active" toward the heading nearest the
86
+ // top of the viewport (the section you're reading INTO), not the last one
87
+ // peeking in at the bottom.
88
+ { rootMargin: "0px 0px -70% 0px", threshold: 0 }
89
+ );
90
+ for (const t of tracked) observer.observe(t.el);
91
+ function onScroll() {
92
+ const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
93
+ if (atBottom) setActive(lastTracked.slug);
94
+ }
95
+ window.addEventListener("scroll", onScroll, { passive: true });
96
+ window.addEventListener("resize", onScroll, { passive: true });
97
+ return () => {
98
+ observer.disconnect();
99
+ window.removeEventListener("scroll", onScroll);
100
+ window.removeEventListener("resize", onScroll);
101
+ };
102
+ }, [headings]);
103
+ return /* @__PURE__ */ jsx("span", { ref, class: "section-map-mount", hidden: true, "aria-hidden": "true" });
104
+ }
105
+ export {
106
+ SectionMap as default
107
+ };
@@ -1,4 +1,4 @@
1
- import { Q as Question } from './schemas-DDWDRUxs.js';
1
+ import { Q as Question } from './schemas-CKipJ5Ie.js';
2
2
 
3
3
  /**
4
4
  * exam-engine.ts — PURE sampling + scoring for the interactive practice exam
@@ -1,4 +1,4 @@
1
- import { G as GlossaryTerm } from './schemas-DDWDRUxs.js';
1
+ import { G as GlossaryTerm } from './schemas-CKipJ5Ie.js';
2
2
 
3
3
  /**
4
4
  * flashcards.ts — PURE deck-manifest bridge between the `glossary` collection