hd-garden 0.9.3 → 0.9.4

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.
@@ -1,8 +1,9 @@
1
1
  import type { Metadata } from "next";
2
2
  import { notFound } from "next/navigation";
3
3
  import { bookshelf } from "@garden/bookshelf";
4
- import { BookHub } from "../../components/bookshelf/BookHub";
4
+ import { Bookshelf } from "../../components/bookshelf";
5
5
  import { pageAlternates } from "../../lib/page-alternates";
6
+ import { siteConfig } from "../../site.config";
6
7
  import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
7
8
  import type { BookshelfBook } from "../../components/bookshelf/types";
8
9
 
@@ -47,9 +48,25 @@ export default async function BookHubPage({
47
48
  }) {
48
49
  const { bookId } = await params;
49
50
  const found = hubBooks().find((item) => item.book.id === bookId);
50
- if (!found) {
51
+ if (!found || !bookshelf) {
51
52
  if (isStaticPlaceholder(bookId)) return null;
52
53
  notFound();
53
54
  }
54
- return <BookHub shelfTitle={found.shelfTitle} book={found.book} />;
55
+ return (
56
+ <main className="sh-main bookshelf-page" id="content">
57
+ <header className="sh-head">
58
+ {bookshelf.eyebrow ? <p className="sh-kicker">{bookshelf.eyebrow}</p> : null}
59
+ <h1 id="sh-title">{bookshelf.title}</h1>
60
+ {bookshelf.description ? <p className="sh-deck">{bookshelf.description}</p> : null}
61
+ </header>
62
+ <Bookshelf
63
+ embedded
64
+ catalog={bookshelf.catalog}
65
+ notes={bookshelf.notes}
66
+ title={bookshelf.title}
67
+ eyebrow={bookshelf.eyebrow ?? siteConfig.name}
68
+ initialHub={found.book.id}
69
+ />
70
+ </main>
71
+ );
55
72
  }
@@ -79,6 +79,7 @@ function shelfShell(brand: string, title: string) {
79
79
  <div class="note-view" id="noteView" hidden>
80
80
  <article class="note">
81
81
  <button type="button" class="back-btn" id="backBtn">← 返回书架</button>
82
+ <div class="hub-panel" id="hubPanel" hidden></div>
82
83
  <div class="note-hero">
83
84
  <div class="note-cover" id="noteCover"></div>
84
85
  <div class="note-meta">
@@ -101,7 +102,14 @@ function shelfShell(brand: string, title: string) {
101
102
  }
102
103
 
103
104
  /** Approved bookshelf. Pass `catalog` and `notes` for a site shelf; omit them for the package demo. */
104
- export function Bookshelf({ catalog, notes, title, eyebrow, embedded = false }: BookshelfProps) {
105
+ export function Bookshelf({
106
+ catalog,
107
+ notes,
108
+ title,
109
+ eyebrow,
110
+ embedded = false,
111
+ initialHub,
112
+ }: BookshelfProps) {
105
113
  const ref = useRef<HTMLDivElement>(null);
106
114
  useLayoutEffect(() => {
107
115
  const root = ref.current;
@@ -111,13 +119,18 @@ export function Bookshelf({ catalog, notes, title, eyebrow, embedded = false }:
111
119
  if (saved != null) window.scrollTo(0, saved);
112
120
  };
113
121
  applyScroll();
114
- const unmount = mountBookshelf(root, { catalog: withNoteLinks(catalog), notes });
122
+ const unmount = mountBookshelf(root, {
123
+ catalog: withNoteLinks(catalog),
124
+ notes,
125
+ initialHub,
126
+ shelfHref: siteHref("/bookshelf"),
127
+ });
115
128
  const frame = window.requestAnimationFrame(applyScroll);
116
129
  return () => {
117
130
  window.cancelAnimationFrame(frame);
118
131
  unmount?.();
119
132
  };
120
- }, [catalog, notes, title, eyebrow, embedded]);
133
+ }, [catalog, notes, title, eyebrow, embedded, initialHub]);
121
134
  return (
122
135
  <div
123
136
  className={embedded ? "bookshelf-demo is-site" : "bookshelf-demo"}
@@ -283,6 +283,7 @@
283
283
  overscroll-behavior-x: contain;
284
284
  scrollbar-width: none;
285
285
  }
286
+ .bookshelf-demo .spines.has-jackets { gap: 10px; }
286
287
  .bookshelf-demo .spines::-webkit-scrollbar { display: none; }
287
288
 
288
289
  .bookshelf-demo .spine {
@@ -353,14 +354,29 @@
353
354
  transition: opacity 220ms var(--ease-motion);
354
355
  }
355
356
 
356
- /* Mode C: temporary expand on spine wall */
357
- /* resting spines: solid hue only; cover image only when expanded */
358
- /* Expanded / extracting face must not fall back to bk-* (#513688 etc.).
359
- The jacket is .cover-art. A missing image stays cream shelf, not a shell pad. */
357
+ /* Mode C: temporary expand on spine wall.
358
+ Demo spines (no .has-jacket) stay cloth until expand.
359
+ A real books[].cover is .has-jacket: the <img> is the face at rest,
360
+ on hover, and while extracting. bk-* must not paint a plate over it.
361
+ The old `.spine[class*="img-"] { background-image: none !important }`
362
+ was that plate. */
360
363
  .bookshelf-demo .spine.is-expanded,
361
- .bookshelf-demo .spine.is-extracting {
364
+ .bookshelf-demo .spine.is-extracting,
365
+ .bookshelf-demo .spine.has-jacket {
362
366
  background-color: transparent !important;
363
367
  }
368
+ .bookshelf-demo .spine.has-jacket {
369
+ padding: 0;
370
+ writing-mode: horizontal-tb;
371
+ text-orientation: mixed;
372
+ letter-spacing: 0;
373
+ background-image: none;
374
+ }
375
+ .bookshelf-demo .spine.has-jacket > span {
376
+ opacity: 0 !important;
377
+ visibility: hidden !important;
378
+ }
379
+ .bookshelf-demo .spine > .cover-art,
364
380
  .bookshelf-demo .spine.is-expanded > .cover-art,
365
381
  .bookshelf-demo .spine.is-extracting > .cover-art {
366
382
  position: absolute;
@@ -374,33 +390,34 @@
374
390
  z-index: 1;
375
391
  border-radius: inherit;
376
392
  }
377
- .bookshelf-demo .spine[class*="img-"] {
393
+ /* Demo catalog only: cloth at rest, jacket when the spine expands. */
394
+ .bookshelf-demo .spine[class*="img-"]:not(.has-jacket) {
378
395
  background-image: none !important;
379
396
  }
380
- .bookshelf-demo .spine.is-expanded[class*="img-"] {
397
+ .bookshelf-demo .spine.is-expanded[class*="img-"]:not(.has-jacket) {
381
398
  background-image: unset;
382
399
  }
383
- .bookshelf-demo .spine.is-expanded.img-clean-code { background-image: url("/covers/clean-code.jpg") !important; }
384
- .bookshelf-demo .spine.is-expanded.img-refactoring { background-image: url("/covers/refactoring.jpg") !important; }
385
- .bookshelf-demo .spine.is-expanded.img-design-patterns { background-image: url("/covers/design-patterns.jpg") !important; }
386
- .bookshelf-demo .spine.is-expanded.img-pragmatic-programmer { background-image: url("/covers/pragmatic-programmer.jpg") !important; }
387
- .bookshelf-demo .spine.is-expanded.img-continuous-delivery { background-image: url("/covers/continuous-delivery.jpg") !important; }
388
- .bookshelf-demo .spine.is-expanded.img-phoenix-project { background-image: url("/covers/phoenix-project.jpg") !important; }
389
- .bookshelf-demo .spine.is-expanded.img-mythical-man-month { background-image: url("/covers/mythical-man-month.jpg") !important; }
390
- .bookshelf-demo .spine.is-expanded.img-tdd { background-image: url("/covers/tdd.jpg") !important; }
391
- .bookshelf-demo .spine.is-expanded.img-lean-startup { background-image: url("/covers/lean-startup.jpg") !important; }
392
- .bookshelf-demo .spine.is-expanded.img-release-it { background-image: url("/covers/release-it.jpg") !important; }
393
- .bookshelf-demo .spine.is-expanded.img-sre { background-image: url("/covers/sre.jpg") !important; }
394
- .bookshelf-demo .spine.is-expanded.img-team-topologies { background-image: url("/covers/team-topologies.jpg") !important; }
395
- .bookshelf-demo .spine.is-expanded.img-accelerate { background-image: url("/covers/accelerate.jpg") !important; }
396
- .bookshelf-demo .spine.is-expanded.img-ddd { background-image: url("/covers/ddd.jpg") !important; }
397
- .bookshelf-demo .spine.is-expanded.img-microservices { background-image: url("/covers/microservices.jpg") !important; }
398
- .bookshelf-demo .spine.is-expanded.img-eaa { background-image: url("/covers/eaa.jpg") !important; }
399
- .bookshelf-demo .spine.is-expanded.img-ddia { background-image: url("/covers/ddia.jpg") !important; }
400
- .bookshelf-demo .spine.is-expanded.img-fundamentals-sa { background-image: url("/covers/fundamentals-sa.jpg") !important; }
401
- .bookshelf-demo .spine.is-expanded.img-implementing-ddd { background-image: url("/covers/implementing-ddd.jpg") !important; }
402
- .bookshelf-demo .spine.is-expanded.img-systems-performance { background-image: url("/covers/systems-performance.jpg") !important; }
403
- .bookshelf-demo .spine.is-expanded.img-clean-architecture { background-image: url("/covers/clean-architecture.jpg") !important; }
400
+ .bookshelf-demo .spine.is-expanded.img-clean-code:not(.has-jacket) { background-image: url("/covers/clean-code.jpg") !important; }
401
+ .bookshelf-demo .spine.is-expanded.img-refactoring:not(.has-jacket) { background-image: url("/covers/refactoring.jpg") !important; }
402
+ .bookshelf-demo .spine.is-expanded.img-design-patterns:not(.has-jacket) { background-image: url("/covers/design-patterns.jpg") !important; }
403
+ .bookshelf-demo .spine.is-expanded.img-pragmatic-programmer:not(.has-jacket) { background-image: url("/covers/pragmatic-programmer.jpg") !important; }
404
+ .bookshelf-demo .spine.is-expanded.img-continuous-delivery:not(.has-jacket) { background-image: url("/covers/continuous-delivery.jpg") !important; }
405
+ .bookshelf-demo .spine.is-expanded.img-phoenix-project:not(.has-jacket) { background-image: url("/covers/phoenix-project.jpg") !important; }
406
+ .bookshelf-demo .spine.is-expanded.img-mythical-man-month:not(.has-jacket) { background-image: url("/covers/mythical-man-month.jpg") !important; }
407
+ .bookshelf-demo .spine.is-expanded.img-tdd:not(.has-jacket) { background-image: url("/covers/tdd.jpg") !important; }
408
+ .bookshelf-demo .spine.is-expanded.img-lean-startup:not(.has-jacket) { background-image: url("/covers/lean-startup.jpg") !important; }
409
+ .bookshelf-demo .spine.is-expanded.img-release-it:not(.has-jacket) { background-image: url("/covers/release-it.jpg") !important; }
410
+ .bookshelf-demo .spine.is-expanded.img-sre:not(.has-jacket) { background-image: url("/covers/sre.jpg") !important; }
411
+ .bookshelf-demo .spine.is-expanded.img-team-topologies:not(.has-jacket) { background-image: url("/covers/team-topologies.jpg") !important; }
412
+ .bookshelf-demo .spine.is-expanded.img-accelerate:not(.has-jacket) { background-image: url("/covers/accelerate.jpg") !important; }
413
+ .bookshelf-demo .spine.is-expanded.img-ddd:not(.has-jacket) { background-image: url("/covers/ddd.jpg") !important; }
414
+ .bookshelf-demo .spine.is-expanded.img-microservices:not(.has-jacket) { background-image: url("/covers/microservices.jpg") !important; }
415
+ .bookshelf-demo .spine.is-expanded.img-eaa:not(.has-jacket) { background-image: url("/covers/eaa.jpg") !important; }
416
+ .bookshelf-demo .spine.is-expanded.img-ddia:not(.has-jacket) { background-image: url("/covers/ddia.jpg") !important; }
417
+ .bookshelf-demo .spine.is-expanded.img-fundamentals-sa:not(.has-jacket) { background-image: url("/covers/fundamentals-sa.jpg") !important; }
418
+ .bookshelf-demo .spine.is-expanded.img-implementing-ddd:not(.has-jacket) { background-image: url("/covers/implementing-ddd.jpg") !important; }
419
+ .bookshelf-demo .spine.is-expanded.img-systems-performance:not(.has-jacket) { background-image: url("/covers/systems-performance.jpg") !important; }
420
+ .bookshelf-demo .spine.is-expanded.img-clean-architecture:not(.has-jacket) { background-image: url("/covers/clean-architecture.jpg") !important; }
404
421
 
405
422
  .bookshelf-demo .spine.is-expanded {
406
423
  width: 100px;
@@ -435,7 +452,8 @@
435
452
  box-shadow var(--t-collapse) var(--ease-motion),
436
453
  background-size var(--t-collapse) var(--ease-motion);
437
454
  }
438
- /* C collapse: shrink while still "expanded" class — drop cover image to solid hue immediately, never flash a wide black plate */
455
+ /* C collapse: shrink while still "expanded". Real jackets keep .cover-art.
456
+ Demo cloth may drop the expanded photo; it must not clear a .has-jacket. */
439
457
  .bookshelf-demo .spine.is-expanded.is-collapsing {
440
458
  width: 28px;
441
459
  height: 150px;
@@ -449,10 +467,18 @@
449
467
  box-shadow:
450
468
  inset -1px 0 0 rgba(255,255,255,0.12),
451
469
  inset 1px 0 0 rgba(0,0,0,0.18);
452
- background-image: none !important;
453
470
  background-size: auto;
454
471
  z-index: 2;
455
472
  }
473
+ .bookshelf-demo .spine.is-expanded.is-collapsing:not(.has-jacket) {
474
+ background-image: none !important;
475
+ }
476
+ .bookshelf-demo .spine.has-jacket.is-expanded.is-collapsing {
477
+ writing-mode: horizontal-tb;
478
+ padding: 0;
479
+ background-color: transparent !important;
480
+ background-image: none;
481
+ }
456
482
  .bookshelf-demo .spine.is-expanded.is-collapsing.s-tall { width: 30px; height: 168px; }
457
483
  .bookshelf-demo .spine.is-expanded.is-collapsing.s-mid { width: 28px; height: 152px; }
458
484
  .bookshelf-demo .spine.is-expanded.is-collapsing.s-short { width: 24px; height: 136px; }
@@ -823,7 +849,24 @@
823
849
  opacity: 0 !important;
824
850
  }
825
851
 
826
- /* ux16: exact homeRect standing spine — full height, spine width, slides out/in */
852
+ /* ux16: exact homeRect standing spine — full height, spine width, slides out/in.
853
+ A real jacket fills the slot. bk-* cloth is only the filler / demo label. */
854
+ .bookshelf-demo .flyer.flyer-spine-slot.has-jacket {
855
+ background: none !important;
856
+ background-color: transparent !important;
857
+ box-shadow: none;
858
+ padding: 0;
859
+ }
860
+ .bookshelf-demo .flyer-spine-slot-img {
861
+ position: absolute;
862
+ inset: 0;
863
+ width: 100%;
864
+ height: 100%;
865
+ object-fit: contain;
866
+ object-position: center bottom;
867
+ display: block;
868
+ border-radius: inherit;
869
+ }
827
870
  .bookshelf-demo .flyer.flyer-spine-slot {
828
871
  position: fixed;
829
872
  margin: 0;
@@ -1161,35 +1204,6 @@
1161
1204
  .bookshelf-demo .s-tall { height: 148px; width: 26px; }
1162
1205
  .bookshelf-demo .s-mid { height: 134px; width: 24px; }
1163
1206
  .bookshelf-demo .s-short { height: 120px; width: 20px; }
1164
- /* resting spines: solid hue only; cover image only when expanded */
1165
- .bookshelf-demo .spine[class*="img-"] {
1166
- background-image: none !important;
1167
- }
1168
- .bookshelf-demo .spine.is-expanded[class*="img-"] {
1169
- background-image: unset;
1170
- }
1171
- .bookshelf-demo .spine.is-expanded.img-clean-code { background-image: url("/covers/clean-code.jpg") !important; }
1172
- .bookshelf-demo .spine.is-expanded.img-refactoring { background-image: url("/covers/refactoring.jpg") !important; }
1173
- .bookshelf-demo .spine.is-expanded.img-design-patterns { background-image: url("/covers/design-patterns.jpg") !important; }
1174
- .bookshelf-demo .spine.is-expanded.img-pragmatic-programmer { background-image: url("/covers/pragmatic-programmer.jpg") !important; }
1175
- .bookshelf-demo .spine.is-expanded.img-continuous-delivery { background-image: url("/covers/continuous-delivery.jpg") !important; }
1176
- .bookshelf-demo .spine.is-expanded.img-phoenix-project { background-image: url("/covers/phoenix-project.jpg") !important; }
1177
- .bookshelf-demo .spine.is-expanded.img-mythical-man-month { background-image: url("/covers/mythical-man-month.jpg") !important; }
1178
- .bookshelf-demo .spine.is-expanded.img-tdd { background-image: url("/covers/tdd.jpg") !important; }
1179
- .bookshelf-demo .spine.is-expanded.img-lean-startup { background-image: url("/covers/lean-startup.jpg") !important; }
1180
- .bookshelf-demo .spine.is-expanded.img-release-it { background-image: url("/covers/release-it.jpg") !important; }
1181
- .bookshelf-demo .spine.is-expanded.img-sre { background-image: url("/covers/sre.jpg") !important; }
1182
- .bookshelf-demo .spine.is-expanded.img-team-topologies { background-image: url("/covers/team-topologies.jpg") !important; }
1183
- .bookshelf-demo .spine.is-expanded.img-accelerate { background-image: url("/covers/accelerate.jpg") !important; }
1184
- .bookshelf-demo .spine.is-expanded.img-ddd { background-image: url("/covers/ddd.jpg") !important; }
1185
- .bookshelf-demo .spine.is-expanded.img-microservices { background-image: url("/covers/microservices.jpg") !important; }
1186
- .bookshelf-demo .spine.is-expanded.img-eaa { background-image: url("/covers/eaa.jpg") !important; }
1187
- .bookshelf-demo .spine.is-expanded.img-ddia { background-image: url("/covers/ddia.jpg") !important; }
1188
- .bookshelf-demo .spine.is-expanded.img-fundamentals-sa { background-image: url("/covers/fundamentals-sa.jpg") !important; }
1189
- .bookshelf-demo .spine.is-expanded.img-implementing-ddd { background-image: url("/covers/implementing-ddd.jpg") !important; }
1190
- .bookshelf-demo .spine.is-expanded.img-systems-performance { background-image: url("/covers/systems-performance.jpg") !important; }
1191
- .bookshelf-demo .spine.is-expanded.img-clean-architecture { background-image: url("/covers/clean-architecture.jpg") !important; }
1192
-
1193
1207
  .bookshelf-demo .spine.is-expanded {
1194
1208
  width: 90px;
1195
1209
  height: 136px;
@@ -1328,6 +1342,10 @@ body:has(.bookshelf-demo.is-site) .sh-header .sh-word {
1328
1342
  text-underline-offset: 3px;
1329
1343
  }
1330
1344
  .bookshelf-demo .note-read[hidden] { display: none; }
1345
+ .bookshelf-demo .hub-panel[hidden] { display: none !important; }
1346
+ .bookshelf-demo .note-view.is-hub .note-hero,
1347
+ .bookshelf-demo .note-view.is-hub .note-body { display: none !important; }
1348
+ .bookshelf-demo .hub-panel .book-hub { width: 100%; padding: 0; }
1331
1349
  .bookshelf-demo.is-site .note-read { color: var(--blue); }
1332
1350
  @media (max-width: 820px) {
1333
1351
  .bookshelf-demo.is-site .note { padding: 16px 20px 64px; }
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable */
2
2
  // @ts-nocheck
3
3
  // Tip ux26 held-book-s10-ux26-20261028. Motion copied from that app.js. Do not retune it.
4
+ import { ui } from "../../lib/i18n";
4
5
  import { DEMO_CATALOG, DEMO_NOTES } from "./demo-data";
5
6
 
6
7
  function cssIdent(id) {
@@ -12,6 +13,19 @@ function coverSrc(book) {
12
13
  return `/covers/${book.id}.jpg`;
13
14
  }
14
15
 
16
+ /** Site jackets are URL strings. Demo `cover: true` stays a cloth spine at rest. */
17
+ function realCover(book) {
18
+ return !!(book && typeof book.cover === "string" && book.cover && !book.filler);
19
+ }
20
+
21
+ function escapeHtml(value) {
22
+ return String(value ?? "")
23
+ .replaceAll("&", "&amp;")
24
+ .replaceAll("<", "&lt;")
25
+ .replaceAll(">", "&gt;")
26
+ .replaceAll('"', "&quot;");
27
+ }
28
+
15
29
  function injectExtraCovers(catalog) {
16
30
  const rules = [];
17
31
  for (const cat of catalog) {
@@ -43,6 +57,10 @@ export function mountBookshelf(root, options = {}) {
43
57
  const CATALOG = options.catalog ?? DEMO_CATALOG;
44
58
  const NOTES = options.notes ?? (options.catalog ? {} : DEMO_NOTES);
45
59
  const usingDemo = !options.catalog;
60
+ const initialHub = typeof options.initialHub === "string" ? options.initialHub : "";
61
+ const shelfHref = typeof options.shelfHref === "string" && options.shelfHref
62
+ ? options.shelfHref
63
+ : "/bookshelf/";
46
64
  const removeExtraCovers = injectExtraCovers(CATALOG);
47
65
 
48
66
  // ux26: per-book cover dominant (= existing bk-* spine colors) for open-shell + page wash
@@ -297,6 +315,24 @@ export function mountBookshelf(root, options = {}) {
297
315
  return window.innerWidth <= 640 ? 102 : 118;
298
316
  }
299
317
 
318
+ // Real jackets stand face-out. Width fits the row when it can (≥72);
319
+ // otherwise 72 and the row scrolls, so the art stays readable.
320
+ function jacketFaceWidth(count) {
321
+ const col = Math.min((window.innerWidth || 1280) - 32, 740);
322
+ const gap = 10;
323
+ const full = coverWidthPx();
324
+ const n = Math.max(1, count | 0);
325
+ const fit = Math.floor((col - 8 - gap * Math.max(0, n - 1)) / n);
326
+ if (fit >= 72) return Math.min(full, fit);
327
+ return Math.min(full, 72);
328
+ }
329
+
330
+ function faceHeightFor(book, w) {
331
+ const nat = bookNat(book);
332
+ if (!nat) return Math.max(96, Math.round(w / 0.68));
333
+ return Math.max(96, Math.round(w * (nat.h / nat.w)));
334
+ }
335
+
300
336
  function bookNat(book) {
301
337
  if (!book || !book.cover) return null;
302
338
  const pair = COVER_NAT[book.id];
@@ -306,10 +342,7 @@ export function mountBookshelf(root, options = {}) {
306
342
 
307
343
  // Fixed width; height from real cover aspect. Fallback ~2:3 if unknown.
308
344
  function bookFaceHeight(book) {
309
- const w = coverWidthPx();
310
- const nat = bookNat(book);
311
- if (!nat) return Math.round(w / 0.68);
312
- return Math.max(96, Math.round(w * (nat.h / nat.w)));
345
+ return faceHeightFor(book, coverWidthPx());
313
346
  }
314
347
 
315
348
  // Real jacket on the expanded spine. CSS background-image is stripped by
@@ -338,18 +371,21 @@ export function mountBookshelf(root, options = {}) {
338
371
 
339
372
  function applyFaceSize(el, book, { expanded = false } = {}) {
340
373
  if (!el || !book) return;
341
- const faceH = bookFaceHeight(book);
342
- if (el.classList.contains("cover") || expanded) {
343
- const w = coverWidthPx();
374
+ const jacket = realCover(book) && el.classList.contains("spine");
375
+ const stored = jacket ? Number(el.dataset.jacketW) : 0;
376
+ const w = expanded ? coverWidthPx() : (stored > 0 ? stored : (jacket ? jacketFaceWidth(1) : coverWidthPx()));
377
+ const faceH = faceHeightFor(book, jacket || el.classList.contains("cover") || expanded ? w : coverWidthPx());
378
+ if (el.classList.contains("cover") || expanded || jacket) {
344
379
  el.style.width = w + "px";
345
380
  el.style.height = faceH + "px";
346
381
  el.style.backgroundSize = "contain";
347
382
  el.style.backgroundPosition = "center top";
348
383
  el.style.backgroundRepeat = "no-repeat";
384
+ el.dataset.faceH = String(faceH);
349
385
  } else if (el.classList.contains("spine")) {
350
- // Resting spine: same height as that book's cover face; width from size class.
386
+ // Demo / filler spine: height tracks the cover face; width stays the cloth class.
351
387
  el.style.height = faceH + "px";
352
- el.style.removeProperty("width"); // keep CSS size class width
388
+ el.style.removeProperty("width");
353
389
  el.dataset.faceH = String(faceH);
354
390
  }
355
391
  }
@@ -557,16 +593,24 @@ export function mountBookshelf(root, options = {}) {
557
593
  } else {
558
594
  const row = document.createElement("div");
559
595
  row.className = "spines";
596
+ const jacketCount = cat.books.filter(realCover).length;
597
+ if (jacketCount) row.classList.add("has-jackets");
598
+ const jacketW = jacketFaceWidth(Math.max(1, jacketCount));
560
599
  for (const book of cat.books) {
561
600
  const btn = document.createElement("button");
562
601
  btn.type = "button";
563
602
  const sizeCls = book.size === "thin" ? "thin dark-text" : `${book.size} light-text`;
564
603
  btn.className = `spine ${bkClass(book)} ${sizeCls}`;
565
604
  if (book.cover) btn.classList.add(coverClass(book));
605
+ if (realCover(book)) {
606
+ btn.classList.add("has-jacket");
607
+ btn.dataset.jacketW = String(jacketW);
608
+ }
566
609
  btn.dataset.bookId = book.id;
567
610
  btn.setAttribute("aria-label", book.title);
568
- btn.innerHTML = `<span>${book.spine}</span>`;
611
+ btn.innerHTML = `<span>${escapeHtml(book.spine)}</span>`;
569
612
  if (book.cover && !book.filler) applyFaceSize(btn, book);
613
+ if (realCover(book)) mountSeatCover(btn, book);
570
614
  else if (book.filler) { /* thin filler keeps CSS height */ }
571
615
 
572
616
  if (book.cover && !book.filler) {
@@ -604,13 +648,19 @@ export function mountBookshelf(root, options = {}) {
604
648
  function restoreRestingSpine(el, bookId) {
605
649
  if (!el) return;
606
650
  el.classList.remove("is-expanded", "is-collapsing");
607
- clearSeatCover(el);
608
651
  const found = bookById(bookId);
609
- if (found && found.book.cover && !found.book.filler) {
652
+ if (found && realCover(found.book)) {
610
653
  applyFaceSize(el, found.book, { expanded: false });
654
+ mountSeatCover(el, found.book);
655
+ el.classList.add("has-jacket");
611
656
  } else {
612
- el.style.removeProperty("width");
613
- el.style.removeProperty("height");
657
+ clearSeatCover(el);
658
+ if (found && found.book.cover && !found.book.filler) {
659
+ applyFaceSize(el, found.book, { expanded: false });
660
+ } else {
661
+ el.style.removeProperty("width");
662
+ el.style.removeProperty("height");
663
+ }
614
664
  }
615
665
  }
616
666
 
@@ -618,13 +668,10 @@ export function mountBookshelf(root, options = {}) {
618
668
  $$(".spine[data-book-id]").forEach((el) => {
619
669
  const id = el.dataset.bookId;
620
670
  if (!id || id === exceptId) return;
621
- if (
622
- el.classList.contains("is-expanded") ||
623
- el.classList.contains("is-collapsing") ||
624
- el.style.width
625
- ) {
626
- restoreRestingSpine(el, id);
627
- }
671
+ const expandedLike = el.classList.contains("is-expanded") || el.classList.contains("is-collapsing");
672
+ // Real jackets keep an inline face width at rest. That is not an orphan expand.
673
+ const strayWidth = !!el.style.width && !el.classList.contains("has-jacket");
674
+ if (expandedLike || strayWidth) restoreRestingSpine(el, id);
628
675
  });
629
676
  }
630
677
 
@@ -660,7 +707,10 @@ export function mountBookshelf(root, options = {}) {
660
707
  if (!el) return;
661
708
  // Keep is-expanded + expandedId during shrink so a click mid-collapse still takes C path.
662
709
  // Drop inline width FIRST — ux17 face size would otherwise beat .is-collapsing CSS width.
663
- el.style.removeProperty("width");
710
+ // Real jackets keep their resting face; clearing width snaps them to a 28px cloth bar.
711
+ const found = bookById(id);
712
+ if (found && realCover(found.book)) applyFaceSize(el, found.book, { expanded: false });
713
+ else el.style.removeProperty("width");
664
714
  el.classList.add("is-collapsing");
665
715
  const token = ++collapseToken;
666
716
  await wait(cssMs("--t-collapse", 230));
@@ -677,30 +727,55 @@ export function mountBookshelf(root, options = {}) {
677
727
  collapseToken += 1;
678
728
  }
679
729
 
680
- // Multi-note books leave the shelf for /bookshelf/<id>. No extract flyer.
681
- function leaveForHub(book) {
730
+ // Two or more notes: the shelf extract lands on the book hub, not the first note.
731
+ // List → article is an ordinary link and does not use this.
732
+ function isHubBook(book) {
682
733
  const posts = book && Array.isArray(book.posts) ? book.posts : [];
683
- const href = book && typeof book.hub === "string" ? book.hub : "";
684
- if (posts.length < 2 || !href) return false;
685
- const y = scrollLock ? scrollLock.y : readScroll().y;
686
- try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
687
- window.location.assign(href);
688
- return true;
734
+ return posts.length >= 2 && typeof book.hub === "string" && !!book.hub;
735
+ }
736
+
737
+ function samePath(a, b) {
738
+ const norm = (value) => String(value || "").replace(/\/+$/u, "") || "/";
739
+ return norm(a) === norm(b);
740
+ }
741
+
742
+ function rememberHubUrl() {
743
+ if (!openBook || !openBook.hubMode || !openBook.book || !openBook.book.hub) return;
744
+ if (openBook.hubEntry === "direct" || openBook.hubEntry === "pushed") return;
745
+ const url = openBook.book.hub;
746
+ if (samePath(location.pathname, url)) {
747
+ openBook.hubEntry = "direct";
748
+ return;
749
+ }
750
+ try {
751
+ history.pushState(history.state, "", url);
752
+ openBook.hubEntry = "pushed";
753
+ } catch (_) {
754
+ openBook.hubEntry = "direct";
755
+ }
756
+ }
757
+
758
+ function releaseHubUrl() {
759
+ if (!openBook || !openBook.hubMode || openBook.hubEntry === "history") return;
760
+ if (samePath(location.pathname, shelfHref)) return;
761
+ try { history.replaceState(history.state, "", shelfHref); } catch (_) {}
689
762
  }
690
763
 
691
764
  async function onCoverClick(id, el) {
692
765
  if (busy) return;
693
766
  const found = bookById(id);
694
767
  if (!found || found.book.filler) return;
695
- if (leaveForHub(found.book)) return;
696
- await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: false });
768
+ await openBookFlow(found.book, found.cat, el, {
769
+ fromExpanded: false,
770
+ asSpine: false,
771
+ hub: isHubBook(found.book),
772
+ });
697
773
  }
698
774
 
699
775
  async function onSpineClick(id, el, ev) {
700
776
  if (busy) return;
701
777
  const found = bookById(id);
702
778
  if (!found || found.book.filler) return;
703
- if (leaveForHub(found.book)) return;
704
779
  if (!found.book.cover) return;
705
780
 
706
781
  const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
@@ -709,7 +784,11 @@ export function mountBookshelf(root, options = {}) {
709
784
  if (isTouch || window.matchMedia("(hover: none)").matches) {
710
785
  touchExpandedId = null;
711
786
  if (expandedId) await collapseSpine(expandedId);
712
- await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: true });
787
+ await openBookFlow(found.book, found.cat, el, {
788
+ fromExpanded: false,
789
+ asSpine: true,
790
+ hub: isHubBook(found.book),
791
+ });
713
792
  return;
714
793
  }
715
794
 
@@ -717,10 +796,105 @@ export function mountBookshelf(root, options = {}) {
717
796
  const fromExpanded =
718
797
  (expandedId === id && el.classList.contains("is-expanded")) ||
719
798
  el.classList.contains("is-collapsing");
720
- await openBookFlow(found.book, found.cat, el, { fromExpanded, asSpine: true });
799
+ await openBookFlow(found.book, found.cat, el, {
800
+ fromExpanded,
801
+ asSpine: true,
802
+ hub: isHubBook(found.book),
803
+ });
804
+ }
805
+
806
+ function showNoteChrome() {
807
+ noteView.classList.remove("is-hub");
808
+ const hub = $("#hubPanel");
809
+ if (hub) {
810
+ hub.hidden = true;
811
+ hub.replaceChildren();
812
+ }
813
+ }
814
+
815
+ function showHubChrome() {
816
+ noteView.classList.add("is-hub");
817
+ const hub = $("#hubPanel");
818
+ if (hub) hub.hidden = false;
819
+ }
820
+
821
+ function hubKindLabel(kind) {
822
+ const t = ui();
823
+ switch (kind) {
824
+ case "overview":
825
+ return t.bookHubOverview;
826
+ case "excerpt":
827
+ return t.bookHubExcerpt;
828
+ case undefined:
829
+ return "";
830
+ default: {
831
+ const unexpected = kind;
832
+ return unexpected;
833
+ }
834
+ }
835
+ }
836
+
837
+ function hubDay(value) {
838
+ const date = new Date(value);
839
+ if (Number.isNaN(date.getTime())) return String(value).slice(0, 10);
840
+ return new Intl.DateTimeFormat("en-CA", {
841
+ timeZone: "Asia/Shanghai",
842
+ year: "numeric",
843
+ month: "2-digit",
844
+ day: "2-digit",
845
+ }).format(date);
846
+ }
847
+
848
+ function hubSourceList(book) {
849
+ const list = Array.isArray(book.sources) ? book.sources : [];
850
+ const douban = list.filter((source) => source.label === "豆瓣" || /^douban$/iu.test(source.label));
851
+ const pdf = list.filter((source) => /pdf/iu.test(source.label));
852
+ return [...douban, ...pdf];
853
+ }
854
+
855
+ function fillHub(book) {
856
+ showHubChrome();
857
+ const t = ui();
858
+ const panel = $("#hubPanel");
859
+ if (!panel) return;
860
+ const jacket = realCover(book) ? coverSrc(book) : "";
861
+ const sources = hubSourceList(book).map((source) => {
862
+ const external = /^(?:https?:)?\/\//iu.test(source.href);
863
+ const pdf = /pdf/iu.test(source.label);
864
+ const attrs = external ? ` target="_blank" rel="noopener noreferrer"` : "";
865
+ return `<span class="book-hub-source"><a class="book-hub-chip" href="${escapeHtml(source.href)}"${attrs}>${escapeHtml(source.label)}<span class="ext" aria-hidden="true">↗</span></a>${pdf ? `<span class="book-hub-source-note">${escapeHtml(t.bookHubPdfNote)}</span>` : ""}</span>`;
866
+ }).join("");
867
+ const notes = (Array.isArray(book.posts) ? book.posts : []).map((entry) => {
868
+ const label = hubKindLabel(entry.kind);
869
+ const href = entry.href || `/blog/${entry.path}`;
870
+ const when = entry.date ? hubDay(entry.date) : "";
871
+ const count = entry.words ? t.wordCount(entry.words) : "";
872
+ const meta = when || count
873
+ ? `<p class="book-hub-note-meta">${escapeHtml(when)}${when && count ? " · " : ""}${escapeHtml(count)}</p>`
874
+ : "";
875
+ return `<li><a class="book-hub-note" href="${escapeHtml(href)}">${label ? `<p class="book-hub-note-label">${escapeHtml(label)}</p>` : ""}<h3>${escapeHtml(entry.title || book.title)}</h3>${entry.summary ? `<p class="book-hub-note-sum">${escapeHtml(entry.summary)}</p>` : ""}${meta}</a></li>`;
876
+ }).join("");
877
+ panel.innerHTML = `<div class="book-hub">
878
+ <section class="book-hub-card" aria-labelledby="book-hub-title">
879
+ ${jacket ? `<div class="book-hub-cover"><img src="${escapeHtml(jacket)}" alt="" width="148"></div>` : ""}
880
+ <div class="book-hub-meta">
881
+ <h1 id="book-hub-title">${escapeHtml(book.title)}</h1>
882
+ ${book.author ? `<p class="book-hub-author">${escapeHtml(book.author)}</p>` : ""}
883
+ ${sources ? `<div class="book-hub-sources">${sources}</div>` : ""}
884
+ </div>
885
+ </section>
886
+ <h2 class="book-hub-notes-head">${escapeHtml(t.bookHubNotes)}</h2>
887
+ <ul class="book-hub-notes">${notes}</ul>
888
+ </div>`;
889
+ const read = $("#noteRead");
890
+ if (read) {
891
+ read.hidden = true;
892
+ read.removeAttribute("href");
893
+ }
721
894
  }
722
895
 
723
896
  function fillNote(book, cat) {
897
+ showNoteChrome();
724
898
  $("#noteCat").textContent = cat.name;
725
899
  $("#noteTitle").textContent = book.title;
726
900
  $("#noteAuthor").textContent = book.author || "";
@@ -892,10 +1066,23 @@ export function mountBookshelf(root, options = {}) {
892
1066
  el.style.opacity = String(opacity);
893
1067
  el.style.zIndex = "80";
894
1068
  el.style.pointerEvents = "none";
895
- const label = document.createElement("span");
896
- label.className = "flyer-spine-slot-label";
897
- label.textContent = book.spine || book.title || "";
898
- el.appendChild(label);
1069
+ if (book && book.cover && !book.filler) {
1070
+ el.classList.add("has-jacket", coverClass(book));
1071
+ el.style.backgroundColor = "transparent";
1072
+ el.style.backgroundImage = "none";
1073
+ el.style.padding = "0";
1074
+ const img = document.createElement("img");
1075
+ img.className = "flyer-spine-slot-img";
1076
+ img.alt = "";
1077
+ img.draggable = false;
1078
+ img.src = coverSrc(book);
1079
+ el.appendChild(img);
1080
+ } else {
1081
+ const label = document.createElement("span");
1082
+ label.className = "flyer-spine-slot-label";
1083
+ label.textContent = book.spine || book.title || "";
1084
+ el.appendChild(label);
1085
+ }
899
1086
  stage.appendChild(el);
900
1087
  return el;
901
1088
  }
@@ -1047,16 +1234,31 @@ export function mountBookshelf(root, options = {}) {
1047
1234
  ];
1048
1235
  }
1049
1236
 
1050
- // Article is gone by ~0.12 (230ms). Shrink has already started on frame 0.
1237
+ // Close start must not composite the open spread on the note (double exposure).
1238
+ // The article is gone within ~80ms. The flyer stays hidden until then, already
1239
+ // collapsed to a single jacket, then flies home.
1051
1240
  function noteOutKeys() {
1052
1241
  return [
1053
1242
  { offset: 0, opacity: 1 },
1054
- { offset: 0.05, opacity: 0.35 },
1055
- { offset: 0.12, opacity: 0 },
1243
+ { offset: 0.02, opacity: 0 },
1056
1244
  { offset: 1, opacity: 0 },
1057
1245
  ];
1058
1246
  }
1059
1247
 
1248
+ function closedReturnLeftKeys() {
1249
+ return [
1250
+ { offset: 0, transform: "rotateY(180deg)" },
1251
+ { offset: 1, transform: "rotateY(180deg)" },
1252
+ ];
1253
+ }
1254
+
1255
+ function closedReturnRightKeys() {
1256
+ return [
1257
+ { offset: 0, opacity: 0, transform: "rotateY(0deg)" },
1258
+ { offset: 1, opacity: 0, transform: "rotateY(0deg)" },
1259
+ ];
1260
+ }
1261
+
1060
1262
  // ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
1061
1263
  // X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
1062
1264
  // Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
@@ -1367,7 +1569,7 @@ export function mountBookshelf(root, options = {}) {
1367
1569
  "is-expanded",
1368
1570
  "is-collapsing"
1369
1571
  );
1370
- clearSeatCover(el);
1572
+ if (!(book && realCover(book))) clearSeatCover(el);
1371
1573
  // Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
1372
1574
  [
1373
1575
  "width",
@@ -1391,6 +1593,10 @@ export function mountBookshelf(root, options = {}) {
1391
1593
  applyFaceSize(el, book);
1392
1594
  } else if (book && el.classList.contains("spine") && book.cover && !book.filler) {
1393
1595
  applyFaceSize(el, book);
1596
+ if (realCover(book)) {
1597
+ el.classList.add("has-jacket");
1598
+ mountSeatCover(el, book);
1599
+ }
1394
1600
  }
1395
1601
  }
1396
1602
 
@@ -1560,6 +1766,24 @@ export function mountBookshelf(root, options = {}) {
1560
1766
  }
1561
1767
  }
1562
1768
 
1769
+ async function prepareHub(book) {
1770
+ fillHub(book);
1771
+ noteView.hidden = false;
1772
+ noteView.classList.add("is-preparing", "is-hub");
1773
+ noteView.classList.remove("is-open");
1774
+ noteView.style.opacity = "0";
1775
+ const img = $("#hubPanel") && $("#hubPanel").querySelector("img");
1776
+ if (img && !coverImgReady(img)) {
1777
+ try {
1778
+ const decodeOrLoad = img.decode
1779
+ ? img.decode()
1780
+ : new Promise((res) => { img.onload = res; img.onerror = res; });
1781
+ await Promise.race([decodeOrLoad.catch(() => {}), wait(400)]);
1782
+ } catch (_) {}
1783
+ }
1784
+ void noteView.offsetWidth;
1785
+ }
1786
+
1563
1787
  async function prepareNote(book, cat) {
1564
1788
  fillNote(book, cat);
1565
1789
  noteView.hidden = false;
@@ -1610,13 +1834,22 @@ export function mountBookshelf(root, options = {}) {
1610
1834
  restoreSlot(el);
1611
1835
  }
1612
1836
 
1613
- async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine }) {
1837
+ async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine, hub }) {
1614
1838
  if (busy) return;
1615
1839
  busy = true;
1616
1840
  const myFlow = ++flowSeq;
1617
1841
  document.body.classList.add("is-busy");
1618
1842
  root.classList.remove("is-return");
1619
- openBook = { book, cat, asSpine, fromExpanded: !!fromExpanded, openSeatRect: null };
1843
+ const hubMode = !!hub && isHubBook(book);
1844
+ openBook = {
1845
+ book,
1846
+ cat,
1847
+ asSpine,
1848
+ fromExpanded: !!fromExpanded,
1849
+ hubMode,
1850
+ hubEntry: hubMode ? "pending" : "",
1851
+ openSeatRect: null,
1852
+ };
1620
1853
  openFromExpanded = !!fromExpanded;
1621
1854
 
1622
1855
  // ux6b: freeze window scroll to idle/expand home (face-out stuck ~197px up).
@@ -1629,7 +1862,8 @@ export function mountBookshelf(root, options = {}) {
1629
1862
  } catch (_) {}
1630
1863
 
1631
1864
  try {
1632
- await prepareNote(book, cat);
1865
+ if (hubMode) await prepareHub(book);
1866
+ else await prepareNote(book, cat);
1633
1867
  } catch (_) {
1634
1868
  // Decode/prepare failed — still open; never leave is-busy hung.
1635
1869
  }
@@ -1644,6 +1878,7 @@ export function mountBookshelf(root, options = {}) {
1644
1878
  noteView.classList.add("is-open");
1645
1879
  noteView.style.opacity = "1";
1646
1880
  parkShelf();
1881
+ rememberHubUrl();
1647
1882
  releaseScrollLock();
1648
1883
  busy = false;
1649
1884
  document.body.classList.remove("is-busy");
@@ -1989,6 +2224,7 @@ export function mountBookshelf(root, options = {}) {
1989
2224
  unlockNoteClicks();
1990
2225
  holdFlyerUnderNote();
1991
2226
  parkShelf();
2227
+ rememberHubUrl();
1992
2228
  restoreSlot(sourceEl, book);
1993
2229
  sourceEl.classList.remove("is-extracting");
1994
2230
  // Keep sourceEl as invisible reserved seat while note open (no gray plate).
@@ -2026,13 +2262,14 @@ export function mountBookshelf(root, options = {}) {
2026
2262
  const targetMode = pageMode;
2027
2263
 
2028
2264
  if (reduceMotion()) {
2029
- noteView.classList.remove("is-open", "is-preparing");
2265
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2030
2266
  noteView.hidden = true;
2031
2267
  noteView.style.opacity = "";
2032
2268
  expandedId = null;
2033
2269
  openFromExpanded = false;
2034
2270
  unparkShelf();
2035
2271
  renderShelf();
2272
+ releaseHubUrl();
2036
2273
  clearGhost(
2037
2274
  targetMode === "covers"
2038
2275
  ? $(`.cover[data-book-id="${book.id}"]`)
@@ -2069,47 +2306,36 @@ export function mountBookshelf(root, options = {}) {
2069
2306
  stashEarly.Tp2 &&
2070
2307
  stashEarly.Tp3
2071
2308
  ) {
2309
+ // Hide the spread on the first frame. Shrinking starts now, but the
2310
+ // open pages must not sit on top of the note.
2072
2311
  heldEarly.style.visibility = "visible";
2073
2312
  heldEarly.style.zIndex = "70";
2074
- heldEarly.style.opacity = "1";
2313
+ heldEarly.style.opacity = "0";
2075
2314
  if (heldEarly._board) {
2076
2315
  heldEarly._board.style.opacity = "0";
2077
2316
  heldEarly._board.style.visibility = "hidden";
2078
2317
  }
2318
+ if (heldEarly._right) heldEarly._right.style.opacity = "0";
2319
+ if (heldEarly._left) heldEarly._left.style.transform = "rotateY(180deg)";
2320
+ if (heldEarly._leftThick) heldEarly._leftThick.style.opacity = "0";
2321
+ if (heldEarly._rightThick) heldEarly._rightThick.style.opacity = "0";
2322
+ if (heldEarly._gutter) heldEarly._gutter.style.opacity = "0";
2079
2323
  promote(heldEarly);
2080
2324
  earlyMove = heldEarly.animate(
2081
2325
  returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
2082
2326
  closeMotion
2083
2327
  );
2084
2328
  earlyNote = noteView.animate(noteOutKeys(), closeOpts);
2085
- // Backup if the fade is dropped: the article must not still be up after ~230ms.
2086
- wait(Math.round(CLOSE_MS * 0.12)).then(() => {
2329
+ // Backup if the fade is dropped: the article must not still be up.
2330
+ wait(Math.round(CLOSE_MS * 0.05)).then(() => {
2087
2331
  if (myFlow !== flowSeq || !noteView) return;
2088
2332
  noteView.style.opacity = "0";
2089
2333
  });
2090
2334
  if (heldEarly._left) {
2091
- earlyLeft = heldEarly._left.animate(
2092
- [
2093
- { offset: 0, transform: "rotateY(8deg)" },
2094
- { offset: 0.08, transform: "rotateY(20deg)" },
2095
- { offset: 0.16, transform: "rotateY(70deg)" },
2096
- { offset: 0.24, transform: "rotateY(140deg)" },
2097
- { offset: 0.32, transform: "rotateY(180deg)" },
2098
- { offset: 1.0, transform: "rotateY(180deg)" },
2099
- ],
2100
- closeMotion
2101
- );
2335
+ earlyLeft = heldEarly._left.animate(closedReturnLeftKeys(), closeMotion);
2102
2336
  }
2103
2337
  if (heldEarly._right) {
2104
- earlyRight = heldEarly._right.animate(
2105
- [
2106
- { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
2107
- { offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
2108
- { offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
2109
- { offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
2110
- ],
2111
- closeMotion
2112
- );
2338
+ earlyRight = heldEarly._right.animate(closedReturnRightKeys(), closeMotion);
2113
2339
  }
2114
2340
  }
2115
2341
 
@@ -2141,9 +2367,10 @@ export function mountBookshelf(root, options = {}) {
2141
2367
  try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
2142
2368
  try { earlyRight && earlyRight.cancel(); } catch (_) {}
2143
2369
  root.classList.remove("is-return");
2144
- noteView.classList.remove("is-open", "is-preparing");
2370
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2145
2371
  noteView.hidden = true;
2146
2372
  noteView.style.opacity = "";
2373
+ releaseHubUrl();
2147
2374
  openBook = null;
2148
2375
  busy = false;
2149
2376
  document.body.classList.remove("is-busy");
@@ -2297,19 +2524,20 @@ export function mountBookshelf(root, options = {}) {
2297
2524
  }
2298
2525
  }
2299
2526
  }
2300
- // Ensure open-spread leaf pose at Tfull so reverse hinge is continuous (under note).
2301
- if (flyer._left) flyer._left.style.transform = "rotateY(8deg)";
2527
+ // Collapse to one jacket before the flyer is allowed to paint. An open
2528
+ // spread here is the full-screen double exposure over the note.
2529
+ if (flyer._left) flyer._left.style.transform = "rotateY(180deg)";
2302
2530
  if (flyer._right) {
2303
- flyer._right.style.transform = "rotateY(-8deg)";
2304
- flyer._right.style.opacity = "1";
2531
+ flyer._right.style.transform = "rotateY(0deg)";
2532
+ flyer._right.style.opacity = "0";
2305
2533
  }
2306
2534
  if (flyer._board) {
2307
2535
  flyer._board.style.opacity = "0";
2308
2536
  flyer._board.style.visibility = "hidden";
2309
2537
  }
2310
- if (flyer._leftThick) flyer._leftThick.style.opacity = "1";
2311
- if (flyer._rightThick) flyer._rightThick.style.opacity = "1";
2312
- if (flyer._gutter) flyer._gutter.style.opacity = "1";
2538
+ if (flyer._leftThick) flyer._leftThick.style.opacity = "0";
2539
+ if (flyer._rightThick) flyer._rightThick.style.opacity = "0";
2540
+ if (flyer._gutter) flyer._gutter.style.opacity = "0";
2313
2541
  const closeSpineLabel = flyer._cover && flyer._cover.querySelector(".flyer-spine-label");
2314
2542
  if (closeSpineLabel) closeSpineLabel.style.opacity = "0";
2315
2543
  // ux23: spines may swap to img-only; covers keep applyCoverImage bg under img
@@ -2357,7 +2585,7 @@ export function mountBookshelf(root, options = {}) {
2357
2585
  // Start the reverse on this frame. A pre-clock rAF left the spread parked at
2358
2586
  // 672×504 while the note was still up (the ~240ms close-start hitch).
2359
2587
  void readyPulse;
2360
- if (!earlyMove) flyer.style.opacity = "1";
2588
+ if (!earlyMove) flyer.style.opacity = "0";
2361
2589
 
2362
2590
  // ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
2363
2591
  const PUT_MS = 380;
@@ -2380,33 +2608,13 @@ export function mountBookshelf(root, options = {}) {
2380
2608
  );
2381
2609
  // Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
2382
2610
  const aLeftClose = earlyLeft || (flyer._left
2383
- ? flyer._left.animate(
2384
- [
2385
- { offset: 0, transform: "rotateY(8deg)" },
2386
- { offset: 0.08, transform: "rotateY(20deg)" },
2387
- { offset: 0.16, transform: "rotateY(70deg)" },
2388
- { offset: 0.24, transform: "rotateY(140deg)" },
2389
- { offset: 0.32, transform: "rotateY(180deg)" },
2390
- { offset: 1.0, transform: "rotateY(180deg)" },
2391
- ],
2392
- closeMotion
2393
- )
2611
+ ? flyer._left.animate(closedReturnLeftKeys(), closeMotion)
2394
2612
  : null);
2395
2613
  const aRightClose = earlyRight || (flyer._right
2396
- ? flyer._right.animate(
2397
- [
2398
- { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
2399
- { offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
2400
- { offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
2401
- { offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
2402
- ],
2403
- closeMotion
2404
- )
2614
+ ? flyer._right.animate(closedReturnRightKeys(), closeMotion)
2405
2615
  : null);
2406
2616
  const closedFaceOut = [
2407
- { offset: 0, opacity: 1 },
2408
- { offset: 0.18, opacity: 0.7 },
2409
- { offset: 0.28, opacity: 0 },
2617
+ { offset: 0, opacity: 0 },
2410
2618
  { offset: 1.0, opacity: 0 },
2411
2619
  ];
2412
2620
  const aBoardClose = null;
@@ -2415,8 +2623,7 @@ export function mountBookshelf(root, options = {}) {
2415
2623
  const aGutterClose = flyer._gutter
2416
2624
  ? flyer._gutter.animate(
2417
2625
  [
2418
- { offset: 0, opacity: 1 },
2419
- { offset: 0.24, opacity: 0 },
2626
+ { offset: 0, opacity: 0 },
2420
2627
  { offset: 1.0, opacity: 0 },
2421
2628
  ],
2422
2629
  closeOpts
@@ -2450,15 +2657,19 @@ export function mountBookshelf(root, options = {}) {
2450
2657
  const aFlyerFade = flyer.animate(
2451
2658
  targetMode === "spines"
2452
2659
  ? [
2453
- // Stay through the shrink. Cutting at 0.58 dropped a still-large
2454
- // cover and made the first ~1.1s look like overlap, then a pop.
2455
- { offset: 0, opacity: 1 },
2456
- { offset: 0.68, opacity: 1 },
2457
- { offset: 0.80, opacity: 0 },
2660
+ // Hidden while the note is still up, then the collapsed jacket
2661
+ // flies home. Do not hold opacity 1 at the full spread.
2662
+ { offset: 0, opacity: 0 },
2663
+ { offset: 0.06, opacity: 0 },
2664
+ { offset: 0.12, opacity: 1 },
2665
+ { offset: 0.72, opacity: 1 },
2666
+ { offset: 0.84, opacity: 0 },
2458
2667
  { offset: 1.0, opacity: 0 },
2459
2668
  ]
2460
2669
  : [
2461
- { offset: 0, opacity: 1 },
2670
+ { offset: 0, opacity: 0 },
2671
+ { offset: 0.06, opacity: 0 },
2672
+ { offset: 0.12, opacity: 1 },
2462
2673
  { offset: flyerFadeAt, opacity: 1 },
2463
2674
  { offset: 1.0, opacity: 0 },
2464
2675
  ],
@@ -2503,13 +2714,17 @@ export function mountBookshelf(root, options = {}) {
2503
2714
  ? coverImg.animate(
2504
2715
  targetMode === "spines"
2505
2716
  ? [
2506
- { offset: 0, opacity: 1 },
2507
- { offset: 0.68, opacity: 1 },
2508
- { offset: 0.80, opacity: 0 },
2717
+ { offset: 0, opacity: 0 },
2718
+ { offset: 0.06, opacity: 0 },
2719
+ { offset: 0.12, opacity: 1 },
2720
+ { offset: 0.72, opacity: 1 },
2721
+ { offset: 0.84, opacity: 0 },
2509
2722
  { offset: 1, opacity: 0 },
2510
2723
  ]
2511
2724
  : [
2512
- { offset: 0, opacity: 1 },
2725
+ { offset: 0, opacity: 0 },
2726
+ { offset: 0.06, opacity: 0 },
2727
+ { offset: 0.12, opacity: 1 },
2513
2728
  { offset: flyerFadeAt, opacity: 1 },
2514
2729
  { offset: 1, opacity: 0 },
2515
2730
  ],
@@ -2593,7 +2808,7 @@ export function mountBookshelf(root, options = {}) {
2593
2808
  slot.style.opacity = "1";
2594
2809
  slot.style.transform = "none";
2595
2810
  root.classList.remove("is-return");
2596
- noteView.classList.remove("is-open", "is-preparing");
2811
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2597
2812
  noteView.hidden = true;
2598
2813
  noteView.style.opacity = "";
2599
2814
  noteView.style.zIndex = "";
@@ -2664,6 +2879,7 @@ export function mountBookshelf(root, options = {}) {
2664
2879
  });
2665
2880
  try { shelfView.style.removeProperty("min-height"); } catch (_) {}
2666
2881
  shelfView.style.opacity = "";
2882
+ releaseHubUrl();
2667
2883
  openBook = null;
2668
2884
  openFromExpanded = false;
2669
2885
  releaseScrollLock();
@@ -2702,6 +2918,13 @@ export function mountBookshelf(root, options = {}) {
2702
2918
 
2703
2919
  listen($("#backBtn"), "click", () => closeBookFlow());
2704
2920
 
2921
+ listen(window, "popstate", () => {
2922
+ if (!openBook || !openBook.hubMode || busy || !openBook.book) return;
2923
+ if (samePath(location.pathname, openBook.book.hub)) return;
2924
+ openBook.hubEntry = "history";
2925
+ closeBookFlow();
2926
+ });
2927
+
2705
2928
  // Browser Back from the full note restores this page from bfcache, still open.
2706
2929
  // Land on the shelf at the scroll we saved, without replaying the close clock.
2707
2930
  listen(window, "pageshow", (event) => {
@@ -2717,7 +2940,7 @@ export function mountBookshelf(root, options = {}) {
2717
2940
  busy = false;
2718
2941
  flowSeq += 1;
2719
2942
  document.body.classList.remove("is-busy");
2720
- noteView.classList.remove("is-open", "is-preparing");
2943
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2721
2944
  noteView.hidden = true;
2722
2945
  noteView.style.opacity = "";
2723
2946
  noteView.style.zIndex = "";
@@ -2737,7 +2960,7 @@ export function mountBookshelf(root, options = {}) {
2737
2960
  listen(root, "click", (event) => {
2738
2961
  const target = event.target;
2739
2962
  if (!target || !target.closest) return;
2740
- const link = target.closest("a.flyer-page-more, a.note-read");
2963
+ const link = target.closest("a.flyer-page-more, a.note-read, a.book-hub-note");
2741
2964
  if (!link || !root.contains(link)) return;
2742
2965
  const y = scrollLock ? scrollLock.y : readScroll().y;
2743
2966
  try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
@@ -2755,12 +2978,73 @@ export function mountBookshelf(root, options = {}) {
2755
2978
  /* intentionally empty — enter-time density + face sizes stay put */
2756
2979
  });
2757
2980
 
2981
+ // Direct /bookshelf/<id>: hub is already the page. Shelf sits underneath so
2982
+ // 返回书架 can play the return flyer. No extract — there was no click.
2983
+ function presentHubImmediate(book) {
2984
+ const found = bookById(book.id);
2985
+ const cat = found ? found.cat : { name: "" };
2986
+ const slot = $(`.spine[data-book-id="${book.id}"]`) || $(`.cover[data-book-id="${book.id}"]`);
2987
+ if (!slot) return;
2988
+ fillHub(book);
2989
+ noteView.hidden = false;
2990
+ noteView.classList.add("is-open", "is-hub");
2991
+ noteView.classList.remove("is-preparing");
2992
+ noteView.style.opacity = "1";
2993
+ const homeRect = copyRect(rectOf(slot));
2994
+ const mid = midRect(book);
2995
+ const wp = buildSlotWaypoints(homeRect, mid, book);
2996
+ const startAsSpine = slot.classList.contains("spine");
2997
+ const flyer = makeFlyer(book, homeRect, { spineShape: false, spreadOpen: true });
2998
+ flyer.style.opacity = "0";
2999
+ if (flyer._left) flyer._left.style.transform = "rotateY(180deg)";
3000
+ if (flyer._right) flyer._right.style.opacity = "0";
3001
+ if (flyer._board) {
3002
+ flyer._board.style.opacity = "0";
3003
+ flyer._board.style.visibility = "hidden";
3004
+ }
3005
+ slot.classList.add("is-placeholder");
3006
+ slot.style.opacity = "0";
3007
+ openBook = {
3008
+ book,
3009
+ cat,
3010
+ asSpine: startAsSpine,
3011
+ fromExpanded: false,
3012
+ hubMode: true,
3013
+ hubEntry: "direct",
3014
+ openSeatRect: { ...homeRect },
3015
+ homeRect: { ...homeRect },
3016
+ homeMode: startAsSpine ? "spines" : "covers",
3017
+ pathMid: copyRect(mid),
3018
+ pathWp: wp,
3019
+ pathTs: {
3020
+ T0: wp.T0,
3021
+ Tp1: wp.Tp1,
3022
+ Tp2: wp.Tp2,
3023
+ Tp3: wp.Tp3,
3024
+ TdualClosed: wp.Tp2,
3025
+ Tgrow: wp.Tp3,
3026
+ Tnear: lerpTS(wp.Tp3, wp.Tfull, 0.5),
3027
+ Tfull: wp.Tfull,
3028
+ },
3029
+ spineHome: copyRect(homeRect),
3030
+ heldFlyer: flyer,
3031
+ };
3032
+ if (startAsSpine) openBook.spineOut = spineSlotOutDelta(homeRect, mid);
3033
+ parkShelf();
3034
+ rememberScrollHome();
3035
+ armScrollLock();
3036
+ }
3037
+
2758
3038
  // init
2759
3039
  const q = parseQuery();
2760
3040
  if (q) modeOverride = q;
2761
3041
  enableDebugChrome();
2762
3042
  applyModePreference();
2763
3043
  preloadCovers();
3044
+ if (initialHub) {
3045
+ const found = bookById(initialHub);
3046
+ if (found && isHubBook(found.book)) presentHubImmediate(found.book);
3047
+ }
2764
3048
 
2765
3049
  return () => {
2766
3050
  for (const fn of unlisten) fn();
@@ -59,16 +59,16 @@ export type BookshelfFileSource = {
59
59
  * the same path as `posts[0]`. Otherwise the shelf is rejected.
60
60
  * - one note: the shelf opens that note as it does today (extract flyer, note,
61
61
  * return flyer). There is no book hub.
62
- * - two or more notes: a spine click leaves the shelf for `/bookshelf/<id>`
63
- * (本书). It does not open `posts[0]` and it does not play the flyer.
62
+ * - two or more notes: a spine click plays the same extract flyer and lands
63
+ * on `/bookshelf/<id>` (本书). It does not open `posts[0]`.
64
64
  * The hub card is the locked book-multi layout: the jacket slot is 148px
65
65
  * wide, the image fills that width, and the height follows the file's ratio.
66
66
  * There is no plate behind the jacket. A Douban scan's white border is
67
67
  * cropped in the jacket file before it is committed; the build copies that
68
68
  * file into `/media/covers/` unchanged.
69
69
  * 「我的笔记」 lists the notes. A note card is an ordinary link to that
70
- * article; article ↔ list does not play the flyer. The flyer runs only when
71
- * a single-note book is extracted from the shelf and put back.
70
+ * article; article ↔ list does not play the flyer. Returning from the hub
71
+ * to the shelf plays the close flyer. A single note uses that same flyer.
72
72
  * - `sources` are optional `{ label, href }` chips. The hub shows 豆瓣 and PDF
73
73
  * only. The PDF chip carries the caption 「(仅分享,侵权删)」. No chapter list.
74
74
  * - `note` is Markdown used only when the book has no posts.
@@ -86,4 +86,9 @@ export type BookshelfProps = {
86
86
  * unset and keeps its own full-viewport frame.
87
87
  */
88
88
  embedded?: boolean;
89
+ /**
90
+ * Open `/bookshelf/<id>` already on the hub, with the shelf parked under it
91
+ * so the return flyer can run. A shelf click does not use this.
92
+ */
93
+ initialHub?: string;
89
94
  };
@@ -130,7 +130,7 @@ columns:
130
130
 
131
131
  `/bookshelf` 在站点写了 `content/bookshelf.yaml` 时渲染这里的书,并带上站点顶栏,和其他页面一样可以被索引。没有这个文件时,`/bookshelf` 只在引擎包内的中文验收里显示内置 demo(无顶栏、不收录);正式站要真书目,就放下这份文件。
132
132
 
133
- 架上每一条 `books` 是一本书。`post: note/ddia` 是只有一篇笔记时的简写。一本书有多篇笔记时写 `posts`,点书脊进入 `/bookshelf/<id>`(本书),不打开第一篇,也不播放抽书动画。本书页左边是书衣(宽 148,高度跟图片走),右边是书名、作者,以及豆瓣和 PDF 两枚出处胶囊;PDF 后面有一行「(仅分享,侵权删)」。下面是「我的笔记」,点进去才是文章。单篇笔记仍走原来的抽书和合上。
133
+ 架上每一条 `books` 是一本书。`post: note/ddia` 是只有一篇笔记时的简写。一本书有多篇笔记时写 `posts`,点书脊播放抽书动画,落到 `/bookshelf/<id>`(本书),不打开第一篇。从本书页回到书架播放合上动画。本书页左边是书衣(宽 148,高度跟图片走),右边是书名、作者,以及豆瓣和 PDF 两枚出处胶囊;PDF 后面有一行「(仅分享,侵权删)」。下面是「我的笔记」,点进去才是文章,这一步没有飞页。单篇笔记仍走原来的抽书和合上。
134
134
 
135
135
  `cover` 是书衣,只给书架、抽书飞页和本书页用。文件放在 `content/covers/`,构建时会复制到 `/media/covers/`,即使没有任何博文的 front matter 引用这个文件名。豆瓣封面四周的白边在放进 `content/covers/` 之前裁好,构建不会再改像素。笔记页的横幅仍是各篇 front matter 的 `cover`,不要把那张图填进书衣。yaml 不写 `cover` 时,书衣才沿用第一篇的封面。`color`(`#rgb` 或 `#rrggbb`)画书脊和单篇打开后的纸页。
136
136
 
@@ -130,7 +130,7 @@ columns:
130
130
 
131
131
  When a site has `content/bookshelf.yaml`, `/bookshelf` renders those books inside the site header and is indexed with the other pages. Without that file, `/bookshelf` is only the package's built-in demo (Chinese click-test, no header, not indexed). A production site supplies the file.
132
132
 
133
- Each `books` entry is one volume. `post: note/ddia` is the one-note shorthand. Several notes use `posts`. Clicking that spine opens `/bookshelf/<id>` and does not open the first note or play the extract animation. The book page shows a 148px-wide jacket (height follows the image), the title, the author, and only the Douban and PDF chips. The PDF chip is followed by a short sharing note. My notes lists the notes; each card is a normal link to the article. A single note still uses the extract and return animation.
133
+ Each `books` entry is one volume. `post: note/ddia` is the one-note shorthand. Several notes use `posts`. Clicking that spine plays the extract animation and lands on `/bookshelf/<id>`. It does not open the first note. Returning from that page to the shelf plays the close animation. The book page shows a 148px-wide jacket (height follows the image), the title, the author, and only the Douban and PDF chips. The PDF chip is followed by a short sharing note. My notes lists the notes; each card is a normal link to the article and does not play the flyer. A single note still uses the extract and return animation.
134
134
 
135
135
  `cover` is the jacket for the shelf, the flyer, and the book page. The file lives in `content/covers/` and the build copies it to `/media/covers/`, even when no post front matter names that file. Crop a Douban scan's white margin before committing the file; the build does not change pixels. A note's hero stays that post's front matter `cover`. Omit the jacket and it falls back to the first note's cover. `color` (`#rgb` or `#rrggbb`) is the spine and the single-note open page.
136
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",