@pagenary/publisher 2026.6.14 → 2026.6.16

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 (74) hide show
  1. package/examples/page-effects/config.json +2 -1
  2. package/examples/page-effects/content/disclosure.md +40 -0
  3. package/examples/page-effects/content/index.md +28 -0
  4. package/examples/page-effects/content/parallax-and-sticky.md +13 -0
  5. package/examples/page-effects/content/scroll-snap.md +29 -0
  6. package/examples/page-effects/content/scrollytelling.md +35 -0
  7. package/examples/page-effects/manifest.json +16 -4
  8. package/package.json +1 -1
  9. package/scripts/build-tenants.js +148 -20
  10. package/site/{app.2a8fb673db91.js → app.b987cce735d6.js} +1 -1
  11. package/site/app.js +1 -1
  12. package/site/assets/images/pipeline.e57f0dbfd05a.svg +33 -0
  13. package/site/assets/images/pipeline.svg +33 -0
  14. package/site/index.html +4 -4
  15. package/site/lib/page-effects.2131b53bea6b.js +1 -0
  16. package/site/lib/page-effects.js +1 -1
  17. package/site/llms.txt +11 -6
  18. package/site/{manifest.a870df3a026b.js → manifest.76e14cbcc513.js} +51 -46
  19. package/site/manifest.js +51 -46
  20. package/site/pages/accessible-authoring.html +4 -4
  21. package/site/pages/api.html +2 -2
  22. package/site/pages/architecture.html +2 -2
  23. package/site/pages/blog-layout.html +3 -3
  24. package/site/pages/deployment.html +2 -2
  25. package/site/pages/developer-guide.html +2 -2
  26. package/site/pages/extending.html +2 -2
  27. package/site/pages/overview.html +189 -0
  28. package/site/pages/page-effects.html +265 -0
  29. package/site/pages/publishing.html +258 -0
  30. package/site/pages/quickstart.html +2 -2
  31. package/site/pages/search-and-data.html +2 -2
  32. package/site/pages/seo-strategy.html +2 -2
  33. package/site/pages/showcase-gallery.html +184 -0
  34. package/site/pages/showcase-story.html +130 -0
  35. package/site/pages/tenant-config.html +12 -14
  36. package/site/pages/theming-recipes.html +2 -2
  37. package/site/pages/welcome.html +2 -2
  38. package/site/robots.txt +1 -1
  39. package/site/search-index/manifest.json +36 -38
  40. package/site/search-index/metadata.json +706 -355
  41. package/site/search-index/{part-0000.c67bff0d76c7.json → part-0000.510602c32169.json} +708 -355
  42. package/site/search-index/part-0000.json +708 -355
  43. package/site/sections/{blog-layout.f34ad33cdde0.js → blog-layout.3e80561d6838.js} +1 -1
  44. package/site/sections/blog-layout.js +1 -1
  45. package/site/sections/overview.5a483987fb9d.js +3 -0
  46. package/site/sections/overview.js +3 -0
  47. package/site/sections/page-effects.a38e5a7715d4.js +3 -0
  48. package/site/sections/page-effects.js +3 -0
  49. package/site/sections/publishing.32bf1d55b285.js +3 -0
  50. package/site/sections/publishing.js +3 -0
  51. package/site/sections/showcase-gallery.cd729f94752d.js +3 -0
  52. package/site/sections/showcase-gallery.js +3 -0
  53. package/site/sections/showcase-story.79311d1302c8.js +3 -0
  54. package/site/sections/showcase-story.js +3 -0
  55. package/site/sections/tenant-config.532868a466d7.js +3 -0
  56. package/site/sections/tenant-config.js +1 -1
  57. package/site/sitemap.xml +38 -26
  58. package/site/{styles.d957d4c1c1af.css → styles.bdb30ba34de5.css} +326 -8
  59. package/site/styles.css +326 -8
  60. package/src/app.js +35 -28
  61. package/src/lib/page-effects.js +410 -23
  62. package/src/styles.css +324 -6
  63. package/examples/page-effects/content/effects-spike.md +0 -212
  64. package/site/lib/page-effects.2e4e21674b13.js +0 -1
  65. package/site/pages/managed-hosting.html +0 -298
  66. package/site/pages/page-effects-spike.html +0 -152
  67. package/site/pages/theme-token-audit.html +0 -158
  68. package/site/sections/managed-hosting.e707893d4520.js +0 -3
  69. package/site/sections/managed-hosting.js +0 -3
  70. package/site/sections/page-effects-spike.d18d9601f432.js +0 -3
  71. package/site/sections/page-effects-spike.js +0 -3
  72. package/site/sections/tenant-config.e99335c66502.js +0 -3
  73. package/site/sections/theme-token-audit.182808e9139e.js +0 -3
  74. package/site/sections/theme-token-audit.js +0 -3
