@brandon_m_behring/book-scaffold-astro 4.23.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.
Files changed (42) hide show
  1. package/CLAUDE.md +9 -1
  2. package/components/ChapterNav.astro +3 -2
  3. package/components/ChapterTOC.astro +8 -1
  4. package/components/Cite.astro +2 -1
  5. package/components/Epigraph.astro +28 -0
  6. package/components/EvidenceTag.astro +102 -0
  7. package/components/MarginFigure.astro +49 -0
  8. package/components/Newthought.astro +18 -0
  9. package/components/PartReview.astro +2 -1
  10. package/components/Rationale.astro +1 -1
  11. package/components/SectionMap.astro +56 -0
  12. package/components/SectionMap.tsx +170 -0
  13. package/components/Sidebar.astro +10 -7
  14. package/components/Term.astro +2 -1
  15. package/components/TipsCard.astro +2 -1
  16. package/dist/components/ExamRunner.d.ts +2 -2
  17. package/dist/components/Flashcards.d.ts +2 -2
  18. package/dist/components/SectionMap.d.ts +10 -0
  19. package/dist/components/SectionMap.mjs +107 -0
  20. package/dist/{exam-manifest-DbTHo90M.d.ts → exam-manifest-DnqyzZ9I.d.ts} +1 -1
  21. package/dist/{flashcards-DPFFQhP8.d.ts → flashcards-okekZcl8.d.ts} +1 -1
  22. package/dist/index.d.ts +71 -8
  23. package/dist/index.mjs +38 -5
  24. package/dist/{schemas-DDWDRUxs.d.ts → schemas-CKipJ5Ie.d.ts} +22 -1
  25. package/dist/schemas.d.ts +2 -2
  26. package/dist/schemas.mjs +15 -5
  27. package/dist/{types-CGN95mrX.d.ts → types-Bn7rKhgP.d.ts} +1 -1
  28. package/layouts/Base.astro +4 -3
  29. package/layouts/Chapter.astro +36 -1
  30. package/package.json +11 -1
  31. package/recipes/04-component-library.md +6 -2
  32. package/recipes/16-tikz-figures.md +39 -0
  33. package/src/lib/section-map.ts +88 -0
  34. package/src/profiles/academic.ts +1 -1
  35. package/src/profiles/course-notes.ts +1 -1
  36. package/src/profiles/minimal.ts +1 -1
  37. package/src/profiles/research-portfolio.ts +1 -1
  38. package/src/profiles/tools.ts +1 -0
  39. package/src/schemas.ts +15 -0
  40. package/styles/layout.css +64 -0
  41. package/styles/section-map.css +130 -0
  42. package/styles/typography.css +51 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@brandon_m_behring/book-scaffold-astro",
3
3
  "description": "Astro 6 + MDX toolkit for long-form technical books. Profile-aware (academic / tools / minimal); ships Tufte typography, KaTeX, BibTeX citations, Pagefind, Cloudflare Workers deploy. See PACKAGE_DESIGN.md for the API contract.",
4
- "version": "4.23.0",
4
+ "version": "4.25.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Brandon Behring",
@@ -59,13 +59,17 @@
59
59
  "./components/Diagnostic.astro": "./components/Diagnostic.astro",
60
60
  "./components/Divergence.astro": "./components/Divergence.astro",
61
61
  "./components/DynConnect.astro": "./components/DynConnect.astro",
62
+ "./components/Epigraph.astro": "./components/Epigraph.astro",
63
+ "./components/EvidenceTag.astro": "./components/EvidenceTag.astro",
62
64
  "./components/ExampleBox.astro": "./components/ExampleBox.astro",
63
65
  "./components/Exercise.astro": "./components/Exercise.astro",
64
66
  "./components/ExerciseSolutions.astro": "./components/ExerciseSolutions.astro",
65
67
  "./components/Figure.astro": "./components/Figure.astro",
66
68
  "./components/InsightBox.astro": "./components/InsightBox.astro",
67
69
  "./components/KeyIdea.astro": "./components/KeyIdea.astro",
70
+ "./components/MarginFigure.astro": "./components/MarginFigure.astro",
68
71
  "./components/MarginNote.astro": "./components/MarginNote.astro",
72
+ "./components/Newthought.astro": "./components/Newthought.astro",
69
73
  "./components/NoteBox.astro": "./components/NoteBox.astro",
