@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.
- package/components/ChapterNav.astro +23 -6
- package/components/EvidenceTag.astro +6 -3
- package/components/NavContent.astro +286 -0
- package/components/Provenance.astro +2 -2
- package/components/Sidebar.astro +16 -210
- package/dist/index.d.ts +66 -6
- package/dist/index.mjs +87 -5
- package/dist/schemas.d.ts +1 -1
- package/dist/schemas.mjs +14 -2
- package/dist/{types-Bn7rKhgP.d.ts → types-Hue-uSeQ.d.ts} +66 -0
- package/layouts/Base.astro +118 -1
- package/package.json +2 -1
- package/recipes/08-decisions-ledger.md +10 -0
- package/recipes/22-responsive-nav-and-multibook-routing.md +92 -0
- package/src/lib/chapters.ts +22 -9
- package/src/lib/define-style.ts +2 -0
- package/src/lib/nav-href.ts +100 -0
- package/src/profiles/renderers/fallback-chapters.ts +3 -3
- package/src/profiles/renderers/research-portfolio-chapters.ts +26 -0
- package/src/profiles/research-portfolio.ts +4 -3
- package/styles/callouts.css +10 -6
- package/styles/layout.css +94 -3
- package/styles/section-map.css +5 -1
- package/styles/tokens.css +17 -13
- package/styles/typography.css +16 -0
|
@@ -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.
|
package/src/lib/chapters.ts
CHANGED
|
@@ -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
|
-
*
|
|
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(
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 ?
|
|
42
|
-
next: idx <
|
|
54
|
+
prev: idx > 0 ? scoped[idx - 1]! : null,
|
|
55
|
+
next: idx < scoped.length - 1 ? scoped[idx + 1]! : null,
|
|
43
56
|
};
|
|
44
57
|
}
|
package/src/lib/define-style.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
50
|
-
|
|
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
|
});
|
package/styles/callouts.css
CHANGED
|
@@ -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
|
-
|
|
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(--
|
|
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(--
|
|
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
|
-
.
|
|
579
|
-
|
|
580
|
-
.practice-tag[data-kind="
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/styles/section-map.css
CHANGED
|
@@ -30,7 +30,11 @@
|
|
|
30
30
|
display: none !important;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
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:
|
|
110
|
-
--measure-side:
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
--astro-code-token-
|
|
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+).
|
|
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:
|
|
153
|
-
--measure-side:
|
|
156
|
+
--measure-main: 66ch;
|
|
157
|
+
--measure-side: 20ch;
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
/* Tier 3: ultrawide (1920px+).
|
|
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:
|
|
161
|
-
--measure-side:
|
|
164
|
+
--measure-main: 78ch;
|
|
165
|
+
--measure-side: 24ch;
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
168
|
|
package/styles/typography.css
CHANGED
|
@@ -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
|
+
}
|