@@ -7,5 +7,6 @@
7
7
  "accentColor": "#e11d48",
8
8
  "surfaceColor": "#ffffff",
9
9
  "inkColor": "#0b1220",
10
- "bottomNav": "always"
10
+ "bottomNav": "always",
11
+ "livingScroll": true
11
12
  }
@@ -0,0 +1,40 @@
1
+ ---
2
+ title: Disclosure (accordion)
3
+ summary: Production accordion built on native <details> — works with zero JS, keyboard-operable, single-open optional.
4
+ hero:
5
+ eyebrow: Primitive
6
+ title: Disclosure
7
+ subtitle: An accordion that's just native <details> — accessible and complete with JavaScript off.
8
+ align: start
9
+ ---
10
+
11
+ # Disclosure (accordion)
12
+
13
+ `.pe-accordion` styles native `<details>/<summary>`, so each panel opens, closes,
14
+ and takes keyboard focus with **zero JavaScript**. Add `data-pe-single` to make it
15
+ single-open — opening one panel closes its siblings — which is a pure enhancement:
16
+ with JS off, every panel still works independently and no content is ever hidden
17
+ behind the script.
18
+
19
+ ```html
20
+ <div class="pe-accordion" data-pe-single>
21
+ <details open>
22
+ <summary>What is a page effect?</summary>
23
+ <p>An opt-in, accessible enhancement — heroes, reveal-on-scroll, living scroll,
24
+ disclosure — that degrades to plain content when JavaScript or motion is off.</p>
25
+ </details>
26
+ <details>
27
+ <summary>Does it work without JavaScript?</summary>
28
+ <p>Yes. The accordion is native <code>&lt;details&gt;</code>. The only thing JS
29
+ adds is the single-open grouping, and that's optional.</p>
30
+ </details>
31
+ <details>
32
+ <summary>Is it keyboard accessible?</summary>
33
+ <p>Native disclosure is operable with Enter/Space and shows a visible focus
34
+ ring. The open/closed state uses a +/− marker, not color alone.</p>
35
+ </details>
36
+ </div>
37
+ ```
38
+
39
+ Drop `data-pe-single` to let multiple panels stay open at once. Style is theme-token
40
+ aware, so it inherits each tenant's palette automatically.
@@ -81,6 +81,34 @@ the viewport, and appear instantly under reduced-motion.
81
81
  </div>
82
82
  ```
83
83
 
84
+ ## Staggered reveal
85
+
86
+ The cards below arrive in a wave — `data-reveal-stagger` on the grid sequences
87
+ each child's entrance as it scrolls into view (JS-off and reduced-motion show all
88
+ at once).
89
+
90
+ ```html
91
+ <div class="pe-card-grid" data-reveal-stagger="90">
92
+ <section class="pe-demo-card"><h3>One</h3><p>Arrives first.</p></section>
93
+ <section class="pe-demo-card"><h3>Two</h3><p>A beat later.</p></section>
94
+ <section class="pe-demo-card"><h3>Three</h3><p>Then this one.</p></section>
95
+ <section class="pe-demo-card"><h3>Four</h3><p>And finally this.</p></section>
96
+ </div>
97
+ ```
98
+
99
+ ## Figure zoom
100
+
101
+ The figure below shows its image normally; click it (or press Enter when focused)
102
+ to enlarge it in a modal. With JavaScript off, the image is just visible at its
103
+ normal size.
104
+
105
+ ```html
106
+ <figure class="pe-figure" data-pe-zoom>
107
+ <img src="images/ridge.svg" alt="Layered ridge illustration" />
108
+ <figcaption>Click to enlarge — opens a native dialog with focus trap and Esc-to-close.</figcaption>
109
+ </figure>
110
+ ```
111
+
84
112
  ## Authoring paths
85
113
 
86
114
  You have four ways to add a hero, from least to most control:
@@ -61,3 +61,16 @@ The CTA band primitive is just as portable:
61
61
  ```
