hd-garden 0.9.2 → 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,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("&", "&")
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,18 +727,56 @@ export function mountBookshelf(root, options = {}) {
677
727
  collapseToken += 1;
678
728
  }
679
729
 
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) {
733
+ const posts = book && Array.isArray(book.posts) ? book.posts : [];
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 (_) {}
762
+ }
763
+
680
764
  async function onCoverClick(id, el) {
681
765
  if (busy) return;
682
766
  const found = bookById(id);
683
767
  if (!found || found.book.filler) return;
684
- 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
+ });
685
773
  }
686
774
 
687
775
  async function onSpineClick(id, el, ev) {
688
776
  if (busy) return;
689
777
  const found = bookById(id);
690
- if (!found) return;
691
- if (found.book.filler || !found.book.cover) return;
778
+ if (!found || found.book.filler) return;
779
+ if (!found.book.cover) return;
692
780
 
693
781
  const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
694
782
 
@@ -696,7 +784,11 @@ export function mountBookshelf(root, options = {}) {
696
784
  if (isTouch || window.matchMedia("(hover: none)").matches) {
697
785
  touchExpandedId = null;
698
786
  if (expandedId) await collapseSpine(expandedId);
699
- 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
+ });
700
792
  return;
701
793
  }
702
794
 
@@ -704,10 +796,105 @@ export function mountBookshelf(root, options = {}) {
704
796
  const fromExpanded =
705
797
  (expandedId === id && el.classList.contains("is-expanded")) ||
706
798
  el.classList.contains("is-collapsing");
707
- 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
+ }
708
894
  }
709
895
 
710
896
  function fillNote(book, cat) {
897
+ showNoteChrome();
711
898
  $("#noteCat").textContent = cat.name;
712
899
  $("#noteTitle").textContent = book.title;
713
900
  $("#noteAuthor").textContent = book.author || "";
@@ -879,10 +1066,23 @@ export function mountBookshelf(root, options = {}) {
879
1066
  el.style.opacity = String(opacity);
880
1067
  el.style.zIndex = "80";
881
1068
  el.style.pointerEvents = "none";
882
- const label = document.createElement("span");
883
- label.className = "flyer-spine-slot-label";
884
- label.textContent = book.spine || book.title || "";
885
- 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
+ }
886
1086
  stage.appendChild(el);
887
1087
  return el;
888
1088
  }
@@ -1034,16 +1234,31 @@ export function mountBookshelf(root, options = {}) {
1034
1234
  ];
1035
1235
  }
1036
1236
 
1037
- // 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.
1038
1240
  function noteOutKeys() {
1039
1241
  return [
1040
1242
  { offset: 0, opacity: 1 },
1041
- { offset: 0.05, opacity: 0.35 },
1042
- { offset: 0.12, opacity: 0 },
1243
+ { offset: 0.02, opacity: 0 },
1043
1244
  { offset: 1, opacity: 0 },
1044
1245
  ];
1045
1246
  }
1046
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
+
1047
1262
  // ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
1048
1263
  // X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
1049
1264
  // Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
@@ -1354,7 +1569,7 @@ export function mountBookshelf(root, options = {}) {
1354
1569
  "is-expanded",
1355
1570
  "is-collapsing"
1356
1571
  );
1357
- clearSeatCover(el);
1572
+ if (!(book && realCover(book))) clearSeatCover(el);
1358
1573
  // Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
1359
1574
  [
1360
1575
  "width",
@@ -1378,6 +1593,10 @@ export function mountBookshelf(root, options = {}) {
1378
1593
  applyFaceSize(el, book);
1379
1594
  } else if (book && el.classList.contains("spine") && book.cover && !book.filler) {
1380
1595
  applyFaceSize(el, book);
1596
+ if (realCover(book)) {
1597
+ el.classList.add("has-jacket");
1598
+ mountSeatCover(el, book);
1599
+ }
1381
1600
  }
1382
1601
  }
1383
1602
 
@@ -1547,6 +1766,24 @@ export function mountBookshelf(root, options = {}) {
1547
1766
  }
1548
1767
  }
