@brandon_m_behring/book-scaffold-astro 4.26.2 → 4.27.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 (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CLAUDE.md +35 -10
  3. package/LATEX_TO_MDX_MAPPING.md +1 -1
  4. package/LICENSE +27 -0
  5. package/LICENSE-CONTENT +19 -0
  6. package/README.md +33 -0
  7. package/components/AssessmentTest.astro +2 -1
  8. package/components/ChapterNav.astro +2 -2
  9. package/components/Cite.astro +2 -1
  10. package/components/NavContent.astro +2 -2
  11. package/components/PartReview.astro +2 -1
  12. package/components/Rationale.astro +3 -2
  13. package/components/Sidebar.astro +2 -1
  14. package/components/Term.astro +2 -1
  15. package/components/TipsCard.astro +2 -1
  16. package/components/VersionSelector.tsx +39 -36
  17. package/components/WeekRef.astro +2 -1
  18. package/components/XRef.astro +2 -1
  19. package/dist/components/VersionSelector.d.ts +16 -2
  20. package/dist/components/VersionSelector.mjs +18 -17
  21. package/dist/index.d.ts +24 -6
  22. package/dist/index.mjs +96 -8
  23. package/dist/schemas.d.ts +1 -1
  24. package/dist/schemas.mjs +8 -1
  25. package/dist/{types-Hue-uSeQ.d.ts → types-CZrkqzpC.d.ts} +84 -48
  26. package/layouts/Base.astro +23 -21
  27. package/package.json +7 -4
  28. package/pages/answers.astro +2 -1
  29. package/pages/chapters.astro +2 -1
  30. package/pages/exercises.astro +2 -1
  31. package/pages/index.astro +4 -3
  32. package/pages/search.astro +2 -1
  33. package/pages/tips.astro +2 -1
  34. package/recipes/01-add-math.md +40 -28
  35. package/recipes/04-component-library.md +14 -4
  36. package/recipes/05-deploy-cloudflare.md +44 -0
  37. package/recipes/06-mobile-first-layout.md +25 -2
  38. package/recipes/09-validation.md +18 -9
  39. package/recipes/15-defining-styles.md +7 -3
  40. package/recipes/19-prevalidate-hook.md +29 -83
  41. package/scripts/build-bib.mjs +7 -3
  42. package/scripts/build-labels.mjs +33 -16
  43. package/scripts/read-env.mjs +25 -0
  44. package/scripts/resolve-book-config.mjs +96 -0
  45. package/scripts/validate.mjs +125 -84
  46. package/src/lib/define-style.ts +25 -8
  47. package/src/lib/nav-href.ts +24 -2
  48. package/styles/layout.css +14 -8
  49. package/styles/tokens.css +8 -10
@@ -14,7 +14,8 @@
14
14
  * 5. <CodeRef path="..." line={N} /> — when BOOK_REPO_ROOT set,
15
15
  * path exists + line in bounds.
16
16
  * 6. <Theorem> — has a resolvable kind= (or legacy type=); else it would
17
- * render an empty label and throw at build (#121).
17
+ * render an empty label and throw at build (#121). An id'd theorem must
18
+ * resolve in labels.json, and a literal n= must agree with that index.
18
19
  * 7. <BookLink book="…" to="…"> (#96) — both props present, and book= is a
19
20
  * key in the consumer's siblingBooks registry (best-effort).
20
21
  * 8. Questions collection (#112) — each question's frontmatter `domain` is a
@@ -38,38 +39,11 @@
38
39
  import { readFile, access } from 'node:fs/promises';
39
40
  import { existsSync, readFileSync } from 'node:fs';
40
41
  import { resolve, dirname, join } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { spawnSync } from 'node:child_process';
41
44
  import { walkMdx, readChaptersBase, readBookSchemaConfig } from './walk-mdx.mjs';
42
-
43
- /**
44
- * Best-effort .env reader. Mirrors `readEnvFile` in src/types.ts; kept inline
45
- * here because scripts/ is shipped as plain JS without compiling src/.
46
- *
47
- * Closes #20 — validate.mjs previously skipped the .env fallback that
48
- * `resolveProfileWithSource` honors, so consumers who set BOOK_PROFILE in
49
- * .env (per the SKILL.md and scaffold's create-book defaults) saw the CLI
50
- * silently default to minimal, masking academic-profile errors.
51
- */
52
- function readEnvFile(path = '.env') {
53
- try {
54
- if (!existsSync(path)) return {};
55
- const out = {};
56
- for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
57
- const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
58
- if (!m) continue;
59
- let val = m[2] ?? '';
60
- if (
61
- (val.startsWith('"') && val.endsWith('"')) ||
62
- (val.startsWith("'") && val.endsWith("'"))
63
- ) {
64
- val = val.slice(1, -1);
65
- }
66
- out[m[1]] = val;
67
- }
68
- return out;
69
- } catch {
70
- return {};
71
- }
72
- }
45
+ import { readEnvFile } from './read-env.mjs';
46
+ import { loadResolvedBookConfig } from './resolve-book-config.mjs';
73
47
 
74
48
  // --help / -h: non-mutating (closes #14).
75
49
  const USAGE = `Usage: book-scaffold validate [--preset <name>]
@@ -78,8 +52,8 @@ Pre-flight content validator. Checks Cite keys, XRef ids, Figure srcs,
78
52
  internal markdown links, and (when BOOK_REPO_ROOT is set) CodeRef paths.
79
53
 
80
54
  Options:
81
- --preset <name> academic | tools | minimal | course-notes
82
- (overrides BOOK_PRESET / BOOK_PROFILE env)
55
+ --preset <name> academic | tools | minimal | course-notes | research-portfolio
56
+ Legacy override when no scaffold integration is resolved.
83
57
  --help, -h Print this message and exit (non-mutating).
84
58
 
85
59
  Env:
@@ -113,12 +87,20 @@ const CHAPTERS_DIR = await readChaptersBase(ROOT);
113
87
  const PUBLIC_DIR = resolve(ROOT, 'public');
114
88
  const DATA_DIR = resolve(ROOT, 'src/data');
115
89
 
116
- // Preset resolution (matches resolvePreset in src/types.ts):
117
- // --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
90
+ let TOOLING_CONFIG;
91
+ try {
92
+ TOOLING_CONFIG = await loadResolvedBookConfig(ROOT);
93
+ } catch (error) {
94
+ process.stderr.write(`validate: fatal: ${error?.message ?? error}\n`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Preset resolution:
99
+ // composed Astro-config preset > --preset flag > BOOK_PRESET env > BOOK_PROFILE env >
118
100
  // .env BOOK_PRESET > .env BOOK_PROFILE >
119
101
  // defineBookSchemas({ preset }) in content.config.ts >
120
102
  // defineBookSchemas({ profile }) in content.config.ts (alias) >
121
- // 'minimal'.
103
+ // warned v4 compatibility fallback 'minimal'.
122
104
  // .env fallback closes #20 — without it, consumers who set BOOK_PROFILE in
123
105
  // .env (the documented convenience in SKILL.md + create-book defaults) saw
124
106
  // the CLI silently default to minimal, hiding academic-profile errors.
@@ -126,16 +108,35 @@ const DATA_DIR = resolve(ROOT, 'src/data');
126
108
  // canonical v4.5+ defineBookSchemas({ preset, chaptersBase }) form had the
127
109
  // CLI silently default to minimal, hiding research-portfolio (and any
128
110
  // non-env-set) profile errors while astro build applied the correct settings.
129
- const dotenv = readEnvFile(resolve(ROOT, '.env'));
111
+ const dotenv = readEnvFile(ROOT);
130
112
  const schemaConfig = await readBookSchemaConfig(ROOT);
131
- const PRESET =
113
+ const PRESET_CANDIDATE =
114
+ TOOLING_CONFIG.preset ??
132
115
  presetFromFlag ??
133
116
  process.env.BOOK_PRESET ??
134
117
  process.env.BOOK_PROFILE ??
135
118
  dotenv.BOOK_PRESET ??
136
119
  dotenv.BOOK_PROFILE ??
137
- schemaConfig.preset ??
138
- 'minimal';
120
+ schemaConfig.preset;
121
+ const PRESETS = ['academic', 'tools', 'minimal', 'course-notes', 'research-portfolio'];
122
+ if (presetFlagIdx >= 0 && !presetFromFlag) {
123
+ process.stderr.write('validate: --preset requires a value.\n');
124
+ process.exit(2);
125
+ }
126
+ if (PRESET_CANDIDATE && !PRESETS.includes(PRESET_CANDIDATE)) {
127
+ process.stderr.write(
128
+ `validate: preset must be one of ${PRESETS.join(' | ')} ` +
129
+ `(got ${JSON.stringify(PRESET_CANDIDATE)}).\n`,
130
+ );
131
+ process.exit(1);
132
+ }
133
+ const PRESET = PRESET_CANDIDATE ?? 'minimal';
134
+ if (!PRESET_CANDIDATE) {
135
+ process.stderr.write(
136
+ "validate: no preset resolved; falling back to 'minimal' for v4 compatibility. " +
137
+ 'This fallback will be removed in v5; configure a built-in style or BOOK_PRESET.\n',
138
+ );
139
+ }
139
140
  // Alias kept for downstream message text only; the resolution above is canonical.
140
141
  const PROFILE = PRESET;
141
142
  const REPO_ROOT = process.env.BOOK_REPO_ROOT ?? null;
@@ -213,6 +214,37 @@ const warnings = [];
213
214
  const fail = (file, line, msg) => errors.push({ file, line, msg });
214
215
  const warn = (file, line, msg) => warnings.push({ file, line, msg });
215
216
 
217
+ // ===== Self-heal missing generated artifacts (#186) =====
218
+ // These files are intentionally gitignored. Direct `book-scaffold validate`
219
+ // bypasses npm's prevalidate lifecycle, so rebuild each missing artifact before
220
+ // loading it. Existing files remain untouched; child diagnostics and failures
221
+ // are propagated verbatim instead of becoming downstream unknown-id noise.
222
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
223
+ function regenerate(scriptName, artifact) {
224
+ process.stdout.write(`validate: ${artifact} is missing — regenerating via ${scriptName} (#186)\n`);
225
+ const result = spawnSync(process.execPath, [join(scriptDir, scriptName)], {
226
+ cwd: ROOT,
227
+ env: process.env,
228
+ encoding: 'utf8',
229
+ });
230
+ if (result.stdout) process.stdout.write(result.stdout);
231
+ if (result.stderr) process.stderr.write(result.stderr);
232
+ if (result.error || result.status !== 0) {
233
+ const detail = result.error ? `: ${result.error.message}` : '';
234
+ process.stderr.write(
235
+ `validate: ${scriptName} failed (exit ${result.status ?? 1})${detail} — cannot self-heal.\n`,
236
+ );
237
+ process.exit(result.status ?? 1);
238
+ }
239
+ }
240
+
241
+ if (!existsSync(join(DATA_DIR, 'labels.json'))) {
242
+ regenerate('build-labels.mjs', 'src/data/labels.json');
243
+ }
244
+ if (!existsSync(join(DATA_DIR, 'references.json'))) {
245
+ regenerate('build-bib.mjs', 'src/data/references.json');
246
+ }
247
+
216
248
  // ===== Load reference data (graceful when missing) =====
217
249
  async function loadJson(path) {
218
250
  try {
@@ -267,6 +299,29 @@ function lineOf(content, idx) {
267
299
  return content.slice(0, idx).split('\n').length;
268
300
  }
269
301
 
302
+ /**
303
+ * Return a statically knowable n= value. Quoted strings and braced
304
+ * string/numeric literals are safe to compare; identifiers, calls,
305
+ * interpolation, and all other expressions are deliberately skipped.
306
+ */
307
+ function literalTheoremNumber(attrs) {
308
+ const quoted = attrs.match(/\bn\s*=\s*(["'])(.*?)\1/);
309
+ if (quoted) return quoted[2];
310
+
311
+ const braced = attrs.match(/\bn\s*=\s*\{\s*([^}]*?)\s*\}/);
312
+ if (!braced) return null;
313
+ const expression = braced[1].trim();
314
+ const stringLiteral = expression.match(/^(["'`])([\s\S]*)\1$/);
315
+ if (stringLiteral) {
316
+ if (stringLiteral[1] === '`' && stringLiteral[2].includes('${')) return null;
317
+ return stringLiteral[2];
318
+ }
319
+ if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/.test(expression)) {
320
+ return String(Number(expression));
321
+ }
322
+ return null;
323
+ }
324
+
270
325
  // #96: best-effort siblingBooks registry keys from astro.config.mjs, so the
271
326
  // <BookLink> check can flag an unknown book= earlier than the component's
272
327
  // build-time throw. null = couldn't determine → membership not checked (the
@@ -359,13 +414,33 @@ for (const rel of chapterFiles) {
359
414
  );
360
415
  }
361
416
  const thmId = attrs.match(/\bid=["']([^"']+)["']/);
362
- if (thmId && !/\blabel\s*=\s*["']/.test(attrs) && !labels[thmId[1]]) {
417
+ const hasLabelOverride = /\blabel\s*=/.test(attrs);
418
+ if (thmId && !hasLabelOverride && !labels[thmId[1]]) {
363
419
  fail(
364
420
  rel,
365
421
  lineOf(content, m.index),
366
422
  `<Theorem id="${thmId[1]}"> — not in labels.json; heading silently renders unnumbered. Run build:labels, or fix the id.`,
367
423
  );
368
424
  }
425
+ // #176: compare only literal n= values. Dynamic expressions cannot be
426
+ // evaluated reliably by this intentionally regex-based validator, and a
427
+ // label= override explicitly opts out of labels.json auto-numbering.
428
+ const explicitNumber = hasLabelOverride ? null : literalTheoremNumber(attrs);
429
+ const indexedNumber = thmId ? labels[thmId[1]]?.number : null;
430
+ if (
431
+ thmId &&
432
+ explicitNumber !== null &&
433
+ indexedNumber != null &&
434
+ explicitNumber !== String(indexedNumber)
435
+ ) {
436
+ fail(
437
+ rel,
438
+ lineOf(content, m.index),
439
+ `<Theorem id="${thmId[1]}" n="${explicitNumber}"> — labels.json numbers it ` +
440
+ `${indexedNumber}, and the rendered heading + every XRef use the index. ` +
441
+ 'Drop the stale n= (auto-numbering wins) or re-run build:labels.',
442
+ );
443
+ }
369
444
  }
370
445
 
371
446
  // 7. BookLink (#96): structural (book= + to=) + best-effort registry membership.
@@ -544,46 +619,6 @@ let questionsChecked = 0;
544
619
  }
545
620
  }
546
621
 
547
- // ===== v4.6.0 (issue #77): missing-prereq re-framing =====
548
- //
549
- // When errors are downstream symptoms of a missing artifact (references.json
550
- // or labels.json), abort with ONE leading error pointing at the prereq
551
- // instead of printing 25 "Unknown bibkey" / "Unknown XRef" symptoms. Single
552
- // clean signal: fix the prereq. Per D12 of the v4.6.0 plan.
553
- {
554
- if (PROFILE === 'academic') {
555
- const refsPath = join(DATA_DIR, 'references.json');
556
- const hasBibkeyErrors = errors.some((e) => /Unknown bibkey/.test(e.msg));
557
- if (hasBibkeyErrors && !existsSync(refsPath)) {
558
- console.error(
559
- `\n✗ Validate cannot run: src/data/references.json is missing.\n\n` +
560
- `This file is generated from bibliography.bib by 'npm run build:bib'.\n` +
561
- `Run that first, OR adopt the prevalidate npm hook convention so\n` +
562
- `'npm run validate' regenerates it automatically:\n\n` +
563
- ` "prevalidate": "npm run build:bib && npm run build:labels --if-present"\n` +
564
- ` "validate": "book-scaffold validate"\n\n` +
565
- `See package/recipes/19-prevalidate-hook.md.\n`,
566
- );
567
- process.exit(1);
568
- }
569
- }
570
- const labelsPath = join(DATA_DIR, 'labels.json');
571
- // #126: also collapse <Theorem id> not-in-labels errors (they share the
572
- // "not in labels.json" phrase) under the same missing-prereq message.
573
- const hasXrefErrors = errors.some((e) => /not in labels\.json/.test(e.msg));
574
- if (hasXrefErrors && !existsSync(labelsPath)) {
575
- console.error(
576
- `\n✗ Validate cannot run: src/data/labels.json is missing.\n\n` +
577
- `This file is generated from <Theorem id="..."> and <Figure id="..."> markers\n` +
578
- `in chapter MDX by 'npm run build:labels'. Run that first, OR adopt the\n` +
579
- `prevalidate npm hook convention so 'npm run validate' regenerates it:\n\n` +
580
- ` "prevalidate": "npm run build:bib && npm run build:labels --if-present"\n\n` +
581
- `See package/recipes/19-prevalidate-hook.md.\n`,
582
- );
583
- process.exit(1);
584
- }
585
- }
586
-
587
622
  // ===== Report =====
588
623
  const format = ({ file, line, msg }) => ` ${file}:${line} ${msg}`;
589
624
  if (warnings.length > 0) {
@@ -592,9 +627,15 @@ if (warnings.length > 0) {
592
627
  }
593
628
  if (errors.length === 0) {
594
629
  const qNote = questionsChecked > 0 ? ` + ${questionsChecked} question(s)` : '';
595
- console.log(`validate: ✓ ${chapterFiles.length} chapter(s)${qNote} checked (profile=${PROFILE}); no errors.`);
630
+ console.log(
631
+ `validate: ✓ ${chapterFiles.length} chapter(s)${qNote} checked ` +
632
+ `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}); no errors.`,
633
+ );
596
634
  process.exit(0);
597
635
  }
598
- console.error(`validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) (profile=${PROFILE}):`);
636
+ console.error(
637
+ `validate: ✗ ${errors.length} error(s) in ${chapterFiles.length} chapter(s) ` +
638
+ `(profile=${PROFILE}, number-style=${TOOLING_CONFIG.numberStyle}):`,
639
+ );
599
640
  errors.forEach((e) => console.error(format(e)));
600
641
  process.exit(errors.length);
@@ -27,7 +27,12 @@
27
27
  * for migration from the v3 `preset:` shorthand.
28
28
  */
29
29
  import type { AstroIntegration, AstroUserConfig } from 'astro';
30
- import type { BookPreset, RouteToggles } from '../types.js';
30
+ import type {
31
+ BookPreset,
32
+ NumberStyle,
33
+ ReleaseStatusConfig,
34
+ RouteToggles,
35
+ } from '../types.js';
31
36
 
32
37
  // ===== Branded nominal type =====
33
38
 
@@ -90,6 +95,9 @@ export interface Style {
90
95
  /** Profile that backs this style — determines schema + default routes + styles + KaTeX wiring. */
91
96
  readonly preset?: BookPreset;
92
97
 
98
+ /** Theorem-family numbering strategy; shallow override (last wins). */
99
+ readonly numberStyle?: NumberStyle;
100
+
93
101
  /** Book's deployed origin (sitemap, canonical, Pagefind). Required at composition end;
94
102
  * optional inside a Style (so styles can omit it and consumers can provide per-book). */
95
103
  readonly site?: string;
@@ -118,13 +126,17 @@ export interface Style {
118
126
  * scalar fields override. */
119
127
  readonly markdown?: AstroUserConfig['markdown'];
120
128
 
121
- /** Deploy target drives create-book's wrangler.toml shape.
122
- * - `'workers'`: Cloudflare Workers + Static Assets (default for academic/tools/minimal)
123
- * - `'pages'`: Cloudflare Pages (default for research-portfolio/course-notes)
124
- * Closes #50. */
129
+ /** Reserved legacy metadata. It is composed but has no runtime or
130
+ * create-book effect: create-book chooses wrangler.toml from its CLI
131
+ * preset before a consumer Style exists (#180).
132
+ * @deprecated Inert in v4; scheduled for removal in v5. */
125
133
  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 };
134
+ /**
135
+ * v4.26.2 (#149; style inheritance fixed in v4.26.3): release-state
136
+ * banner. Shallow override (last defined wins); `false` suppresses a
137
+ * banner inherited from an earlier style.
138
+ */
139
+ readonly releaseStatus?: ReleaseStatusConfig | false;
128
140
 
129
141
  /**
130
142
  * Scoped consumer-side metadata. Ignored by the toolkit; survives composition
@@ -195,7 +207,8 @@ export function defineStyle(opts: StyleInput): Style {
195
207
  * - Top-level `defineBookConfig` fields beat any style (handled in config.ts)
196
208
  *
197
209
  * Per-key merge strategy:
198
- * - `preset`, `site`, `deploy`, `mdxComponentsModule`, `name` → shallow override (last wins)
210
+ * - `preset`, `numberStyle`, `site`, `deploy`, `mdxComponentsModule`, `name`, `releaseStatus`
211
+ * → shallow override (last defined wins; `releaseStatus: false` suppresses)
199
212
  * - `routes` → per-route spread (each route key independently overridable)
200
213
  * - `katexMacros` → per-macro spread (each macro key independently overridable)
201
214
  * - `extra` → per-key spread (consumer metadata accumulates across the chain)
@@ -216,8 +229,12 @@ export function composeStyles(styles: readonly Style[]): Style {
216
229
  // Shallow override for primitives + readonly-scalar fields.
217
230
  if (style.name !== undefined) merged.name = style.name;
218
231
  if (style.preset !== undefined) merged.preset = style.preset;
232
+ if (style.numberStyle !== undefined) merged.numberStyle = style.numberStyle;
219
233
  if (style.site !== undefined) merged.site = style.site;
220
234
  if (style.deploy !== undefined) merged.deploy = style.deploy;
235
+ if (style.releaseStatus !== undefined) {
236
+ merged.releaseStatus = style.releaseStatus;
237
+ }
221
238
  if (style.mdxComponentsModule !== undefined) {
222
239
  merged.mdxComponentsModule = style.mdxComponentsModule;
223
240
  }
@@ -29,11 +29,33 @@ export interface ChapterLike {
29
29
  data: Record<string, unknown>;
30
30
  }
31
31
 
32
- /** Normalize a base URL to exactly one trailing slash (`''` → `'/'`). */
33
- function normBase(baseUrl: string): string {
32
+ /**
33
+ * Normalize a base URL to exactly one trailing slash (`''`/`undefined` → `'/'`).
34
+ *
35
+ * v4.27.0 (#182): promoted from this module's private helper to THE shared
36
+ * normalizer — 18 `.astro` files previously inlined the same normalization in
37
+ * three regex idioms. Astro does not guarantee a trailing slash on `base`
38
+ * (`'/foo'` is a documented form), so every `import.meta.env.BASE_URL` read
39
+ * must pass through here before href composition: `${normalizeBase(...)}chapters/`.
40
+ * Takes the base as a PARAMETER because src/lib ships pre-compiled in dist/,
41
+ * where Vite's import.meta.env replacement cannot reach (see exam-manifest.ts).
42
+ */
43
+ export function normalizeBase(baseUrl: string | undefined): string {
34
44
  return (baseUrl || '/').replace(/\/*$/, '/');
35
45
  }
36
46
 
47
+ /**
48
+ * The composing variant (#182): strip ALL trailing slashes (`'/'` → `''`,
49
+ * `'/foo/'` → `'/foo'`) for `${baseNoSlash(...)}/answers`-style templates
50
+ * where the literal supplies the slash (Rationale.astro's route detection).
51
+ */
52
+ export function baseNoSlash(baseUrl: string | undefined): string {
53
+ return (baseUrl || '/').replace(/\/+$/, '');
54
+ }
55
+
56
+ /** Internal alias for the pre-#182 name. */
57
+ const normBase = normalizeBase;
58
+
37
59
  /** Replace `:book` / `:slug` / `:route` / `:id` tokens; `\b` keeps each token
38
60
  * whole, so order is irrelevant and `:id` never matches inside `:identifier`. */
39
61
  function fillTokens(pattern: string, tokens: Record<string, string>): string {
package/styles/layout.css CHANGED
@@ -4,10 +4,10 @@
4
4
  *
5
5
  * 1. Page chrome at very top: theme toggle (top-right, position: fixed).
6
6
  *
7
- * 2. Optional left sidebar (Sidebar.astro). At ≥64rem (1024px), grid
8
- * pins it to the left edge; below 1024px it's hidden (mobile pages
9
- * don't carry the nav). Toggle per-page via Base.astro's `showSidebar`
10
- * prop — default true; the landing page sets false.
7
+ * 2. Optional left sidebar (Sidebar.astro). At ≥80rem (1280px), grid
8
+ * pins it to the left edge; below 1280px it's hidden and the mobile
9
+ * nav drawer is the chapter nav (v4.26.0, #80). Toggle per-page via
10
+ * Base.astro's `showSidebar` prop — default true; landing sets false.
11
11
  *
12
12
  * 3. Main content (.prose) — Tufte 2-column with sidenotes in right
13
13
  * margin on desktop (≥48rem), inline on mobile (<48rem). See
@@ -225,15 +225,21 @@
225
225
  }
226
226
 
227
227
  /* ===== Mobile/tablet nav drawer (v4.26.0, #80) =====
228
- * Below the sidebar breakpoint (64rem) the left Sidebar is display:none — this
228
+ * Below the sidebar breakpoint (80rem matching .layout-with-sidebar above and
229
+ * the controller's desktop auto-close) the left Sidebar is display:none — this
229
230
  * is the navigation in that range: a hamburger in the chrome row toggles a
230
231
  * slide-in drawer reusing NavContent (the same book-scoped nav source as the
231
232
  * sidebar). No-JS baseline: `:target` opens it (the toggle is <a href="#nav-drawer">);
232
233
  * 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. */
234
+ * scroll-lock. Hidden at ≥80rem, where the persistent sidebar is the nav. The
235
+ * ≥64rem gutter tier (sidenote/section-map floats) is a separate, lower breakpoint. */
235
236
  @media (min-width: 80rem) {
236
- .nav-toggle,
237
+ /* #183: `.chrome-buttons .nav-toggle` (0,2,0), not bare `.nav-toggle` —
238
+ * Base.astro's global `.chrome-button { display: inline-flex }` ties a
239
+ * (0,1,0) selector and wins on cascade order, leaving the hamburger
240
+ * visible at desktop where it opens an invisible drawer and strands the
241
+ * body scroll-lock. Caught by drawer-interaction.spec.ts. */
242
+ .chrome-buttons .nav-toggle,
237
243
  .nav-drawer {
238
244
  display: none;
239
245
  }
package/styles/tokens.css CHANGED
@@ -85,19 +85,17 @@
85
85
  * Three-tier responsive measure tuned via Playwright comparison at
86
86
  * 375 / 1280 / 1440 / 1920px viewports (post_transformers commit d9d085b).
87
87
  *
88
- * The classic 65ch Tufte measure feels narrow on wide desktops (≥1440px)
89
- * where ~38% of the viewport sits as empty margin. The tiered approach
90
- * keeps 65ch where it's typographically right (laptop) and expands to
91
- * 80ch / 90ch on wider monitors without crossing the 90ch upper limit
92
- * of comfortable line length.
88
+ * The tiered measures leave room for the book-aware left sidebar and the
89
+ * Tufte right gutter at every desktop width. v4.26.0 tightened the older
90
+ * 65/80/90ch values after the responsive audit found horizontal overflow.
93
91
  *
94
92
  * Tier values + utilization (verified):
95
- * default (≤1440px): 65ch + 28ch → 70% at 1280px
96
- * 1440px tier: 80ch + 24ch → 69% (with sidebar: 89%)
97
- * 1920px tier: 90ch + 26ch → 57% (with sidebar: 72%)
93
+ * default: 60ch + 20ch
94
+ * 1440px tier: 66ch + 20ch
95
+ * 1920px tier: 78ch + 24ch
98
96
  *
99
- * Sidenote column shrinks slightly at wider tiers (24ch / 26ch) to give
100
- * main column more room while staying readable for short notes.
97
+ * These values keep `main + gutter layout-main` after subtracting the
98
+ * sidebar, while preserving readable line length and short-note width.
101
99
  *
102
100
  * Mobile (<48rem) handled separately in layout.css — sidenotes reflow
103
101
  * inline; the tokens here don't apply below 48rem.