@brandon_m_behring/book-scaffold-astro 4.25.2 → 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); }
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,8 +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) */
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 */
111
111
  --breakpoint-narrow: 48rem; /* ~768px — mobile fold (sidenotes inline) */
112
112
  --breakpoint-wide: 90rem; /* ~1440px — wide-desktop tier */
113
113
  --breakpoint-ultrawide: 120rem; /* ~1920px — ultrawide tier */
@@ -134,30 +134,34 @@
134
134
  * same visual language as prose callouts. Dark mode overrides below. */
135
135
  --astro-code-foreground: var(--color-text);
136
136
  --astro-code-background: var(--color-code-bg);
137
- --astro-code-token-constant: var(--warm-rose);
138
- --astro-code-token-string: var(--warm-green);
139
- --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);
140
142
  --astro-code-token-comment: var(--color-text-muted);
141
- --astro-code-token-keyword: var(--warm-plum);
143
+ --astro-code-token-keyword: color-mix(in srgb, var(--warm-plum) 60%, black);
142
144
  --astro-code-token-parameter: var(--warm-blue);
143
- --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) */
144
146
  --astro-code-token-punctuation: var(--color-text-muted);
145
147
  --astro-code-token-link: var(--color-link);
146
148
  }
147
149
 
148
- /* 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. */
149
153
  @media (min-width: 90rem) {
150
154
  :root {
151
- --measure-main: 80ch;
152
- --measure-side: 24ch;
155
+ --measure-main: 66ch;
156
+ --measure-side: 20ch;
153
157
  }
154
158
  }
155
159
 
156
- /* Tier 3: ultrawide (1920px+). Main 90ch, sidenote 26ch. */
160
+ /* Tier 3: ultrawide (1920px+). Room to widen both again and still fit. */
157
161
  @media (min-width: 120rem) {
158
162
  :root {
159
- --measure-main: 90ch;
160
- --measure-side: 26ch;
163
+ --measure-main: 78ch;
164
+ --measure-side: 24ch;
161
165
  }
162
166
  }
163
167
 
@@ -196,3 +196,19 @@ th {
196
196
  color: var(--warm-blue);
197
197
  font-style: italic;
198
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
+ }