1549
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
+
1550
1787
  async function prepareNote(book, cat) {
1551
1788
  fillNote(book, cat);
1552
1789
  noteView.hidden = false;
@@ -1597,13 +1834,22 @@ export function mountBookshelf(root, options = {}) {
1597
1834
  restoreSlot(el);
1598
1835
  }
1599
1836
 
1600
- async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine }) {
1837
+ async function openBookFlow(book, cat, sourceEl, { fromExpanded, asSpine, hub }) {
1601
1838
  if (busy) return;
1602
1839
  busy = true;
1603
1840
  const myFlow = ++flowSeq;
1604
1841
  document.body.classList.add("is-busy");
1605
1842
  root.classList.remove("is-return");
1606
- 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
+ };
1607
1853
  openFromExpanded = !!fromExpanded;
1608
1854
 
1609
1855
  // ux6b: freeze window scroll to idle/expand home (face-out stuck ~197px up).
@@ -1616,7 +1862,8 @@ export function mountBookshelf(root, options = {}) {
1616
1862
  } catch (_) {}
1617
1863
 
1618
1864
  try {
1619
- await prepareNote(book, cat);
1865
+ if (hubMode) await prepareHub(book);
1866
+ else await prepareNote(book, cat);
1620
1867
  } catch (_) {
1621
1868
  // Decode/prepare failed — still open; never leave is-busy hung.
1622
1869
  }
@@ -1631,6 +1878,7 @@ export function mountBookshelf(root, options = {}) {
1631
1878
  noteView.classList.add("is-open");
1632
1879
  noteView.style.opacity = "1";
1633
1880
  parkShelf();
1881
+ rememberHubUrl();
1634
1882
  releaseScrollLock();
1635
1883
  busy = false;
1636
1884
  document.body.classList.remove("is-busy");
@@ -1976,6 +2224,7 @@ export function mountBookshelf(root, options = {}) {
1976
2224
  unlockNoteClicks();
1977
2225
  holdFlyerUnderNote();
1978
2226
  parkShelf();
2227
+ rememberHubUrl();
1979
2228
  restoreSlot(sourceEl, book);
1980
2229
  sourceEl.classList.remove("is-extracting");
1981
2230
  // Keep sourceEl as invisible reserved seat while note open (no gray plate).
@@ -2013,13 +2262,14 @@ export function mountBookshelf(root, options = {}) {
2013
2262
  const targetMode = pageMode;
2014
2263
 
2015
2264
  if (reduceMotion()) {
2016
- noteView.classList.remove("is-open", "is-preparing");
2265
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2017
2266
  noteView.hidden = true;
2018
2267
  noteView.style.opacity = "";
2019
2268
  expandedId = null;
2020
2269
  openFromExpanded = false;
2021
2270
  unparkShelf();
2022
2271
  renderShelf();
2272
+ releaseHubUrl();
2023
2273
  clearGhost(
2024
2274
  targetMode === "covers"
2025
2275
  ? $(`.cover[data-book-id="${book.id}"]`)
@@ -2056,47 +2306,36 @@ export function mountBookshelf(root, options = {}) {
2056
2306
  stashEarly.Tp2 &&
2057
2307
  stashEarly.Tp3
2058
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.
2059
2311
  heldEarly.style.visibility = "visible";
2060
2312
  heldEarly.style.zIndex = "70";
2061
- heldEarly.style.opacity = "1";
2313
+ heldEarly.style.opacity = "0";
2062
2314
  if (heldEarly._board) {
2063
2315
  heldEarly._board.style.opacity = "0";
2064
2316
  heldEarly._board.style.visibility = "hidden";
2065
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";
2066
2323
  promote(heldEarly);
2067
2324
  earlyMove = heldEarly.animate(
2068
2325
  returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
2069
2326
  closeMotion
2070
2327
  );
2071
2328
  earlyNote = noteView.animate(noteOutKeys(), closeOpts);
2072
- // Backup if the fade is dropped: the article must not still be up after ~230ms.
2073
- 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(() => {
2074
2331
  if (myFlow !== flowSeq || !noteView) return;
2075
2332
  noteView.style.opacity = "0";
2076
2333
  });
2077
2334
  if (heldEarly._left) {
2078
- earlyLeft = heldEarly._left.animate(
2079
- [
2080
- { offset: 0, transform: "rotateY(8deg)" },
2081
- { offset: 0.08, transform: "rotateY(20deg)" },
2082
- { offset: 0.16, transform: "rotateY(70deg)" },
2083
- { offset: 0.24, transform: "rotateY(140deg)" },
2084
- { offset: 0.32, transform: "rotateY(180deg)" },
2085
- { offset: 1.0, transform: "rotateY(180deg)" },
2086
- ],
2087
- closeMotion
2088
- );
2335
+ earlyLeft = heldEarly._left.animate(closedReturnLeftKeys(), closeMotion);
2089
2336
  }
2090
2337
  if (heldEarly._right) {
2091
- earlyRight = heldEarly._right.animate(
2092
- [
2093
- { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
2094
- { offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
2095
- { offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
2096
- { offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
2097
- ],
2098
- closeMotion
2099
- );
2338
+ earlyRight = heldEarly._right.animate(closedReturnRightKeys(), closeMotion);
2100
2339
  }
2101
2340
  }
2102
2341
 
@@ -2128,9 +2367,10 @@ export function mountBookshelf(root, options = {}) {
2128
2367
  try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
2129
2368
  try { earlyRight && earlyRight.cancel(); } catch (_) {}
2130
2369
  root.classList.remove("is-return");
2131
- noteView.classList.remove("is-open", "is-preparing");
2370
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2132
2371
  noteView.hidden = true;
2133
2372
  noteView.style.opacity = "";
2373
+ releaseHubUrl();
2134
2374
  openBook = null;
2135
2375
  busy = false;
2136
2376
  document.body.classList.remove("is-busy");
@@ -2284,19 +2524,20 @@ export function mountBookshelf(root, options = {}) {
2284
2524
  }
2285
2525
  }
2286
2526
  }
2287
- // Ensure open-spread leaf pose at Tfull so reverse hinge is continuous (under note).
2288
- 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)";
2289
2530
  if (flyer._right) {
2290
- flyer._right.style.transform = "rotateY(-8deg)";
2291
- flyer._right.style.opacity = "1";
2531
+ flyer._right.style.transform = "rotateY(0deg)";
2532
+ flyer._right.style.opacity = "0";
2292
2533
  }
2293
2534
  if (flyer._board) {
2294
2535
  flyer._board.style.opacity = "0";
2295
2536
  flyer._board.style.visibility = "hidden";
2296
2537
  }
2297
- if (flyer._leftThick) flyer._leftThick.style.opacity = "1";
2298
- if (flyer._rightThick) flyer._rightThick.style.opacity = "1";
2299
- 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";
2300
2541
  const closeSpineLabel = flyer._cover && flyer._cover.querySelector(".flyer-spine-label");
2301
2542
  if (closeSpineLabel) closeSpineLabel.style.opacity = "0";
2302
2543
  // ux23: spines may swap to img-only; covers keep applyCoverImage bg under img
@@ -2344,7 +2585,7 @@ export function mountBookshelf(root, options = {}) {
2344
2585
  // Start the reverse on this frame. A pre-clock rAF left the spread parked at
2345
2586
  // 672×504 while the note was still up (the ~240ms close-start hitch).
2346
2587
  void readyPulse;
2347
- if (!earlyMove) flyer.style.opacity = "1";
2588
+ if (!earlyMove) flyer.style.opacity = "0";
2348
2589
 
2349
2590
  // ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
2350
2591
  const PUT_MS = 380;
@@ -2367,33 +2608,13 @@ export function mountBookshelf(root, options = {}) {
2367
2608
  );
2368
2609
  // Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
2369
2610
  const aLeftClose = earlyLeft || (flyer._left
2370
- ? flyer._left.animate(
2371
- [
2372
- { offset: 0, transform: "rotateY(8deg)" },
2373
- { offset: 0.08, transform: "rotateY(20deg)" },
2374
- { offset: 0.16, transform: "rotateY(70deg)" },
2375
- { offset: 0.24, transform: "rotateY(140deg)" },
2376
- { offset: 0.32, transform: "rotateY(180deg)" },
2377
- { offset: 1.0, transform: "rotateY(180deg)" },
2378
- ],
2379
- closeMotion
2380
- )
2611
+ ? flyer._left.animate(closedReturnLeftKeys(), closeMotion)
2381
2612
  : null);
2382
2613
  const aRightClose = earlyRight || (flyer._right
2383
- ? flyer._right.animate(
2384
- [
2385
- { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
2386
- { offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
2387
- { offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
2388
- { offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
2389
- ],
2390
- closeMotion
2391
- )
2614
+ ? flyer._right.animate(closedReturnRightKeys(), closeMotion)
2392
2615
  : null);
2393
2616
  const closedFaceOut = [
2394
- { offset: 0, opacity: 1 },
2395
- { offset: 0.18, opacity: 0.7 },
2396
- { offset: 0.28, opacity: 0 },
2617
+ { offset: 0, opacity: 0 },
2397
2618
  { offset: 1.0, opacity: 0 },
2398
2619
  ];
2399
2620
  const aBoardClose = null;
@@ -2402,8 +2623,7 @@ export function mountBookshelf(root, options = {}) {
2402
2623
  const aGutterClose = flyer._gutter
2403
2624
  ? flyer._gutter.animate(
2404
2625
  [
2405
- { offset: 0, opacity: 1 },
2406
- { offset: 0.24, opacity: 0 },
2626
+ { offset: 0, opacity: 0 },
2407
2627
  { offset: 1.0, opacity: 0 },
2408
2628
  ],
2409
2629
  closeOpts
@@ -2437,15 +2657,19 @@ export function mountBookshelf(root, options = {}) {
2437
2657
  const aFlyerFade = flyer.animate(
2438
2658
  targetMode === "spines"
2439
2659
  ? [
2440
- // Stay through the shrink. Cutting at 0.58 dropped a still-large
2441
- // cover and made the first ~1.1s look like overlap, then a pop.
2442
- { offset: 0, opacity: 1 },
2443
- { offset: 0.68, opacity: 1 },
2444
- { 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 },
2445
2667
  { offset: 1.0, opacity: 0 },
2446
2668
  ]
2447
2669
  : [
2448
- { offset: 0, opacity: 1 },
2670
+ { offset: 0, opacity: 0 },
2671
+ { offset: 0.06, opacity: 0 },
2672
+ { offset: 0.12, opacity: 1 },
2449
2673
  { offset: flyerFadeAt, opacity: 1 },
2450
2674
  { offset: 1.0, opacity: 0 },
2451
2675
  ],
@@ -2490,13 +2714,17 @@ export function mountBookshelf(root, options = {}) {
2490
2714
  ? coverImg.animate(
2491
2715
  targetMode === "spines"
2492
2716
  ? [
2493
- { offset: 0, opacity: 1 },
2494
- { offset: 0.68, opacity: 1 },
2495
- { 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 },
2496
2722
  { offset: 1, opacity: 0 },
2497
2723
  ]
2498
2724
  : [
2499
- { offset: 0, opacity: 1 },
2725
+ { offset: 0, opacity: 0 },
2726
+ { offset: 0.06, opacity: 0 },
2727
+ { offset: 0.12, opacity: 1 },
2500
2728
  { offset: flyerFadeAt, opacity: 1 },
2501
2729
  { offset: 1, opacity: 0 },
2502
2730
  ],
@@ -2580,7 +2808,7 @@ export function mountBookshelf(root, options = {}) {
2580
2808
  slot.style.opacity = "1";
2581
2809
  slot.style.transform = "none";
2582
2810
  root.classList.remove("is-return");
2583
- noteView.classList.remove("is-open", "is-preparing");
2811
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2584
2812
  noteView.hidden = true;
2585
2813
  noteView.style.opacity = "";
2586
2814
  noteView.style.zIndex = "";
@@ -2651,6 +2879,7 @@ export function mountBookshelf(root, options = {}) {
2651
2879
  });
2652
2880
  try { shelfView.style.removeProperty("min-height"); } catch (_) {}
2653
2881
  shelfView.style.opacity = "";
2882
+ releaseHubUrl();
2654
2883
  openBook = null;
2655
2884
  openFromExpanded = false;
2656
2885
  releaseScrollLock();
@@ -2689,6 +2918,13 @@ export function mountBookshelf(root, options = {}) {
2689
2918
 
2690
2919
  listen($("#backBtn"), "click", () => closeBookFlow());
2691
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
+
2692
2928
  // Browser Back from the full note restores this page from bfcache, still open.
2693
2929
  // Land on the shelf at the scroll we saved, without replaying the close clock.
2694
2930
  listen(window, "pageshow", (event) => {
@@ -2704,7 +2940,7 @@ export function mountBookshelf(root, options = {}) {
2704
2940
  busy = false;
2705
2941
  flowSeq += 1;
2706
2942
  document.body.classList.remove("is-busy");
2707
- noteView.classList.remove("is-open", "is-preparing");
2943
+ noteView.classList.remove("is-open", "is-preparing", "is-hub");
2708
2944
  noteView.hidden = true;
2709
2945
  noteView.style.opacity = "";
2710
2946
  noteView.style.zIndex = "";
@@ -2724,7 +2960,7 @@ export function mountBookshelf(root, options = {}) {
2724
2960
  listen(root, "click", (event) => {
2725
2961
  const target = event.target;
2726
2962
  if (!target || !target.closest) return;
2727
- 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");
2728
2964
  if (!link || !root.contains(link)) return;
2729
2965
  const y = scrollLock ? scrollLock.y : readScroll().y;
2730
2966
  try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
@@ -2742,12 +2978,73 @@ export function mountBookshelf(root, options = {}) {
2742
2978
  /* intentionally empty — enter-time density + face sizes stay put */
2743
2979
  });
2744
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
+
2745
3038
  // init
2746
3039
  const q = parseQuery();
2747
3040
  if (q) modeOverride = q;
2748
3041
  enableDebugChrome();
2749
3042
  applyModePreference();
2750
3043
  preloadCovers();
3044
+ if (initialHub) {
3045
+ const found = bookById(initialHub);
3046
+ if (found && isHubBook(found.book)) presentHubImmediate(found.book);
3047
+ }
2751
3048
 
2752
3049
  return () => {
2753
3050
  for (const fn of unlisten) fn();