70
74
  "./components/ObjectiveMap.astro": "./components/ObjectiveMap.astro",
71
75
  "./components/OpenQuestion.astro": "./components/OpenQuestion.astro",
@@ -82,6 +86,7 @@
82
86
  "./components/Rationale.astro": "./components/Rationale.astro",
83
87
  "./components/Recovery.astro": "./components/Recovery.astro",
84
88
  "./components/ResultBox.astro": "./components/ResultBox.astro",
89
+ "./components/SectionMap.astro": "./components/SectionMap.astro",
85
90
  "./components/Sidebar.astro": "./components/Sidebar.astro",
86
91
  "./components/Sidenote.astro": "./components/Sidenote.astro",
87
92
  "./components/SkillBox.astro": "./components/SkillBox.astro",
@@ -111,6 +116,10 @@
111
116
  "types": "./dist/components/Flashcards.d.ts",
112
117
  "import": "./dist/components/Flashcards.mjs"
113
118
  },
119
+ "./components/SectionMap": {
120
+ "types": "./dist/components/SectionMap.d.ts",
121
+ "import": "./dist/components/SectionMap.mjs"
122
+ },
114
123
  "./components/WarnBox.astro": "./components/WarnBox.astro",
115
124
  "./components/WeekRef.astro": "./components/WeekRef.astro",
116
125
  "./components/WorkedExample.astro": "./components/WorkedExample.astro",
@@ -127,6 +136,7 @@
127
136
  "./styles/tool-filter.css": "./styles/tool-filter.css",
128
137
  "./styles/exam-runner.css": "./styles/exam-runner.css",
129
138
  "./styles/flashcards.css": "./styles/flashcards.css",
139
+ "./styles/section-map.css": "./styles/section-map.css",
130
140
  "./layouts/Base.astro": "./layouts/Base.astro",
131
141
  "./layouts/Chapter.astro": "./layouts/Chapter.astro",