62
62
 
63
63
  Keep scrolling to confirm the sticky hero releases at the end of the page.
64
+
65
+ ## Parallax on any element
66
+
67
+ The card below isn't a hero — it drifts on its own with `data-pe-parallax="0.35"`.
68
+ Any element can; the speed is clamped so it never wanders far, and it's skipped
69
+ entirely under reduced motion.
70
+
71
+ ```html
72
+ <div class="pe-demo-card" data-pe-parallax="0.35" style="max-width: 20rem;">
73
+ <h3>Parallax aside</h3>
74
+ <p>Drifts gently against the scroll — a non-hero element with its own speed.</p>
75
+ </div>
76
+ ```
@@ -0,0 +1,29 @@
1
+ ---
2
+ title: Scroll-snap sections
3
+ summary: A CSS-only .pe-snap region whose panels snap as you scroll — opt-in, no runtime, never traps the keyboard.
4
+ hero:
5
+ eyebrow: Primitive
6
+ title: Scroll-snap sections
7
+ subtitle: Bounded snap panels for landing pages — pure CSS, and the keyboard is never trapped.
8
+ align: start
9
+ ---
10
+
11
+ # Scroll-snap sections
12
+
13
+ `.pe-snap` is a **self-contained, opt-in** scroll region whose `.pe-snap__panel`
14
+ children snap into place as you scroll it. It's pure CSS — no runtime — and uses
15
+ `scroll-snap-type: y proximity` (not `mandatory`) inside a bounded height, so it
16
+ never takes over the page scroll and never traps keyboard users.
17
+
18
+ ```html
19
+ <div class="pe-snap">
20
+ <section class="pe-snap__panel"><h2>Capture</h2><p>Scroll inside this region — each panel snaps to the top.</p></section>
21
+ <section class="pe-snap__panel"><h2>Compose</h2><p>Proximity snapping, so you can still stop between panels.</p></section>
22
+ <section class="pe-snap__panel"><h2>Publish</h2><p>Tab through; the keyboard is never trapped.</p></section>
23
+ </div>
24
+ ```
25
+
26
+ Smooth glide to the snap points is gated under `prefers-reduced-motion:
27
+ no-preference`; reduced-motion readers get instant positioning. The panels are
28
+ ordinary sections, so the content is complete and reachable with or without
29
+ snapping.
@@ -0,0 +1,35 @@
1
+ ---
2
+ title: Scrollytelling
3
+ summary: A sticky stage beside content steps — the stage updates as each step scrolls into view. JS-off shows the steps as plain content.
4
+ hero:
5
+ eyebrow: Primitive
6
+ title: Scrollytelling
7
+ subtitle: A sticky stage that reacts as you read past each step — built from sticky layout + scroll position, no animation engine.
8
+ align: start
9
+ ---
10
+
11
+ # Scrollytelling
12
+
13
+ A `.pe-scrolly` block pairs a **sticky stage** with a column of `[data-pe-step]`
14
+ content steps. As each step scrolls into view, the stage's matching layer
15
+ crossfades in. With JavaScript off, the steps are ordinary readable content and
16
+ the stage shows its layers statically — the swap is a pure enhancement.
17
+
18
+ ```html
19
+ <div class="pe-scrolly">
20
+ <div class="pe-scrolly__stage">
21
+ <div data-pe-step="capture"><h2>Capture</h2></div>
22
+ <div data-pe-step="compose"><h2>Compose</h2></div>
23
+ <div data-pe-step="publish"><h2>Publish</h2></div>
24
+ </div>
25
+ <div class="pe-scrolly__steps">
26
+ <section data-pe-step="capture"><p>Write your content as Markdown, HTML, or a JS module.</p></section>
27
+ <section data-pe-step="compose"><p>Shared templates turn it into a branded, tenant-specific site.</p></section>
28
+ <section data-pe-step="publish"><p>Build emits static files — host them anywhere.</p></section>
29
+ </div>
30
+ </div>
31
+ ```
32
+
33
+ The active step is the last one to scroll past the upper-middle of the viewport —
34
+ the same rect-based detection scroll-spy uses, so no bespoke animation engine is
35
+ needed. The crossfade is gated under `prefers-reduced-motion: no-preference`.
@@ -14,10 +14,22 @@
14
14
  "file": "parallax-and-sticky.md"
