@brandon_m_behring/book-scaffold-astro 4.25.3 → 4.26.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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * src/lib/nav-href.ts — pure route-href resolver (#80 multi-book navigation).
3
+ *
4
+ * No `astro:content` import (mirrors chapter-sort.ts) so tsup can include it in
5
+ * the DTS bundle without dragging Astro virtual modules into the build graph.
6
+ * The nav components (Sidebar, ChapterNav, NavContent, …) call these helpers
7
+ * instead of hardcoding the single-book `/chapters/<id>/` URL shape — so ONE set
8
+ * of components serves both a single-book site (the default pattern) and a
9
+ * multi-book consumer whose chapters render at `/<book>/<slug>/`.
10
+ *
11
+ * Patterns are base-relative TOKEN STRINGS (a resolver *function* could not
12
+ * survive the book-config virtual module's `JSON.stringify`, so the config
13
+ * surface is a declarative string resolved here):
14
+ * :id → entry.id verbatim, slashes preserved e.g. 'kg/01-intro'
15
+ * :book → the entry's book name (see `bookField`), or '' e.g. 'kg'
16
+ * :slug → entry.id with a leading '<book>/' stripped e.g. '01-intro'
17
+ * BASE_URL is applied here, so patterns are written base-relative (lead '/').
18
+ *
19
+ * Defaults reproduce the single-book behavior BYTE-FOR-BYTE:
20
+ * chapterRoute = '/chapters/:id/' → `${base}chapters/${id}/`
21
+ * bookField = 'book' (academic/tools schemas have no `book` → bookOf
22
+ * returns null → "show all chapters", today's nav)
23
+ * apparatusRoute = '/:route/' → `${base}<route>/`
24
+ */
25
+
26
+ /** Minimal shape the resolver needs from a chapter collection entry. */
27
+ export interface ChapterLike {
28
+ id: string;
29
+ data: Record<string, unknown>;
30
+ }
31
+
32
+ /** Normalize a base URL to exactly one trailing slash (`''` → `'/'`). */
33
+ function normBase(baseUrl: string): string {
34
+ return (baseUrl || '/').replace(/\/*$/, '/');
35
+ }
36
+
37
+ /** Replace `:book` / `:slug` / `:route` / `:id` tokens; `\b` keeps each token
38
+ * whole, so order is irrelevant and `:id` never matches inside `:identifier`. */
39
+ function fillTokens(pattern: string, tokens: Record<string, string>): string {
40
+ return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k: string) => tokens[k] ?? '');
41
+ }
42
+
43
+ /**
44
+ * The entry's book name from `data[bookField]`, or `null` when absent/blank.
45
+ * Single-book schemas (academic/tools/minimal) have no such field → `null`,
46
+ * which callers read as "this is the only book — show every chapter".
47
+ */
48
+ export function bookOf(entry: ChapterLike, bookField = 'book'): string | null {
49
+ const v = entry.data[bookField];
50
+ return typeof v === 'string' && v.length > 0 ? v : null;
51
+ }
52
+
53
+ /** `entry.id` with a leading `'<book>/'` stripped (multi-book) or unchanged. */
54
+ export function slugOf(entry: ChapterLike, bookField = 'book'): string {
55
+ const book = bookOf(entry, bookField);
56
+ return book && entry.id.startsWith(`${book}/`) ? entry.id.slice(book.length + 1) : entry.id;
57
+ }
58
+
59
+ /** Resolve a chapter entry to a base-prefixed href via the `chapterRoute` pattern. */
60
+ export function chapterHref(
61
+ entry: ChapterLike,
62
+ pattern = '/chapters/:id/',
63
+ baseUrl = '/',
64
+ bookField = 'book',
65
+ ): string {
66
+ const path = fillTokens(pattern, {
67
+ id: entry.id,
68
+ book: bookOf(entry, bookField) ?? '',
69
+ slug: slugOf(entry, bookField),
70
+ }).replace(/\/{2,}/g, '/').replace(/^\//, ''); // collapse empties (absent token) — never a protocol-relative //
71
+ return normBase(baseUrl) + path;
72
+ }
73
+
74
+ /**
75
+ * Resolve a per-book apparatus route (glossary / practice-exam / flashcards /
76
+ * answers) to a base-prefixed href via the `apparatusRoute` pattern.
77
+ */
78
+ export function apparatusHref(
79
+ route: string,
80
+ book: string | null,
81
+ pattern = '/:route/',
82
+ baseUrl = '/',
83
+ ): string {
84
+ const path = fillTokens(pattern, { route, book: book ?? '' })
85
+ .replace(/\/{2,}/g, '/') // F2 (#80): an absent :book must collapse, never yield a protocol-relative //
86
+ .replace(/^\//, '');
87
+ return normBase(baseUrl) + path;
88
+ }
89
+
90
+ /** Whether `entry` is the page at `currentPath` (trailing-slash tolerant). */
91
+ export function isCurrentChapter(
92
+ entry: ChapterLike,
93
+ currentPath: string,
94
+ pattern = '/chapters/:id/',
95
+ baseUrl = '/',
96
+ bookField = 'book',
97
+ ): boolean {
98
+ const href = chapterHref(entry, pattern, baseUrl, bookField);
99
+ return currentPath === href || currentPath === href.replace(/\/$/, '');
100
+ }
@@ -192,7 +192,9 @@
192
192
  padding: 0 var(--space-2);
193
193
  border: 1px solid var(--callout-worked);
194
194
  border-radius: var(--radius-sm);
195
- color: var(--callout-worked);
195
+ /* v4.26.0 (#80, a11y): chip TEXT uses the high-contrast heading color (the
196
+ * accent stays as the border) — the accent as small text failed WCAG-AA 4.5:1. */
197
+ color: var(--color-heading);
196
198
  }
197
199
 
198
200
  /* Gold — YouWillLearn (v4.1.0, #59). Reuses callout-insight (gold) hue;
@@ -233,7 +235,7 @@
233
235
  padding: 0 var(--space-2);
234
236
  border: 1px solid var(--callout-diagnostic);
235
237
  border-radius: var(--radius-sm);
236
- color: var(--callout-diagnostic);
238
+ color: var(--color-heading); /* a11y: accent as small text failed 4.5:1 (border keeps the hue) */
237
239
  }
238
240
  .callout-diagnostic .callout-routing {
239
241
  font-size: var(--text-sm);
@@ -247,7 +249,7 @@
247
249
  cursor: pointer;
248
250
  font-weight: 600;
249
251
  font-size: var(--text-sm);
250
- color: var(--callout-diagnostic);
252
+ color: var(--color-text); /* a11y: teal accent as text failed 4.5:1 */
251
253
  }
252
254
 
253
255
  /* PartReview (#111) — Part-level interleaved exercise-review aggregation.
@@ -575,6 +577,8 @@ figure.figure figcaption {
575
577
  letter-spacing: 0.04em;
576
578
  text-transform: uppercase;
577
579
  }
578
- .practice-tag[data-kind="official"] { border-color: var(--callout-official); color: var(--callout-official); }
579
- .practice-tag[data-kind="convergence"] { border-color: var(--callout-insight); color: var(--callout-insight); }
580
- .practice-tag[data-kind="practitioner"] { border-color: var(--callout-tip); color: var(--callout-tip); }
580
+ /* a11y (v4.26.0, #80): hue via the border; text uses the body color (accent as
581
+ * small text fails WCAG-AA 4.5:1). */
582
+ .practice-tag[data-kind="official"] { border-color: var(--callout-official); color: var(--color-text); }
583
+ .practice-tag[data-kind="convergence"] { border-color: var(--callout-insight); color: var(--color-text); }
584
+ .practice-tag[data-kind="practitioner"] { border-color: var(--callout-tip); color: var(--color-text); }
@@ -244,15 +244,4 @@
244
244
  overflow-x: auto;
245
245
  /* a little room so the horizontal scrollbar doesn't clip the equation's descenders */
246
246
  padding-block-end: 0.25rem;
247
- /* Edge scroll-shadow when a wide equation scrolls (v4.25.3; docs/responsive-reading.md).
248
- * Same Lea Verou layered technique as code blocks, page-bg covers. */
249
- background-image:
250
- linear-gradient(to right, var(--color-bg) 30%, rgba(0, 0, 0, 0)),
251
- linear-gradient(to right, rgba(0, 0, 0, 0), var(--color-bg) 70%),
252
- radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0)),
253
- radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0));
254
- background-position: 0 0, 100% 0, 0 0, 100% 0;
255
- background-repeat: no-repeat;
256
- background-size: 30px 100%, 30px 100%, 12px 100%, 12px 100%;
257
- background-attachment: local, local, scroll, scroll;
258
247
  }
