@brandon_m_behring/book-scaffold-astro 4.25.3 → 4.26.2

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,92 @@
1
+ # Recipe 22 — Responsive navigation & multi-book routing (v4.26.0, #80)
2
+
3
+ The scaffold's navigation (left `Sidebar`, prev/next `ChapterNav`) is **book-aware**
4
+ and **responsive**: it serves a single-book site unchanged, a multi-book consumer
5
+ (one Astro app at `/<book>/<slug>/`) correctly, and adds a mobile/tablet drawer.
6
+
7
+ ## Single-book — zero config (byte-identical)
8
+
9
+ Do nothing. The defaults reproduce the pre-4.26 behavior:
10
+
11
+ - chapter links are `${BASE_URL}chapters/<id>/`,
12
+ - the sidebar lists every chapter, prev/next walk the full collection,
13
+ - you additionally get a **mobile drawer** for free (hamburger in the chrome row,
14
+ below 80rem), where the auto-hidden sidebar previously left no nav.
15
+
16
+ ## Multi-book — four `defineBookConfig` fields
17
+
18
+ For a consumer that owns `/[book]/[...chapter]` routing and serves chapters at
19
+ `/<book>/<slug>/` (each chapter's `entry.id` is `'<book>/<slug>'` and carries a
20
+ `book` frontmatter field):
21
+
22
+ ```js
23
+ // astro.config.mjs
24
+ export default await defineBookConfig({
25
+ // … styles, routes, etc. …
26
+ chapterRoute: '/:id/', // entry.id is '<book>/<slug>' → '/<book>/<slug>/'
27
+ bookField: 'book', // scope sidebar/drawer/prev-next to the current book
28
+ apparatusRoute: '/:book/:route/', // → '/<book>/practice-exam/', etc.
29
+ apparatusRoutes: ['practice-exam', 'glossary', 'flashcards', 'answers'],
30
+ });
31
+ ```
32
+
33
+ Tokens (base-relative; `BASE_URL` is applied for you):
34
+
35
+ | token | value |
36
+ |---------|-----------------------------------------|
37
+ | `:id` | `entry.id` verbatim (slashes kept) |
38
+ | `:book` | the entry's book (`bookField`), or `''` |
39
+ | `:slug` | `entry.id` minus the leading `<book>/` |
40
+ | `:route`| an apparatus route slug |
41
+
42
+ The nav then:
43
+
44
+ - lists **only the current book's** chapters (the current book is derived from the
45
+ URL's first path segment, validated against the books that exist),
46
+ - emits `/<book>/<slug>/` links and highlights the current chapter (`aria-current`),
47
+ - keeps prev/next **within the current book** (`getNeighbors` is book-scoped),
48
+ - surfaces the per-book apparatus links from `apparatusRoutes`.
49
+
50
+ **Landing pages** (a multi-book corpus front door) should pass
51
+ `showSidebar={false}` to `Base` — there is no "current book" there, so the chapter
52
+ nav would otherwise fall back to the single-book all-chapters list.
53
+
54
+ ## The mobile/tablet drawer
55
+
56
+ Below **80rem (1280px)** the full 3-column layout doesn't fit beside the sidebar,
57
+ so the sidebar is replaced by a slide-in **drawer** reached from a hamburger in
58
+ the chrome row. It reuses `NavContent` (the same book-scoped nav), is a
59
+ `role="dialog"` with focus-trap / ESC / backdrop close (an inline controller in
60
+ `Base.astro`), and degrades to a `:target` CSS open with **no JS**. Nothing to
61
+ configure — it appears automatically wherever the sidebar is enabled.
62
+
63
+ ## The Tufte right-gutter ("on this page" scrollspy)
64
+
65
+ The floated gutter scrollspy (`SectionMap`) + sidenotes need room the sidebar
66
+ steals, so the **full 3-column activates at ≥80rem (1280px)**. Below that the
67
+ drawer is the nav and the collapsed in-flow `ChapterTOC` is the "on this page".
68
+ The shared text measure (`--measure-main` / `--measure-side`, `tokens.css`) is
69
+ tuned so `main + gutter` fits inside `viewport − sidebar` at every desktop width
70
+ — the scrollspy **fits**, it is not hidden. (BC note: single-book sidebar
71
+ consumers see the sidebar + scrollspy from 1280px up, was 1024px, with a slightly
72
+ narrower body measure.)
73
+
74
+ ## Owning your own route components
75
+
76
+ The resolver is exported for consumers who render their own nav (recipe 18):
77
+
78
+ ```ts
79
+ import { chapterHref, apparatusHref, bookOf, isCurrentChapter }
80
+ from '@brandon_m_behring/book-scaffold-astro';
81
+ ```
82
+
83
+ Pure functions, no `astro:content` — pass a `{ id, data }` and the same
84
+ `chapterRoute` / `bookField` you set in `defineBookConfig`.
85
+
86
+ ## Verify
87
+
88
+ A consumer should drive a cross-device audit (Playwright is ideal): for each rich
89
+ page across `{390, 768, 1024, 1440}` × `{light, dark}`, assert no horizontal page
90
+ overflow (`scrollWidth ≤ clientWidth`), a visible sidebar ≥1280 / hamburger+drawer
91
+ below, and that no nav link points outside the current book. See the
92
+ `dlai-study-notes` consumer's `tests/responsive.spec.ts` for a worked harness.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { getCollection, type CollectionEntry } from 'astro:content';
13
13
  import { chapterSortKey } from './chapter-sort.js';