132
142
  "./lib": {
@@ -65,8 +65,12 @@ Supported `type` values: `theorem`, `proposition`, `lemma`, `corollary`, `defini
65
65
  | `Cite` | Inline citation linked to `/references` | `<Cite key="gu2024mamba" page="3" />` |
66
66
  | `XRef` | Cross-reference to a labeled element | `<XRef id="thm:zoh-stability" />` |
67
67
  | `Figure` | Image/SVG + caption + id; local SVGs inline for a11y + dark mode (`alt`, `desc`) | `<Figure src="/figures/week04/eigenvalues.svg" caption="…" alt="…" id="fig-eig" />` |
68
- | `MarginNote` | Right-margin annotation (Tufte-style) | `<MarginNote>side comment</MarginNote>` |
69
- | `Sidenote` | Auto-numbered marginalia | `<Sidenote>numbered note</Sidenote>` |
68
+ | `MarginFigure` | A `Figure` that **floats into the right gutter** (Tufte margin figure; ≥64rem, inline on mobile). Same props as `Figure`; rendering delegated to it. For full-bleed use `<Figure class="wide">` | `<MarginFigure src="/figures/week04/eig.svg" caption="…" alt="…" id="fig-eig-m" />` |
69
+ | `MarginNote` | **Inline** colored callout in the text column (does **not** float to the margin, despite the name) — load-bearing aside the reader must see | `<MarginNote>side comment</MarginNote>` |
70
+ | `Sidenote` | Auto-numbered Tufte marginalia that **floats into the right gutter** on desktop (inline on mobile) | `<Sidenote>numbered note</Sidenote>` |
71
+ | `EvidenceTag` | Inline claim-confidence pill after a claim (`verified`/`inference`/`audit-corrected`) | `<EvidenceTag kind="verified" /> … <EvidenceTag kind="inference" />` |
72
+ | `Newthought` | Tufte run-in small-caps section opener | `<p><Newthought>In practice</Newthought>, …</p>` |
73
+ | `Epigraph` | Chapter-opening italic quotation with right-aligned attribution | `<Epigraph>…<Fragment slot="attribution">Hamming</Fragment></Epigraph>` |
70
74
  | `WeekRef` | Jump-link to a week chapter | `<WeekRef week={4} />` |
71
75
  | `CodeRef` | GitHub-deep-link to file:line | `<CodeRef path="experiments/jax/foo.py" line={42} />` |
72
76
  | `CodeBlock` | Embed code-file range with syntax highlight | `<CodeBlock src="…" lines="10-30" />` |
@@ -95,6 +95,45 @@ Common failures:
95
95
 
96
96
  If you hit friction with the TikZ pipeline (a TikZ feature that doesn't compile, an obscure error message, a workflow pattern that doesn't fit), file an issue at https://github.com/brandon-behring/book-scaffold-astro/issues with the `consumer:<your-workspace>` label. v4.x is the iteration window.
97
97
 
98
+ ## Margin & full-width figure placement (1d)
99
+
100
+ Once a figure exists, three placements are available — all reuse the existing Tufte float + gutter technique (the same one `Sidenote`/SectionMap use); none introduce a grid.
101
+
102
+ **In the running text (default).** Plain `<Figure>` constrained to the main measure:
103
+
104
+ ```mdx
105
+ <Figure src="/figures/topic/diagram.svg" caption="…" alt="…" id="fig-x" />
106
+ ```
107
+
108
+ **In the right margin** — `<MarginFigure>` floats into the gutter at ≥64rem and reflows inline below on mobile. Same props as `<Figure>` (rendering is delegated to it); `width` defaults to `100%` of the ~28ch gutter column, and the caption shrinks:
109
+
110
+ ```mdx
111
+ import MarginFigure from '@brandon_m_behring/book-scaffold-astro/components/MarginFigure.astro';
112
+
113
+ <MarginFigure src="/figures/topic/diagram.svg" caption="A small aside figure." alt="…" id="fig-x-margin" />
114
+ ```
115
+
116
+ **Full-bleed** — a wide figure/table/code block that spans the gutter column too. Use the canonical `.wide` escape (or its alias `.column-page`):
117
+
118
+ ```mdx
119
+ <Figure src="/figures/topic/wide-plot.svg" caption="…" alt="…" id="fig-wide" class="wide" />
120
+ ```
121
+
122
+ **Generic gutter block** — to float a non-figure block (a small note, a key figure number) into the margin, add `class="column-margin"` to any element. It uses the identical gutter float.
123
+
124
+ ### Per-page width knob: `layout: wide`
125
+
126
+ For a chapter that's figure- or table-heavy, widen the **main text measure** via an optional frontmatter field (closed enum `default` | `wide`, accepted by every profile):
127
+
128
+ ```yaml
129
+ ---
130
+ title: "Architecture Atlas"
131
+ layout: wide # widens --measure-main to 80ch; omit (or 'default') for the standard 65ch
132
+ ---
133
+ ```
134
+
135
+ `Chapter.astro` threads this as `data-layout="wide"` on `<article class="prose">`, and `layout.css` maps `.prose[data-layout="wide"] { --measure-main: 80ch }`. Omitting the field (or setting `layout: default`) emits no attribute and applies no rule — existing chapters render unchanged. A value outside the enum fails the build at content load.
136
+
98
137
  ## See also
99
138
 
100
139
  - `recipes/06-figures.md` — overall figure pipeline + matplotlib/svg sources
@@ -0,0 +1,88 @@
1
+ /**
2
+ * section-map.ts — PURE heading-selection + scrollspy logic for the right-gutter
3
+ * "On this page" section map (#section-map).
4
+ *
5
+ * No DOM, no Preact: this is the testable core under both ChapterTOC.astro (the
6
+ * collapsed mobile fallback) and the SectionMap island (the sticky gutter nav).
7
+ * Two pure, total functions:
8
+ *
9
+ * - tocHeadings: the ONE filter (h2+h3) that both the fallback TOC and the
10
+ * gutter map share — a single source of truth so the two
11
+ * never disagree about which headings are "on this page".
12
+ * - pickActive: given the headings currently intersecting the viewport (slug
13
+ * + viewport-relative top), choose which one is "active". The
14
+ * island feeds it IntersectionObserver state; node:test feeds
15
+ * it plain arrays. Browser geometry stays OUT of this file.
16
+ *
17
+ * Both are unit-tested in tests/section-map.test.mjs (node:test, no browser),
18
+ * mirroring exam-engine.ts.
19
+ */
20
+
21
+ import type { MarkdownHeading } from 'astro';
22
+
23
+ /**
24
+ * Filter a chapter's headings to the TOC set: depth 2–3 only. h1 is the chapter
25
+ * title (rendered by ChapterHeader, not the body) and h4+ is noise in an anchor
26
+ * list. This is the shared contract — ChapterTOC.astro and SectionMap.astro both
27
+ * call it so the fallback and the gutter map carry IDENTICAL entries. Pure: it
28
+ * copies (filter) rather than mutating the input.
29
+ */
30
+ export function tocHeadings(headings: MarkdownHeading[]): MarkdownHeading[] {
31
+ return headings.filter((h) => h.depth >= 2 && h.depth <= 3);
32
+ }
33
+
34
+ /**
35
+ * One visible heading, as the island measures it: its slug and its
36
+ * viewport-relative top (CSS pixels — `getBoundingClientRect().top`). A negative
37
+ * `top` means the heading has scrolled above the top of the viewport.
38
+ */
39
+ export interface VisibleHeading {
40
+ slug: string;
41
+ /** Viewport-relative top in px (negative = scrolled above the fold). */
42
+ top: number;
43
+ }
44
+
45
+ /**
46
+ * Choose the active section slug from the headings currently intersecting the
47
+ * viewport.
48
+ *
49
+ * Rule (a stable, total scrollspy):
50
+ * 1. Prefer the topmost heading at or below the top of the viewport — the
51
+ * visible heading with the SMALLEST non-negative `top`. That's the section
52
+ * the reader has just scrolled to / is reading into.
53
+ * 2. If every visible heading is above the fold (all `top` negative — e.g. a
54
+ * heading STRADDLING the top edge: its top is slightly negative but its box
55
+ * still overlaps the top zone, so it's intersecting yet has no non-negative
56
+ * sibling below it), fall back to the one nearest the fold from above: the
57
+ * GREATEST `top` (closest to 0 from the negative side). That keeps the
58
+ * enclosing section lit instead of going dark mid-section. (A heading whose
59
+ * box FULLY scrolled above the top has left `inView` entirely — it isn't in
60
+ * `visible` at all, and is handled by the empty-set → `prev` branch below.)
61
+ * 3. If NOTHING is visible (the observer reports an empty set — between two
62
+ * sparse intersections, or scrolled past the last heading), retain `prev`
63
+ * so the highlight is sticky rather than flickering off.
64
+ *
65
+ * Pure + total: no DOM, no throw, deterministic. Ties (equal `top`) resolve to
66
+ * the first in iteration order, which the island passes in document order.
67
+ */
68
+ export function pickActive(
69
+ visible: ReadonlyArray<VisibleHeading>,
70
+ prev: string | null,
71
+ ): string | null {
72
+ if (visible.length === 0) return prev;
73
+
74
+ let bestNonNeg: VisibleHeading | null = null; // smallest top >= 0
75
+ let bestAbove: VisibleHeading | null = null; // greatest top < 0
76
+ for (const h of visible) {
77
+ if (h.top >= 0) {
78
+ if (bestNonNeg === null || h.top < bestNonNeg.top) bestNonNeg = h;
79
+ } else {
80
+ if (bestAbove === null || h.top > bestAbove.top) bestAbove = h;
81
+ }
82
+ }
83
+
84
+ if (bestNonNeg !== null) return bestNonNeg.slug;
85
+ // All above the fold: the one just above the top edge keeps its section lit.
86
+ if (bestAbove !== null) return bestAbove.slug;
87
+ return prev;
88
+ }
@@ -31,7 +31,7 @@ export const academicProfile = defineProfile({
31
31
  flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
32
32
  landing: true, // v4.5.0: auto-inject minimal root landing; consumers override via src/pages/index.astro
33
33
  },
34
- styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
34
+ styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css', 'section-map.css'],
35
35
  katex: true,
36
36
  chaptersRenderer: academicChaptersRenderer, // v3.7.0 (#35) — owns /chapters semantics if consumer opts in via routes.chapters
37
37
  // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
@@ -33,7 +33,7 @@ export const courseNotesProfile = defineProfile({
33
33
  flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
34
34
  landing: true, // v4.5.0: auto-inject minimal root landing
35
35
  },
36
- styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
36
+ styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css', 'section-map.css'],
37
37
  // v3.7.0 (#35): course-notes schema has tools-style fields (chapter, volatility, sources) — fallback renderer dispatches via tools renderer
38
38
  chaptersRenderer: fallbackChaptersRenderer,
39
39
  // v4.6.0 (#76 Secondary): exclude /print/ from sitemap — print-friendly
@@ -28,7 +28,7 @@ export const minimalProfile = defineProfile({
28
28
  flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
29
29
  landing: true, // v4.5.0: auto-inject minimal root landing
30
30
  },
31
- styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
31
+ styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css', 'section-map.css'],
32
32
  // v3.7.0 (#35): minimal aliases tools schema; fallback renderer field-dispatches if a consumer opts into routes.chapters
33
33
  chaptersRenderer: fallbackChaptersRenderer,
34
34
  });
@@ -44,7 +44,7 @@ export const researchPortfolioProfile = defineProfile({
44
44
  flashcards: false, // v4.22.0 #116: opt-in per book; requires src/content/glossary/
45
45
  landing: true, // v4.5.0: auto-inject minimal root landing
46
46
  },
47
- styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css'],
47
+ styles: ['tokens.css', 'layout.css', 'callouts.css', 'chapter.css', 'typography.css', 'print.css', 'section-map.css'],
48
48
  katex: true, // math is common in research content
49
49
  // v3.7.0 (#35): portfolio schema is a union of academic + tools shapes — fallback renderer dispatches per chapter via field presence
50
50
  chaptersRenderer: fallbackChaptersRenderer,
@@ -31,6 +31,7 @@ export const toolsProfile = defineProfile({
31
31
  styles: [
32
32
  'tokens.css', 'layout.css', 'callouts.css', 'chapter.css',
33
33
  'typography.css', 'print.css', 'convergence.css', 'tool-filter.css',
34
+ 'section-map.css',
34
35
  ],
35
36
  chaptersRenderer: toolsChaptersRenderer, // v3.7.0 (#35) — owns /chapters semantics for tools shape
36
37
  });
package/src/schemas.ts CHANGED
@@ -70,6 +70,17 @@ export const chapterStatus = [
70
70
  'planned',
71
71
  ] as const;
72
72
 
73
+ // ===== Per-page width knob (1d) — any profile =====
74
+ //
75
+ // Optional `layout:` on EVERY chapter schema below. A CLOSED enum (a typo'd
76
+ // value fails loud at content load — that IS the validation, no assertEnumProp
77
+ // needed). Threaded by Chapter.astro as `data-layout` on <article class="prose">;
78
+ // layout.css maps `[data-layout="wide"]` → a wider --measure-main. ADDITIVE:
79
+ // 'default' (or an omitted field) applies no rule, so existing chapters render
80
+ // byte-identically. 'wide' widens the main text measure (e.g. 80ch) for
81
+ // figure-heavy or table-heavy chapters.
82
+ export const layoutModes = ['default', 'wide'] as const;
83
+
73
84
  // ===== Provenance (v4.8.0) — process-as-artifact audit trail =====
74
85
  //
75
86
  // Optional per-chapter block attached to EVERY profile schema below.
@@ -117,6 +128,7 @@ export const academicChapterSchema = z.object({
117
128
  part: z.enum(academicParts),
118
129
  title: z.string().min(1),
119
130
  slug: z.string().optional(), // v4.9.0: explicit URL slug override (else filename → entry.id)
131
+ layout: z.enum(layoutModes).optional(), // 1d: per-page width knob (default measure | 'wide'); threaded as data-layout
120
132
  status: z.enum(chapterStatus),
121
133
  roadmap_lines: z.tuple([z.number().int(), z.number().int()]).optional(),
122
134
  code_path: z.string().optional(),
@@ -138,6 +150,7 @@ export const academicChapterSchema = z.object({
138
150
  export const toolsChapterSchema = z.object({
139
151
  title: z.string().min(1),
140
152
  slug: z.string().optional(), // v4.9.0: explicit URL slug override (else filename → entry.id)
153
+ layout: z.enum(layoutModes).optional(), // 1d: per-page width knob (default measure | 'wide'); threaded as data-layout
141
154
  part: z.number().int().min(0).max(10),
142
155
  chapter: z.number().int().min(0).max(99),
143
156
  volatility: z.enum(volatilityLevels),
@@ -181,6 +194,7 @@ export const courseNotesChapterSchema = z.object({
181
194
  // Identity
182
195
  title: z.string().min(1),
183
196
  slug: z.string().optional(), // v4.9.0: explicit URL slug override (else filename → entry.id)
197
+ layout: z.enum(layoutModes).optional(), // 1d: per-page width knob (default measure | 'wide'); threaded as data-layout
184
198
  chapter: z.number().int().min(0).max(99),
185
199
  part: z.number().int().min(0).max(20).default(1),
186
200
  description: z.string().optional(),
@@ -244,6 +258,7 @@ export const researchPortfolioChapterSchema = z.object({
244
258
  // Identity
245
259
  title: z.string().min(1),
246
260
  slug: z.string().optional(), // explicit slug override (otherwise filename)
261
+ layout: z.enum(layoutModes).optional(), // 1d: per-page width knob (default measure | 'wide'); threaded as data-layout
247
262
  description: z.string().optional(),
248
263
 
249
264
  // Hierarchy — accept either academic-style or tools-style; all optional.
package/styles/layout.css CHANGED
@@ -154,3 +154,67 @@
154
154
  max-width: none !important;
155
155
  width: 100%;
156
156
  }
157
+
158
+ /* ===== 1d: additive figure/content placement + per-page width knob =====
159
+ *
160
+ * STRICTLY ADDITIVE. Nothing here changes `.prose`'s block layout or the
161
+ * `.sidenote` float — the gutter placements below REUSE the same Tufte
162
+ * float + negative-margin-right technique `.sidenote` and SectionMap use
163
+ * (the full float→grid migration is deferred). All values are tokens.
164
+ *
165
+ * .column-margin — generic "float a block into the right gutter" utility
166
+ * (the un-figure version of MarginFigure's placement).
167
+ * .margin-figure — MarginFigure's wrapper: the same gutter float, tuned for
168
+ * a figure (small caption, no italic).
169
+ * .column-page — full-width breakout; an ALIAS of `.wide` (which already
170
+ * does the max-width override). Use either.
171
+ * .prose[data-layout="wide"] — the per-page width knob: widen the main text
172
+ * measure. Only applies when Chapter.astro emits
173
+ * data-layout="wide" from frontmatter `layout: wide`.
174
+ */
175
+
176
+ /* Float a block into the right gutter — same mechanism as .sidenote: the
177
+ * negative margin-right pulls the box into the --measure-side column so it
178
+ * sits BESIDE the running text instead of pushing the measure. Shown only at
179
+ * >= 64rem (the gutter breakpoint, matching SectionMap); below that it reflows
180
+ * inline in document flow (the rule simply doesn't apply). */
181
+ @media (min-width: 64rem) {
182
+ .column-margin,
183
+ .margin-figure {
184
+ float: right;
185
+ clear: right;
186
+ width: var(--measure-side);
187
+ margin-right: calc(-1 * (var(--measure-side) + var(--space-6)));
188
+ margin-top: 0.3rem;
189
+ margin-bottom: var(--space-3);
190
+ }
191
+ }
192
+
193
+ /* MarginFigure: a Figure wrapped for the gutter. The wrapped <figure> fills
194
+ * the column; the caption stays small (margin context = less room) without
195
+ * touching the global .figure/figcaption rules. */
196
+ .margin-figure > .figure {
197
+ margin: 0;
198
+ max-width: none;
199
+ }
200
+ .margin-figure figcaption {
201
+ font-size: var(--text-xs);
202
+ line-height: var(--leading-normal);
203
+ }
204
+
205
+ /* Full-width breakout: an alias of `.wide` (figures, wide tables, code that
206
+ * need the whole container including the gutter column). `.wide` is the
207
+ * canonical escape; `.column-page` reads better next to `.column-margin`. */
208
+ .column-page {
209
+ max-width: none !important;
210
+ width: 100%;
211
+ }
212
+
213
+ /* Per-page width knob (frontmatter `layout: wide`). Widen ONLY the main text
214
+ * measure; the container + gutter math in `.prose` already references
215
+ * --measure-main, so this one override flows through. Additive: absent
216
+ * data-layout (or "default") matches nothing, so existing chapters are
217
+ * unaffected. 80ch matches the wide-desktop tier's main measure (tokens.css). */
218
+ .prose[data-layout="wide"] {
219
+ --measure-main: 80ch;
220
+ }
@@ -0,0 +1,130 @@
1
+ /* section-map.css — right-gutter sticky "On this page" scrollspy (#section-map).
2
+ *
3
+ * The SectionMap island is framework-component-driven markup outside Astro's
4
+ * style scoping, so its nav ships as a plain stylesheet (like exam-runner.css /
5
+ * flashcards.css). Colors via design tokens only — recolors with [data-theme]
6
+ * automatically.
7
+ *
8
+ * Layout: the nav lives in the .prose RIGHT gutter — the same ~28ch column the
9
+ * sidenotes float into (layout.css). It uses the identical float + negative
10
+ * margin-right technique so it sits beside the running text rather than pushing
11
+ * it, and is `position: sticky` so it tracks the reader down the page.
12
+ *
13
+ * Visibility handshake with ChapterTOC (chapter.css): the gutter map shows ONLY
14
+ * at >= 64rem (the sidebar/gutter breakpoint, where there's room for a margin
15
+ * column); the collapsed top ChapterTOC is the < 64rem fallback. Each is hidden
16
+ * in the other's range so they never double-show. At >= 64rem the handshake is
17
+ * ALSO sidenote-aware: the gutter is a single column the sidenotes already float
18
+ * into (layout.css), so a chapter with <Sidenote>s yields it back to them — the
19
+ * map hides and ChapterTOC stays as the nav (see the @media block below, #151).
20
+ */
21
+
22
+ /* Default (< 64rem): the gutter has no room — the collapsed ChapterTOC is the
23
+ * fallback. Hidden until the breakpoint opens it below. */
24
+ .section-map {
25
+ display: none;
26
+ }
27
+
28
+ /* The island's mount node is a zero-footprint anchor; never shown. */
29
+ .section-map-mount {
30
+ display: none !important;
31
+ }
32
+
33
+ @media (min-width: 64rem) {
34
+ /* Sidenote-aware handshake (#151). The gutter map and <Sidenote>s float into
35
+ * the SAME ~28ch right column (layout.css), so they cannot coexist there:
36
+ * - A chapter WITH sidenotes → the gutter belongs to the sidenotes; suppress
37
+ * the map (else its sticky float overlaps the notes on scroll) and keep the
38
+ * collapsed ChapterTOC visible as the nav.
39
+ * - A chapter with NO sidenotes → the gutter map IS the nav; hide the
40
+ * collapsed top ChapterTOC so the two don't double-show (the original
41
+ * handshake).
42
+ * The suppressed .section-map stays in the DOM (display:none) — its island
43
+ * still runs but only toggles hidden links, which is harmless. (:has() is
44
+ * within the scaffold's CSS baseline — tokens.css already uses it.) */
45
+ .prose:has(.sidenote) .section-map {
46
+ display: none;
47
+ }
48
+
49
+ .prose:not(:has(.sidenote)) .chapter-toc {
50
+ display: none;
51
+ }
52
+
53
+ .section-map {
54
+ display: block;
55
+ /* Float into the right margin — same technique as .sidenote: the negative
56
+ * margin-right pulls it into the gutter column so it sits beside the text
57
+ * instead of pushing the measure. */
58
+ float: right;
59
+ clear: right;
60
+ width: var(--measure-side);
61
+ margin-right: calc(-1 * (var(--measure-side) + var(--space-6)));
62
+ margin-top: 0.3rem;
63
+ margin-bottom: var(--space-3);
64
+ padding-left: var(--space-3);
65
+ border-left: var(--border-bar) solid var(--color-border);
66
+ /* Sticky: stay in view as the reader scrolls; cap height + scroll its own
67
+ * overflow so a long TOC never runs off-screen. */
68
+ position: sticky;
69
+ top: var(--space-8);
70
+ max-height: calc(100vh - 2 * var(--space-8));
71
+ overflow: auto;
72
+ font-size: var(--text-sm);
73
+ line-height: var(--leading-normal);
74
+ }
75
+
76
+ .section-map-label {
77
+ margin: 0 0 var(--space-1);
78
+ font-size: var(--text-xs);
79
+ color: var(--color-text-muted);
80
+ font-style: italic;
81
+ }
82
+
83
+ .section-map-title {
84
+ margin: 0 0 var(--space-2);
85
+ font-size: var(--text-xs);
86
+ text-transform: uppercase;
87
+ letter-spacing: 0.06em;
88
+ color: var(--color-text-muted);
89
+ font-weight: 600;
90
+ }
91
+
92
+ .section-map-list {
93
+ list-style: none;
94
+ padding-left: 0;
95
+ margin: 0;
96
+ }
97
+
98
+ .section-map-list li {
99
+ margin: var(--space-1) 0;
100
+ }
101
+
102
+ .section-map-list a {
103
+ display: block;
104
+ text-decoration: none;
105
+ color: var(--color-text-muted);
106
+ /* The active link gains a left border (border-bar); reserve its width on
107
+ * every link so lighting one doesn't shift the others horizontally. */
108
+ border-left: var(--border-bar) solid transparent;
109
+ padding-left: var(--space-2);
110
+ margin-left: calc(-1 * var(--space-2));
111
+ transition: color 120ms ease;
112
+ }
113
+
114
+ /* h3 links indent under their h2. */
115
+ .section-map-list a.toc-h3 {
116
+ padding-left: var(--space-5);
117
+ }
118
+
119
+ .section-map-list a:hover {
120
+ color: var(--color-link);
121
+ }
122
+
123
+ /* Active section (scrollspy): the island sets aria-current="page" + .active. */
124
+ .section-map-list a.active,
125
+ .section-map-list a[aria-current='page'] {
126
+ color: var(--color-link);
127
+ border-left-color: var(--color-link);
128
+ font-weight: 500;
129
+ }
130
+ }
@@ -145,3 +145,54 @@ th {
145
145
  font-weight: 600;
146
146
  color: var(--color-heading);
147
147
  }
148
+
149
+ /* ===== Tufte typographic openers (v4.25.0) =====
150
+ * Ported from the LaTeX book (interview-preamble-tufte.sty): the run-in
151
+ * <Newthought> section opener and the chapter-opening <Epigraph>. Markup is in
152
+ * the matching .astro components; styling stays here so it's always loaded.
153
+ * Token-only colors — recolor with [data-theme] automatically. */
154
+
155
+ /* <Newthought>: run-in small-caps opener. TRUE small-caps via the smcp OpenType
156
+ * feature (the Roboto variable face ships it) — font-variant: small-caps would
157
+ * synthesize faux small-caps by scaling capitals. Slightly heavier + tracked
158
+ * out for the Tufte look. */
159
+ .newthought {
160
+ font-feature-settings: "smcp";
161
+ font-weight: 600;
162
+ letter-spacing: 0.05em;
163
+ }
164
+
165
+ /* <Epigraph>: italic chapter-opening quotation with a right-aligned
166
+ * attribution. Inherits the running measure from .prose / --measure-main rather
167
+ * than forcing a width that would fight the Tufte measure. */
168
+ .epigraph {
169
+ margin: 0 0 var(--space-8) 0;
170
+ }
171
+ .epigraph blockquote {
172
+ /* Override the global blockquote left-bar: an epigraph reads as an opener,
173
+ * not a pull-quote. Keep it italic + muted. */
174
+ border-left: 0;
175
+ padding: 0;
176
+ margin: 0;
177
+ font-style: italic;
178
+ color: var(--color-text-muted);
179
+ }
180
+ .epigraph .epigraph-attribution {
181
+ margin-top: var(--space-2);
182
+ text-align: right;
183
+ font-style: normal;
184
+ font-size: var(--text-sm);
185
+ color: var(--color-text-muted);
186
+ }
187
+ /* The em-dash lead-in is conventional for an attribution; authors may also
188
+ * write it themselves, so it is not auto-inserted. */
189
+
190
+ /* .heading-accent — OPT-IN colored-italic heading utility (warm-blue italic).
191
+ * The global h1–h6 are already blue-italic (above); this class is for a heading
192
+ * an author wants to force into the accent style outside that cascade (e.g. a
193
+ * heading inside a card whose local styles reset it). Deliberately NOT applied
194
+ * to the global headings — restyling those would churn every page + baseline. */
195
+ .heading-accent {
196
+ color: var(--warm-blue);
197
+ font-style: italic;
198
+ }