package/styles/layout.css CHANGED
@@ -31,10 +31,15 @@
31
31
  }
32
32
 
33
33
  /* At 1024px+: pin sidebar to left, main content fills remainder. */
34
- @media (min-width: 64rem) {
34
+ /* v4.26.0 (#80): the full 3-column (sidebar + Tufte gutter) needs room the
35
+ * sidebar steals, so it activates at 80rem (1280px) — below that the drawer is
36
+ * the chapter nav and content runs full-width (the gutter/section-map then fits
37
+ * in the whole viewport). This is where the gutter genuinely fits beside the
38
+ * sidebar with the trimmed measures. */
39
+ @media (min-width: 80rem) {
35
40
  .layout-with-sidebar {
36
41
  display: grid;
37
- grid-template-columns: 16rem 1fr;
42
+ grid-template-columns: 14rem 1fr; /* trimmed so main+gutter fits beside it */
38
43
  grid-template-areas: 'sidebar main';
39
44
  min-height: 100vh;
40
45
  }
@@ -51,7 +56,7 @@
51
56
  * titles. */
52
57
  @media (min-width: 90rem) {
53
58
  .layout-with-sidebar {
54
- grid-template-columns: 18rem 1fr;
59
+ grid-template-columns: 16rem 1fr;
55
60
  }
56
61
  }
57
62
 
@@ -218,3 +223,89 @@
218
223
  .prose[data-layout="wide"] {
219
224
  --measure-main: 80ch;
220
225
  }
226
+
227
+ /* ===== Mobile/tablet nav drawer (v4.26.0, #80) =====
228
+ * Below the sidebar breakpoint (64rem) the left Sidebar is display:none — this
229
+ * is the navigation in that range: a hamburger in the chrome row toggles a
230
+ * slide-in drawer reusing NavContent (the same book-scoped nav source as the
231
+ * sidebar). No-JS baseline: `:target` opens it (the toggle is <a href="#nav-drawer">);
232
+ * the inline controller in Base.astro enhances it with focus-trap + ESC + body
233
+ * scroll-lock. Hidden at ≥64rem, where the persistent sidebar is the nav. The
234
+ * ≥64rem gutter-fit (sidenote/section-map overflow) is a separate fix. */
235
+ @media (min-width: 80rem) {
236
+ .nav-toggle,
237
+ .nav-drawer {
238
+ display: none;
239
+ }
240
+ }
241
+
242
+ @media (max-width: 79.98rem) {
243
+ .nav-drawer {
244
+ position: fixed;
245
+ inset: 0;
246
+ z-index: var(--z-drawer, 30);
247
+ visibility: hidden;
248
+ }
249
+ .nav-drawer.is-open,
250
+ .nav-drawer:target {
251
+ visibility: visible;
252
+ }
253
+ .nav-drawer-backdrop {
254
+ position: absolute;
255
+ inset: 0;
256
+ border: 0;
257
+ background: rgba(0, 0, 0, 0.5);
258
+ opacity: 0;
259
+ transition: opacity 200ms ease;
260
+ }
261
+ .nav-drawer-panel {
262
+ position: absolute;
263
+ inset-block: 0;
264
+ inset-inline-start: 0;
265
+ width: min(20rem, 85vw);
266
+ max-height: 100vh;
267
+ overflow-y: auto;
268
+ background: var(--color-bg-subtle);
269
+ border-right: 1px solid var(--color-border);
270
+ padding: var(--space-6) var(--space-5);
271
+ transform: translateX(-100%);
272
+ transition: transform 220ms ease;
273
+ }
274
+ .nav-drawer.is-open .nav-drawer-panel,
275
+ .nav-drawer:target .nav-drawer-panel {
276
+ transform: translateX(0);
277
+ }
278
+ .nav-drawer.is-open .nav-drawer-backdrop,
279
+ .nav-drawer:target .nav-drawer-backdrop {
280
+ opacity: 1;
281
+ }
282
+ .nav-drawer-dismiss {
283
+ position: absolute;
284
+ top: var(--space-3);
285
+ inset-inline-end: var(--space-3);
286
+ z-index: 1;
287
+ }
288
+ }
289
+
290
+ /* Body scroll-lock while the drawer is open (class toggled by the controller). */
291
+ .nav-drawer-locked {
292
+ overflow: hidden;
293
+ }
294
+
295
+ /* ===== Gutter-fit (v4.26.0, #80) =====
296
+ * The Tufte right-gutter (.section-map scrollspy + .sidenote / .column-margin /
297
+ * .margin-figure floats) used to size its main+gutter measure against the FULL
298
+ * viewport, ignoring that a left sidebar steals 14–16rem — so the negative-margin
299
+ * float landed PAST the viewport at 1024px AND 1440px. The fix is in tokens.css:
300
+ * the `--measure-main` / `--measure-side` tiers are trimmed (60/20ch → 66/20ch →
301
+ * 78/24ch) and the sidebar to 14/16rem, so `main + gutter ≤ layout-main` (viewport
302
+ * − sidebar) at every desktop width. The gutter scrollspy now FITS beside the
303
+ * sidebar instead of overflowing — no suppression, no breakpoint games. Verified
304
+ * scrollWidth == clientWidth across the responsive audit harness. */
305
+
306
+ @media (prefers-reduced-motion: reduce) {
307
+ .nav-drawer-panel,
308
+ .nav-drawer-backdrop {
309
+ transition: none;
310
+ }
311
+ }
@@ -30,7 +30,11 @@
30
30
  display: none !important;
31
31
  }
32
32
 
33
- @media (min-width: 64rem) {
33
+ /* v4.26.0 (#80): the gutter scrollspy floats only alongside the sidebar (≥80rem,
34
+ * matching layout.css) — below that the drawer is the chapter nav and the in-flow
35
+ * collapsed ChapterTOC is the "on this page". This keeps the negative-margin gutter
36
+ * from overflowing the viewport in the cramped 64–80rem band (iPad-landscape). */
37
+ @media (min-width: 80rem) {
34
38
  /* Sidenote-aware handshake (#151). The gutter map and <Sidenote>s float into
35
39
  * the SAME ~28ch right column (layout.css), so they cannot coexist there:
36
40
  * - A chapter WITH sidenotes → the gutter belongs to the sidenotes; suppress
package/styles/tokens.css CHANGED
@@ -106,9 +106,8 @@
106
106
  * the breakpoints are encoded as literal rem values in the media queries
107
107
  * (90rem ≈ 1440px, 120rem ≈ 1920px).
108
108
  */
109
- --measure-main: 65ch; /* default: laptop-friendly typographic measure */
110
- --measure-side: 28ch; /* right-margin sidenote column (desktop) */
111
- --measure-code: 48rem; /* code-block break-out width: fits 80-char lines comfortably (≈88 mono chars @14px before padding); docs/responsive-reading.md */
109
+ --measure-main: 60ch; /* default: main+gutter fits beside the sidebar at ≥64rem (v4.26.0, #80) */
110
+ --measure-side: 20ch; /* right-margin sidenote / section-map column */
112
111
  --breakpoint-narrow: 48rem; /* ~768px — mobile fold (sidenotes inline) */
113
112
  --breakpoint-wide: 90rem; /* ~1440px — wide-desktop tier */
114
113
  --breakpoint-ultrawide: 120rem; /* ~1920px — ultrawide tier */
@@ -135,30 +134,34 @@
135
134
  * same visual language as prose callouts. Dark mode overrides below. */
136
135
  --astro-code-foreground: var(--color-text);
137
136
  --astro-code-background: var(--color-code-bg);
138
- --astro-code-token-constant: var(--warm-rose);
139
- --astro-code-token-string: var(--warm-green);
140
- --astro-code-token-string-expression: var(--warm-green);
137
+ /* a11y (v4.26.0, #80): saturated accents fail 4.5:1 on the light code bg →
138
+ * darken (the dark-theme overrides below keep their lighter variants). */
139
+ --astro-code-token-constant: color-mix(in srgb, var(--warm-rose) 60%, black);
140
+ --astro-code-token-string: color-mix(in srgb, var(--warm-green) 55%, black);
141
+ --astro-code-token-string-expression: color-mix(in srgb, var(--warm-green) 55%, black);
141
142
  --astro-code-token-comment: var(--color-text-muted);
142
- --astro-code-token-keyword: var(--warm-plum);
143
+ --astro-code-token-keyword: color-mix(in srgb, var(--warm-plum) 60%, black);
143
144
  --astro-code-token-parameter: var(--warm-blue);
144
- --astro-code-token-function: var(--warm-gold);
145
+ --astro-code-token-function: color-mix(in srgb, var(--warm-gold) 55%, black); /* a11y: warm-gold alone is 2.46:1 on the code bg (dark-mode override below) */
145
146
  --astro-code-token-punctuation: var(--color-text-muted);
146
147
  --astro-code-token-link: var(--color-link);
147
148
  }
148
149
 
149
- /* Tier 2: wide desktop (1440px+). Main 80ch, sidenote 24ch. */
150
+ /* Tier 2: wide desktop (1440px+). v4.26.0 (#80): main 70ch + gutter 22ch keeps
151
+ * the main+gutter measure inside `layout-main` (viewport − 16rem sidebar) so the
152
+ * Tufte gutter/section-map fits beside the sidebar instead of overflowing. */
150
153
  @media (min-width: 90rem) {
151
154
  :root {
152
- --measure-main: 80ch;
153
- --measure-side: 24ch;
155
+ --measure-main: 66ch;
156
+ --measure-side: 20ch;
154
157
  }
155
158
  }
156
159
 
157
- /* Tier 3: ultrawide (1920px+). Main 90ch, sidenote 26ch. */
160
+ /* Tier 3: ultrawide (1920px+). Room to widen both again and still fit. */
158
161
  @media (min-width: 120rem) {
159
162
  :root {
160
- --measure-main: 90ch;
161
- --measure-side: 26ch;
163
+ --measure-main: 78ch;
164
+ --measure-side: 24ch;
162
165
  }
163
166
  }
164
167
 
@@ -89,43 +89,6 @@ pre code {
89
89
  font-size: var(--text-sm);
90
90
  }
91
91
 
92
- /* ===== Responsive reading (v4.25.3) — docs/responsive-reading.md =====
93
- * Code blocks break out past the prose text measure to fit ~80-char lines,
94
- * capped at --measure-code and centered within .prose. CONTAINER-bounded, not
95
- * viewport-bounded: the width comes from the .prose column itself, so a left
96
- * sidebar offset + the scrollbar gutter can never push the block into horizontal
97
- * PAGE scroll. (The earlier `width: min(100vw - …)` overflowed ~8px at the
98
- * 1024px sidebar boundary — #171 review finding A1, guarded by
99
- * gallery/tests/fixtures/layout-overflow.spec.ts.) `.wide`/`.column-page` keep
100
- * their full-bleed behavior (excluded here). */
101
- .prose > pre:not(.wide):not(.column-page) {
102
- max-width: var(--measure-code);
103
- margin-inline: auto;
104
- /* Lea Verou layered scroll-shadow: bg-colored covers move with content
105
- * (attachment:local); shadow layers are fixed → shown only when scrollable. */
106
- background-image:
107
- linear-gradient(to right, var(--color-code-bg) 30%, rgba(0, 0, 0, 0)),
108
- linear-gradient(to right, rgba(0, 0, 0, 0), var(--color-code-bg) 70%),
109
- radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)),
110
- radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0));
111
- background-position: 0 0, 100% 0, 0 0, 100% 0;
112
- background-repeat: no-repeat;
113
- background-size: 40px 100%, 40px 100%, 14px 100%, 14px 100%;
114
- background-attachment: local, local, scroll, scroll;
115
- }
116
- /* Phone (≤40rem): shrink code a touch so more fits before scrolling. Tighter
117
- * than the 48rem mobile/table breakpoint below — phones benefit from the smaller
118
- * glyphs; tablets (40–48rem) keep the full size. */
119
- @media (max-width: 40rem) {
120
- pre code { font-size: 0.75rem; }
121
- }
122
- /* Mobile: scroll a wide table within its own block instead of overflowing the
123
- * page. Sticky thead intentionally NOT used — it can't stick inside a
124
- * horizontal-scroll wrapper (CSS limitation; see docs/responsive-reading.md). */
125
- @media (max-width: 48rem) {
126
- .prose table { display: block; max-width: 100%; overflow-x: auto; }
127
- }
128
-
129
92
  /* ===== Links ===== */
130
93
  a {
131
94
  color: var(--color-link);
@@ -233,3 +196,19 @@ th {
233
196
  color: var(--warm-blue);
234
197
  font-style: italic;
235
198
  }
199
+
200
+ /* ===== Narrow-viewport overflow guards (v4.26.0, #80) =====
201
+ * Defensive: stop a long INLINE-code identifier (e.g. a dotted module path) or a
202
+ * wide table from forcing horizontal PAGE scroll on a phone. `overflow-wrap:
203
+ * anywhere` only breaks when a token would otherwise overflow, so normal prose is
204
+ * untouched; fenced blocks are excluded (`:not(pre) > code`) so they keep their
205
+ * own `pre { overflow-x: auto }` inner scroll. Wide tables scroll within their own
206
+ * box (`display:block; overflow-x:auto`) instead of stretching the page. */
207
+ :not(pre) > code {
208
+ overflow-wrap: anywhere;
209
+ }
210
+ .prose > table {
211
+ display: block;
212
+ overflow-x: auto;
213
+ max-width: 100%;
214
+ }