15
15
  },
16
16
  {
17
- "id": "effects-spike",
18
- "title": "Effect Spike",
19
- "summary": "Throwaway prototypes for disclosure, scroll-snap panels, and figure zoom candidates.",
20
- "file": "effects-spike.md"
17
+ "id": "disclosure",
18
+ "title": "Disclosure",
19
+ "summary": "Production accordion on native <details> — zero-JS, keyboard-operable, single-open optional.",
20
+ "file": "disclosure.md"
21
+ },
22
+ {
23
+ "id": "scroll-snap",
24
+ "title": "Scroll-snap",
25
+ "summary": "CSS-only .pe-snap region — bounded, opt-in snap panels that never trap the keyboard.",
26
+ "file": "scroll-snap.md"
27
+ },
28
+ {
29
+ "id": "scrollytelling",
30
+ "title": "Scrollytelling",
31
+ "summary": "A sticky stage that reacts as you read past each step — sticky layout + scroll position, no animation engine.",
32
+ "file": "scrollytelling.md"
21
33
  }
22
34
  ]
23
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagenary/publisher",
3
- "version": "2026.6.14",
3
+ "version": "2026.6.16",
4
4
  "type": "module",
5
5
  "description": "Multi-tenant static publishing component for Pagenary platform.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -277,6 +277,11 @@ function isGitAvailable() {
277
277
  * Execute a command with timeout and retry
278
278
  */