14
+ import { bookOf } from './nav-href.js';
14
15
 
15
16
  export type Chapter = CollectionEntry<'chapters'>;
16
17
 
@@ -27,18 +28,30 @@ export async function getAllChapters(): Promise<Chapter[]> {
27
28
  }
28
29
 
29
30
  /**
30
- * Given a chapter id, return its ordered neighbors.
31
- * Either may be null at the edges of the book.
31
+ * Given a chapter id, return its ordered neighbors. Either may be null at the
32
+ * edges of the book.
33
+ *
34
+ * v4.26.0 (#80): book-aware. When the entry carries a book (multi-book consumer,
35
+ * `data[bookField]`), neighbors are scoped to the SAME book so prev/next never
36
+ * bleed across books. Single-book schemas have no book field → no scoping → the
37
+ * pre-4.26 global ordering (byte-identical BC).
32
38
  */
33
- export async function getNeighbors(id: string): Promise<{
34
- prev: Chapter | null;
35
- next: Chapter | null;
36
- }> {
39
+ export async function getNeighbors(
40
+ id: string,
41
+ opts: { bookField?: string } = {},
42
+ ): Promise<{ prev: Chapter | null; next: Chapter | null }> {
43
+ const { bookField = 'book' } = opts;
37
44
  const all = await getAllChapters();
38
- const idx = all.findIndex((c) => c.id === id);
45
+ const self = all.find((c) => c.id === id);
46
+ if (!self) return { prev: null, next: null };
47
+ const selfBook = bookOf({ id: self.id, data: self.data as Record<string, unknown> }, bookField);
48
+ const scoped = selfBook
49
+ ? all.filter((c) => bookOf({ id: c.id, data: c.data as Record<string, unknown> }, bookField) === selfBook)
50
+ : all;
51
+ const idx = scoped.findIndex((c) => c.id === id);
39
52
  if (idx === -1) return { prev: null, next: null };
40
53
  return {
41
- prev: idx > 0 ? all[idx - 1] : null,
42
- next: idx < all.length - 1 ? all[idx + 1] : null,
54
+ prev: idx > 0 ? scoped[idx - 1]! : null,
55
+ next: idx < scoped.length - 1 ? scoped[idx + 1]! : null,
43
56
  };
44
57
  }
@@ -123,6 +123,8 @@ export interface Style {
123
123
  * - `'pages'`: Cloudflare Pages (default for research-portfolio/course-notes)
124
124
  * Closes #50. */
125
125
  readonly deploy?: 'pages' | 'workers';
126
+ /** v4.27.0 (#149): release-state banner; shallow override (last wins). */
127
+ readonly releaseStatus?: { state: 'alpha' | 'beta' | 'rc' | 'locked'; dismissAt?: string; message?: string };
126
128
 
127
129
  /**
128
130
  * Scoped consumer-side metadata. Ignored by the toolkit; survives composition
@@ -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
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * src/profiles/renderers/fallback-chapters.ts — ChaptersRenderer used by
3
- * profiles that don't ship a dedicated renderer (minimal, course-notes,
4
- * research-portfolio). Dispatches by field presence — exactly the v3.5.2
3
+ * profiles that don't ship a dedicated renderer (minimal, course-notes).
4
+ * Dispatches by field presence — exactly the v3.5.2
5
5
  * logic that lived inline in pages/chapters.astro before #35.
6
6
  *
7
7
  * Safety net for shapes we haven't designed for explicitly. If a consumer
8
- * opts a course-notes or research-portfolio book into `routes.chapters: true`,
8
+ * opts a course-notes book into `routes.chapters: true`,
9
9
  * the fallback renders reasonably without crashing. Custom output for those
10
10
  * profiles is a v4+ extension point (consumer-overridable renderer).
11
11
  */
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Research-portfolio /chapters renderer.
3
+ *
4
+ * The portfolio schema intentionally permits numeric Parts through 20. The
5
+ * generic fallback renderer inherits the tools profile's historical
6
+ * `part >= 6` appendix convention, which mislabels a portfolio's sixth Part
7
+ * as "Appendices" and its chapters as Appendix a/b/.... Keep the fallback's
8
+ * field-shape dispatch for every other affordance, but make numeric Parts
9
+ * ordinary numbered Parts throughout the portfolio schema's full range.
10
+ */
11
+ import type { ChaptersRenderer } from '../../lib/chapters-renderer.js';
12
+ import { fallbackChaptersRenderer } from './fallback-chapters.js';
13
+
14
+ export const researchPortfolioChaptersRenderer: ChaptersRenderer = {
15
+ ...fallbackChaptersRenderer,
16
+
17
+ formatPartLabel(part) {
18
+ return typeof part === 'number'
19
+ ? `Part ${part}`
20
+ : fallbackChaptersRenderer.formatPartLabel(part);
21
+ },
22
+
23
+ isAppendix(_part) {
24
+ return false;
25
+ },
26
+ };
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { defineProfile } from '../profile-kit.js';
24
24
  import { researchPortfolioChapterSchema } from '../schemas.js';
25
- import { fallbackChaptersRenderer } from './renderers/fallback-chapters.js';
25
+ import { researchPortfolioChaptersRenderer } from './renderers/research-portfolio-chapters.js';
26
26
 
27
27
  export type { ResearchPortfolioChapter } from '../schemas.js';
28
28
 
@@ -46,6 +46,7 @@ export const researchPortfolioProfile = defineProfile({
46
46
  },
47
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
- // v3.7.0 (#35): portfolio schema is a union of academic + tools shapes fallback renderer dispatches per chapter via field presence
50
- chaptersRenderer: fallbackChaptersRenderer,
49
+ // Portfolio schema is a union of academic + tools shapes, but unlike the
50
+ // tools profile its numeric Parts remain numbered through the schema max 20.
51
+ chaptersRenderer: researchPortfolioChaptersRenderer,
51
52
  });
@@ -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
  --measure-code: 48rem; /* code-block break-out width: fits 80-char lines comfortably (≈88 mono chars @14px before padding); docs/responsive-reading.md */
112
112
  --breakpoint-narrow: 48rem; /* ~768px — mobile fold (sidenotes inline) */
113
113
  --breakpoint-wide: 90rem; /* ~1440px — wide-desktop tier */
@@ -135,30 +135,34 @@
135
135
  * same visual language as prose callouts. Dark mode overrides below. */
136
136
  --astro-code-foreground: var(--color-text);
137
137
  --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);