279
279
  async function execWithRetry(command, options = {}) {
280
+ const commandSegments = Array.isArray(command) ? command : [];
281
+ if (commandSegments.length === 0) {
282
+ throw new Error('execWithRetry expected an argument array');
283
+ }
284
+
280
285
  const {
281
286
  cwd = root,
282
287
  timeout = DEFAULT_GIT_TIMEOUT,
@@ -288,10 +293,13 @@ async function execWithRetry(command, options = {}) {
288
293
  for (let attempt = 1; attempt <= retries; attempt++) {
289
294
  try {
290
295
  return await new Promise((resolve, reject) => {
291
- const proc = spawn('sh', ['-c', command], {
296
+ const proc = spawn(commandSegments[0], commandSegments.slice(1), {
292
297
  cwd,
293
298
  stdio: ['pipe', 'pipe', 'pipe'],
294
- env: { ...env, GIT_TERMINAL_PROMPT: '0' },
299
+ env: {
300
+ ...env,
301
+ GIT_TERMINAL_PROMPT: '0'
302
+ },
295
303
  timeout
296
304
  });
297
305
 
@@ -305,7 +313,10 @@ async function execWithRetry(command, options = {}) {
305
313
  if (code === 0) {
306
314
  resolve({ stdout, stderr });
307
315
  } else {
308
- reject(new Error(`Command failed (exit ${code}): ${stderr || stdout}`));
316
+ const prettyCommand = commandSegments
317
+ .map((segment) => maskAuthSegment(String(segment)))
318
+ .join(' ');
319
+ reject(new Error(`Command failed (exit ${code}): ${stderr || stdout} (command: ${prettyCommand})`));
309
320
  }
310
321
  });
311
322
 
@@ -334,6 +345,34 @@ async function execWithRetry(command, options = {}) {
334
345
  throw lastError;
335
346
  }
336
347
 
348
+ function maskAuthSegment(value) {
349
+ return value.replace(/\/\/[^@]+@/, '//***@');
350
+ }
351
+
352
+ /**
353
+ * Build an authenticated git URL when GIT_CREDENTIALS is available.
354
+ */
355
+ function withGitCredentials(rawUrl) {
356
+ const credentials = process.env.GIT_CREDENTIALS || '';
357
+ const [username, password] = credentials.split(':', 2);
358
+ if (!credentials || !username || password === undefined || !rawUrl) return rawUrl;
359
+
360
+ try {
361
+ const source = new URL(rawUrl);
362
+ if (!['https:', 'http:'].includes(source.protocol)) {
363
+ return rawUrl;
364
+ }
365
+
366
+ if (source.username) {
367
+ return rawUrl;
368
+ }
369
+
370
+ return `${source.protocol}//${encodeURIComponent(username)}:${encodeURIComponent(password)}@${source.host}${source.pathname}${source.search}${source.hash}`;
371
+ } catch {
372
+ return rawUrl;
373
+ }
374
+ }
375
+
337
376
  /**
338
377
  * Generate cache key for git source
339
378
  */
@@ -364,7 +403,10 @@ function isImmutableRef(ref) {
364
403
  */
365
404
  async function getHeadCommit(repoDir) {
366
405
  try {
367
- const { stdout } = await execWithRetry(`git -C "${repoDir}" rev-parse HEAD`, { cwd: root, retries: 1 });
406
+ const { stdout } = await execWithRetry(['git', '-C', repoDir, 'rev-parse', 'HEAD'], {
407
+ cwd: root,
408
+ retries: 1
409
+ });
368
410
  return stdout.trim();
369
411
  } catch {
370
412
  return null;
@@ -385,7 +427,7 @@ async function getChangedFiles(repoDir, oldCommit, newCommit, subPath = '.') {
385
427
  try {
386
428
  // Use --name-status to get file status (A=added, M=modified, D=deleted)
387
429
  const { stdout } = await execWithRetry(
388
- `git -C "${repoDir}" diff --name-status ${oldCommit} ${newCommit}`,
430
+ ['git', '-C', repoDir, 'diff', '--name-status', oldCommit, newCommit],
389
431
  { cwd: root, retries: 1 }
390
432
  );
391
433
 
@@ -461,6 +503,7 @@ async function cloneGitSource(source, cacheDir, options = {}) {
461
503
 
462
504
  // Sanitize URL for logging (hide credentials)
463
505
  const safeUrl = url.replace(/\/\/[^@]+@/, '//***@');
506
+ const authUrl = withGitCredentials(url);
464
507
 
465
508
  console.log(` ↳ git source: ${safeUrl}`);
466
509
  console.log(` ↳ ref: ${ref}, path: ${subPath || '(root)'}`);
@@ -494,8 +537,8 @@ async function cloneGitSource(source, cacheDir, options = {}) {
494
537
  console.log(` ↳ current HEAD: ${oldCommit ? oldCommit.slice(0, 7) : 'unknown'}`);
495
538
 
496
539
  try {
497
- await execWithRetry(`git -C "${cloneDir}" fetch origin ${ref} --depth ${effectiveDepth}`, { cwd: root });
498
- await execWithRetry(`git -C "${cloneDir}" checkout FETCH_HEAD`, { cwd: root });
540
+ await execWithRetry(['git', '-C', cloneDir, 'fetch', 'origin', ref, '--depth', String(effectiveDepth)], { cwd: root });
541
+ await execWithRetry(['git', '-C', cloneDir, 'checkout', 'FETCH_HEAD'], { cwd: root });
499
542
  newCommit = await getHeadCommit(cloneDir);
500
543
  console.log(` ↳ updated HEAD: ${newCommit ? newCommit.slice(0, 7) : 'unknown'}`);
501
544
  } catch (err) {
@@ -513,33 +556,33 @@ async function cloneGitSource(source, cacheDir, options = {}) {
513
556
  wasCloned = true;
514
557
 
515
558
  // Build clone command
516
- let cloneCmd = `git clone --depth ${effectiveDepth}`;
559
+ const cloneArgs = ['git', 'clone', '--depth', String(effectiveDepth)];
517
560
 
518
561
  // Add branch/ref
519
562
  // For commits, we can't use --branch, need to fetch after
520
563
  if (!isImmutableRef(ref) || /^v?\d+\.\d+/.test(ref)) {
521
- cloneCmd += ` --branch ${ref}`;
564
+ cloneArgs.push('--branch', ref);
522
565
  }
523
566
 
524
567
  // Sparse checkout preparation
525
568
  if (effectiveSparse) {
526
- cloneCmd += ' --filter=blob:none --sparse';
569
+ cloneArgs.push('--filter=blob:none', '--sparse');
527
570
  }
528
571
 
529
- cloneCmd += ` "${url}" "${cloneDir}"`;
572
+ cloneArgs.push(authUrl, cloneDir);
530
573
 
531
574
  try {
532
- await execWithRetry(cloneCmd, { cwd: root });
575
+ await execWithRetry(cloneArgs, { cwd: root });
533
576
 
534
577
  // If ref is a commit SHA, checkout after clone
535
578
  if (/^[0-9a-f]{7,40}$/i.test(ref) && !/^v?\d+\.\d+/.test(ref)) {
536
- await execWithRetry(`git -C "${cloneDir}" fetch --depth ${effectiveDepth} origin ${ref}`, { cwd: root });
537
- await execWithRetry(`git -C "${cloneDir}" checkout ${ref}`, { cwd: root });
579
+ await execWithRetry(['git', '-C', cloneDir, 'fetch', '--depth', String(effectiveDepth), 'origin', ref], { cwd: root });
580
+ await execWithRetry(['git', '-C', cloneDir, 'checkout', ref], { cwd: root });
538
581
  }
539
582
 
540
583
  // Set up sparse checkout if needed
541
584
  if (effectiveSparse) {
542
- await execWithRetry(`git -C "${cloneDir}" sparse-checkout set "${subPath}"`, { cwd: root });
585
+ await execWithRetry(['git', '-C', cloneDir, 'sparse-checkout', 'set', subPath], { cwd: root });
543
586
  }
544
587
 
545
588
  newCommit = await getHeadCommit(cloneDir);
@@ -1670,6 +1713,83 @@ async function applyReadingProgressConfig(distDir, config, tenantId) {
1670
1713
  console.log(` ↳ enabled reading progress for ${tenantId}`);
1671
1714
  }
1672
1715
 
1716
+ /**
1717
+ * Living scroll on any layout (docs included). Blog tenants opt in via
1718
+ * `blog.livingScroll` (handled in applyBlogLayout); this is the general path,
1719
+ * driven by a top-level `livingScroll: true` (or `reader.livingScroll`). It sets
1720
+ * the layout-agnostic body flag plus a reading-progress bar. The hidden base
1721
+ * state, reduced-motion fallback, and JS-off completeness all live in
1722
+ * page-effects.js + styles.css — the build only sets the hooks.
1723
+ */
1724
+ async function applyLivingScrollConfig(distDir, config, tenantId) {
1725
+ const reader = (config.reader && typeof config.reader === 'object') ? config.reader : {};
1726
+ if (config.livingScroll !== true && reader.livingScroll !== true) return;
1727
+
1728
+ const indexPath = path.join(distDir, 'index.html');
1729
+ if (!(await pathExists(indexPath))) return;
1730
+
1731
+ let html = await fsp.readFile(indexPath, 'utf8');
1732
+ // Idempotent, and don't fight applyBlogLayout if it already set the blog alias.
1733
+ if (/<body[^>]*data-(?:blog-)?living-scroll(?:[\s=>])/.test(html)) return;
1734
+ const attrs = ['data-living-scroll'];
1735
+ // Living scroll pairs with a progress bar; only add it if not already set
1736
+ // (applyReadingProgressConfig runs first and may have added it).
1737
+ if (!/<body[^>]*data-reading-progress(?:[\s=>])/.test(html)) {
1738
+ attrs.push('data-reading-progress');
1739
+ }
1740
+ html = html.replace(/<body(?=[\s>])/, `<body ${attrs.join(' ')}`);
1741
+ await fsp.writeFile(indexPath, html, 'utf8');
1742
+ console.log(` ↳ enabled living scroll for ${tenantId}`);
1743
+ }
1744
+
1745
+ /**
1746
+ * On-this-page TOC + scroll-spy (#73). Opt-in via a top-level `pageToc`:
1747
+ * pageToc: true → rail placement, default min
1748
+ * pageToc: { placement: "rail"|"top"|"off", minHeadings: N }
1749
+ * Sets the body hooks the pageToc page-effect reads; the effect generates the nav
1750
+ * client-side from the page's headings.
1751
+ */
1752
+ async function applyPageTocConfig(distDir, config, tenantId) {
1753
+ const toc = config.pageToc;
1754
+ if (toc !== true && (!toc || typeof toc !== 'object')) return;
1755
+ const placementRaw = toc && typeof toc === 'object' ? toc.placement : undefined;
1756
+ if (placementRaw === 'off' || (toc && typeof toc === 'object' && toc.enabled === false)) return;
1757
+ const placement = placementRaw === 'top' ? 'top' : 'rail';
1758
+ const min = toc && typeof toc === 'object' && Number.isInteger(toc.minHeadings) && toc.minHeadings > 0
1759
+ ? toc.minHeadings : null;
1760
+
1761
+ const indexPath = path.join(distDir, 'index.html');
1762
+ if (!(await pathExists(indexPath))) return;
1763
+
1764
+ let html = await fsp.readFile(indexPath, 'utf8');
1765
+ if (/<body[^>]*data-page-toc(?:[\s=>])/.test(html)) return;
1766
+ const attrs = [`data-page-toc="${placement}"`];
1767
+ if (min) attrs.push(`data-page-toc-min="${min}"`);
1768
+ html = html.replace(/<body(?=[\s>])/, `<body ${attrs.join(' ')}`);
1769
+ await fsp.writeFile(indexPath, html, 'utf8');
1770
+ console.log(` ↳ enabled on-this-page TOC (${placement}) for ${tenantId}`);
1771
+ }
1772
+
1773
+ /**
1774
+ * Sidebar nav collapse behavior. Configurable via `navCollapse`:
1775
+ * "overlay" (default) — drawer hidden by default; hamburger slides it over the content
1776
+ * "push" — nav visible; collapse slides it out and reflows the content
1777
+ * "instant" — nav visible; collapse instantly hides the column
1778
+ * Writes data-nav-collapse so styles.css can switch modes (drawer modes apply only
1779
+ * to the default left-sidebar layout; positioned-nav demos are unaffected).
1780
+ */
1781
+ async function applyNavCollapseConfig(distDir, config, tenantId) {
1782
+ const raw = typeof config.navCollapse === 'string' ? config.navCollapse.toLowerCase() : '';
1783
+ const mode = raw === 'push' || raw === 'instant' ? raw : 'overlay';
1784
+ const indexPath = path.join(distDir, 'index.html');
1785
+ if (!(await pathExists(indexPath))) return;
1786
+ let html = await fsp.readFile(indexPath, 'utf8');
1787
+ if (/<body[^>]*data-nav-collapse(?:[\s=>])/.test(html)) return;
1788
+ html = html.replace(/<body(?=[\s>])/, `<body data-nav-collapse="${mode}"`);
1789
+ await fsp.writeFile(indexPath, html, 'utf8');
1790
+ console.log(` ↳ nav collapse mode: ${mode} for ${tenantId}`);
1791
+ }
1792
+
1673
1793
  /**
1674
1794
  * Write the synthetic blog-index section module and register it in manifest.js.
1675
1795
  * Mirrors applyDocsMap's injection (append + idempotent).
@@ -3822,8 +3942,11 @@ async function processNestedContent(sourceDir, distDir, tenantId, contentRoot, o
3822
3942
  const layout = typeof config.layout === 'string' ? config.layout.toLowerCase() : 'docs';
3823
3943
  const blogCfg = (config.blog && typeof config.blog === 'object') ? config.blog : {};
3824
3944
  const siteConfig = {
3825
- bottomNav: rootManifest?.bottomNav || 'mobile',
3826
- bottomNavSections: rootManifest?.bottomNavSections || [],
3945
+ // Prev/next article nav at the bottom of each page is a generic docs feature,
3946
+ // visible on all screens by default. Honor config.json or the root manifest;
3947
+ // a tenant can scope it down with "mobile" or turn it off with "never".
3948
+ bottomNav: config.bottomNav || rootManifest?.bottomNav || 'always',
3949
+ bottomNavSections: config.bottomNavSections || rootManifest?.bottomNavSections || [],
3827
3950
  // Pass SEO-relevant config to SPA for dynamic meta tag updates.
3828
3951
  // siteUrl falls back to `domain` (#15); ogImage drives social cards (#16).
3829
3952
  siteTitle: config.title || '',
@@ -4022,7 +4145,7 @@ function buildManifestModuleSource(manifestEntries, defaultSection, siteConfig =
4022
4145
  const manifestJson = JSON.stringify(manifestEntries, null, 2);
4023
4146
  const defaultJson = JSON.stringify(defaultSection || null);
4024
4147
  const configJson = JSON.stringify({
4025
- bottomNav: siteConfig.bottomNav || 'mobile', // 'always' | 'mobile' | 'never'
4148
+ bottomNav: siteConfig.bottomNav || 'always', // 'always' | 'mobile' | 'never'
4026
4149
  bottomNavSections: siteConfig.bottomNavSections || [], // Section prefixes to always show nav
4027
4150
  ...siteConfig
4028
4151
  }, null, 2);
@@ -4215,8 +4338,10 @@ async function processTenantManifestLegacy(sourceDir, distDir, tenantId, options
4215
4338
 
4216
4339
  // Extract site configuration from manifest
4217
4340
  const siteConfig = {
4218
- bottomNav: manifestData.bottomNav || 'mobile',
4219
- bottomNavSections: manifestData.bottomNavSections || [],
4341
+ // Prev/next article nav is a generic docs feature, visible on all screens by
4342
+ // default; honor config.json or the manifest, scope down with "mobile"/"never".
4343
+ bottomNav: config.bottomNav || manifestData.bottomNav || 'always',
4344
+ bottomNavSections: config.bottomNavSections || manifestData.bottomNavSections || [],
4220
4345
  // SEO-relevant config for runtime meta updates — parity with the nested path
4221
4346
  // so the runtime <title> brand matches config.title, not a generic fallback (#29).
4222
4347
  siteTitle: config.title || manifestData.title || '',
@@ -4776,6 +4901,9 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
4776
4901
  await applyNavAlignment(distDir, config, tenantId);
4777
4902
  await applyBlogLayout(distDir, config, tenantId);
4778
4903
  await applyReadingProgressConfig(distDir, config, tenantId);
4904
+ await applyLivingScrollConfig(distDir, config, tenantId);
4905
+ await applyPageTocConfig(distDir, config, tenantId);
4906
+ await applyNavCollapseConfig(distDir, config, tenantId);
4779
4907
  await applyDocsMap(distDir, config, tenantId);
4780
4908
  await applyWelcome(distDir, config, tenantId);
4781
4909
  }