138
+ /* a11y (v4.26.0, #80): saturated accents fail 4.5:1 on the light code bg →
139
+ * darken (the dark-theme overrides below keep their lighter variants). */
140
+ --astro-code-token-constant: color-mix(in srgb, var(--warm-rose) 60%, black);
141
+ --astro-code-token-string: color-mix(in srgb, var(--warm-green) 55%, black);
142
+ --astro-code-token-string-expression: color-mix(in srgb, var(--warm-green) 55%, black);
141
143
  --astro-code-token-comment: var(--color-text-muted);
142
- --astro-code-token-keyword: var(--warm-plum);
144
+ --astro-code-token-keyword: color-mix(in srgb, var(--warm-plum) 60%, black);
143
145
  --astro-code-token-parameter: var(--warm-blue);
144
- --astro-code-token-function: var(--warm-gold);
146
+ --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
147
  --astro-code-token-punctuation: var(--color-text-muted);
146
148
  --astro-code-token-link: var(--color-link);
147
149
  }
148
150
 
149
- /* Tier 2: wide desktop (1440px+). Main 80ch, sidenote 24ch. */
151
+ /* Tier 2: wide desktop (1440px+). v4.26.0 (#80): main 70ch + gutter 22ch keeps
152
+ * the main+gutter measure inside `layout-main` (viewport − 16rem sidebar) so the
153
+ * Tufte gutter/section-map fits beside the sidebar instead of overflowing. */
150
154
  @media (min-width: 90rem) {
151
155
  :root {
152
- --measure-main: 80ch;
153
- --measure-side: 24ch;
156
+ --measure-main: 66ch;
157
+ --measure-side: 20ch;
154
158
  }
155
159
  }
156
160
 
157
- /* Tier 3: ultrawide (1920px+). Main 90ch, sidenote 26ch. */
161
+ /* Tier 3: ultrawide (1920px+). Room to widen both again and still fit. */
158
162
  @media (min-width: 120rem) {
159
163
  :root {
160
- --measure-main: 90ch;
161
- --measure-side: 26ch;
164
+ --measure-main: 78ch;
165
+ --measure-side: 24ch;
162
166
  }
163
167
  }
164
168
 
@@ -233,3 +233,19 @@ th {
233
233
  color: var(--warm-blue);
234
234
  font-style: italic;
235
235
  }
236
+
237
+ /* ===== Narrow-viewport overflow guards (v4.26.0, #80) =====
238
+ * Defensive: stop a long INLINE-code identifier (e.g. a dotted module path) or a
239
+ * wide table from forcing horizontal PAGE scroll on a phone. `overflow-wrap:
240
+ * anywhere` only breaks when a token would otherwise overflow, so normal prose is
241
+ * untouched; fenced blocks are excluded (`:not(pre) > code`) so they keep their
242
+ * own `pre { overflow-x: auto }` inner scroll. Wide tables scroll within their own
243
+ * box (`display:block; overflow-x:auto`) instead of stretching the page. */
244
+ :not(pre) > code {
245
+ overflow-wrap: anywhere;
246
+ }
247
+ .prose > table {
248
+ display: block;
249
+ overflow-x: auto;
250
+ max-width: 100%;
251
+ }