hd-garden 0.9.0 → 0.9.1

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.
@@ -38,8 +38,11 @@ function injectExtraCovers(catalog) {
38
38
  }
39
39
 
40
40
  export function mountBookshelf(root, options = {}) {
41
+ // A passed catalog is a site shelf. Demo notes and demo copy stay on the
42
+ // package preview, which mounts this with no catalog.
41
43
  const CATALOG = options.catalog ?? DEMO_CATALOG;
42
- const NOTES = options.notes ?? DEMO_NOTES;
44
+ const NOTES = options.notes ?? (options.catalog ? {} : DEMO_NOTES);
45
+ const usingDemo = !options.catalog;
43
46
  const removeExtraCovers = injectExtraCovers(CATALOG);
44
47
 
45
48
  // ux26: per-book cover dominant (= existing bk-* spine colors) for open-shell + page wash
@@ -100,8 +103,11 @@ export function mountBookshelf(root, options = {}) {
100
103
 
101
104
  function bookShellHex(book) {
102
105
  if (!book) return "#5a6a88";
103
- if (book.filler === "sand") return COVER_SHELL.sand;
106
+ if (book.filler === "sand" || book.filler === true) return COVER_SHELL.sand;
104
107
  if (book.filler === "cream") return COVER_SHELL.cream;
108
+ // Site books carry the sampled cover color. Demo jackets keep COVER_SHELL.
109
+ const color = typeof book.color === "string" ? book.color.trim() : "";
110
+ if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) return color;
105
111
  return COVER_SHELL[book.id] || "#5a6a88";
106
112
  }
107
113
 
@@ -171,6 +177,9 @@ export function mountBookshelf(root, options = {}) {
171
177
  function noteSummaryText(book) {
172
178
  const key = book && book.note;
173
179
  const note = key ? NOTES[key] : null;
180
+ if (note && typeof note.summary === "string" && note.summary.trim()) {
181
+ return note.summary.trim().replace(/\s+/g, " ");
182
+ }
174
183
  if (note && note.body) {
175
184
  let text = plainFromHtml(note.body);
176
185
  // Prefer first real sentence-ish chunk after stripping headings noise
@@ -179,6 +188,7 @@ export function mountBookshelf(root, options = {}) {
179
188
  if (text.length >= 24) return text;
180
189
  }
181
190
  const who = book && book.author ? ` · ${book.author}` : "";
191
+ if (!usingDemo) return (book && (book.author || book.title)) || "";
182
192
  return `《${book.title}》的读书笔记摘要${who}。正式站会换成完整笔记;Demo 先占这一页。`;
183
193
  }
184
194
 
@@ -190,7 +200,11 @@ export function mountBookshelf(root, options = {}) {
190
200
 
191
201
  function rightPageHtml(book) {
192
202
  const summary = escapeHtml(noteSummaryText(book));
193
- return `<div class="flyer-page-kicker">读书笔记</div><p class="flyer-page-summary">${summary}</p>`;
203
+ const href = book && typeof book.href === "string" ? book.href : "";
204
+ const more = href
205
+ ? `<a class="flyer-page-more" href="${escapeHtml(href)}">阅读全文</a>`
206
+ : "";
207
+ return `<div class="flyer-page-kicker">读书笔记</div><p class="flyer-page-summary">${summary}</p>${more}`;
194
208
  }
195
209
 
196
210
 
@@ -323,12 +337,13 @@ export function mountBookshelf(root, options = {}) {
323
337
  return null;
324
338
  }
325
339
 
326
- // ux21: mobile/touch-primary = (hover:none) AND (pointer:coarse).
327
- // Prefer this over max-width so a narrow desktop window still uses density pin.
340
+ // ux21 lock: mobile resting = all covers (全封面).
341
+ // Match CSS mobile shelf: (hover:none) OR max-width 640.
342
+ // DevTools「手机 393」often keeps hover:hover — width gate is required or autoDecide→spines.
328
343
  function isMobilePrimary() {
329
344
  return (
330
- window.matchMedia("(hover: none)").matches &&
331
- window.matchMedia("(pointer: coarse)").matches
345
+ window.matchMedia("(hover: none)").matches ||
346
+ window.matchMedia("(max-width: 640px)").matches
332
347
  );
333
348
  }
334
349
 
@@ -673,10 +688,22 @@ export function mountBookshelf(root, options = {}) {
673
688
  const coverEl = $("#noteCover");
674
689
  coverEl.className = `note-cover ${bkClass(book)} ${coverClass(book)}`;
675
690
  const note = NOTES[book.note];
691
+ const read = $("#noteRead");
692
+ if (read) {
693
+ if (book.href) {
694
+ read.href = book.href;
695
+ read.hidden = false;
696
+ } else {
697
+ read.hidden = true;
698
+ read.removeAttribute("href");
699
+ }
700
+ }
676
701
  if (note) {
677
702
  $("#noteBody").innerHTML = note.body;
678
- } else {
703
+ } else if (usingDemo) {
679
704
  $("#noteBody").innerHTML = `<p class="placeholder">这本还没有完整笔记正文。Demo 占位:返回书架继续抽翻验收。</p><p>${book.title}${book.author ? " · " + book.author : ""}</p>`;
705
+ } else {
706
+ $("#noteBody").innerHTML = `<p class="placeholder">这本还没有笔记正文。</p><p>${escapeHtml(book.title)}${book.author ? " · " + escapeHtml(book.author) : ""}</p>`;
680
707
  }
681
708
  }
682
709
 
@@ -727,15 +754,23 @@ export function mountBookshelf(root, options = {}) {
727
754
  return `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})${extra ? " " + extra : ""}`;
728
755
  }
729
756
 
730
- // Closed book: cover on RIGHT half. Uniform scale only (non-uniform sx/sy = stretch jitter).
757
+ // Closed book: cover on RIGHT half.
758
+ // Covers stay uniform (sx ≈ sy). A narrow spine must NOT share that one scale:
759
+ // min(spineW / halfSpread, spineH / spreadH) ≈ 0.09 crushes height to ~45px.
760
+ // Spine slot: scale X and Y apart so the flyer box height stays the resting spine.
731
761
  function closedCoverTransform(slotRect, mid) {
732
762
  const halfW = mid.width / 2;
733
763
  const sx = slotRect.width / halfW;
734
764
  const sy = slotRect.height / mid.height;
765
+ const narrow = slotRect.width / Math.max(1, slotRect.height) < 0.42;
766
+ if (narrow) {
767
+ const tx = slotRect.left - mid.left - halfW * sx;
768
+ const ty = slotRect.top - mid.top;
769
+ return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
770
+ }
735
771
  const s = Math.min(sx, sy);
736
772
  const scaledHalfW = halfW * s;
737
773
  const scaledH = mid.height * s;
738
- // Center scaled right-half over slot
739
774
  const tx = slotRect.left - mid.left - halfW * s + (slotRect.width - scaledHalfW) / 2;
740
775
  const ty = slotRect.top - mid.top + (slotRect.height - scaledH) / 2;
741
776
  return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${s.toFixed(4)})`;
@@ -879,19 +914,27 @@ export function mountBookshelf(root, options = {}) {
879
914
  // Parse translate/scale from our path strings; lerp components so grow ≠ hard jump (PATH-SHAKE ~2.2s).
880
915
  function parseTS(transform) {
881
916
  const s = String(transform || "");
882
- let tx = 0, ty = 0, sc = 1;
917
+ let tx = 0, ty = 0, sx = 1, sy = 1;
883
918
  let m = s.match(/translate3d\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
884
919
  if (m) { tx = +m[1]; ty = +m[2]; }
885
920
  else {
886
921
  m = s.match(/translate\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
887
922
  if (m) { tx = +m[1]; ty = +m[2]; }
888
923
  }
889
- m = s.match(/scale\(\s*([\d.-]+)\s*(?:,\s*[\d.-]+)?\s*\)/);
890
- if (m) sc = +m[1];
891
- return { tx, ty, s: sc };
924
+ m = s.match(/scale\(\s*([\d.-]+)(?:\s*,\s*([\d.-]+))?\s*\)/);
925
+ if (m) {
926
+ sx = +m[1];
927
+ sy = m[2] != null ? +m[2] : sx;
928
+ }
929
+ return { tx, ty, sx, sy };
892
930
  }
893
931
  function formatTS(p) {
894
- return `translate(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px) scale(${p.s.toFixed(4)})`;
932
+ const sx = p.sx != null ? p.sx : p.s;
933
+ const sy = p.sy != null ? p.sy : sx;
934
+ const scale = Math.abs(sx - sy) < 1e-4
935
+ ? `scale(${sx.toFixed(4)})`
936
+ : `scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
937
+ return `translate(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px) ${scale}`;
895
938
  }
896
939
  function lerpTS(a, b, t) {
897
940
  const u = Math.max(0, Math.min(1, t));
@@ -900,10 +943,32 @@ export function mountBookshelf(root, options = {}) {
900
943
  return formatTS({
901
944
  tx: pa.tx + (pb.tx - pa.tx) * u,
902
945
  ty: pa.ty + (pb.ty - pa.ty) * u,
903
- s: pa.s + (pb.s - pa.s) * u,
946
+ sx: pa.sx + (pb.sx - pa.sx) * u,
947
+ sy: pa.sy + (pb.sy - pa.sy) * u,
904
948
  });
905
949
  }
906
950
 
951
+ // Pose for the flyer move. Same tx/ty/sx/sy as the ux26 string, but on the
952
+ // individual translate and scale properties. A combined transform matrix
953
+ // decomposes badly when sx ≠ sy (slot height pin) and the flight stutters.
954
+ function pathPose(transform) {
955
+ const p = parseTS(transform);
956
+ return {
957
+ translate: `${p.tx.toFixed(2)}px ${p.ty.toFixed(2)}px`,
958
+ scale: `${p.sx.toFixed(4)} ${p.sy.toFixed(4)}`,
959
+ };
960
+ }
961
+ function applyPathPose(el, transform) {
962
+ if (!el) return;
963
+ const pose = pathPose(transform);
964
+ el.style.transform = "none";
965
+ el.style.translate = pose.translate;
966
+ el.style.scale = pose.scale;
967
+ }
968
+ function pathKey(offset, transform) {
969
+ return { offset, ...pathPose(transform) };
970
+ }
971
+
907
972
  // ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
908
973
  // X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
909
974
  // Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
@@ -1328,7 +1393,7 @@ export function mountBookshelf(root, options = {}) {
1328
1393
  right.style.transform = "rotateY(-8deg)";
1329
1394
  bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
1330
1395
  gutter.style.opacity = "1";
1331
- flyer.style.transform = "translate3d(0px, -4px, 0) scale(1)";
1396
+ applyPathPose(flyer, "translate(0.00px, -4.00px) scale(1)");
1332
1397
  flyer.style.opacity = "1";
1333
1398
  } else {
1334
1399
  // ux10: closed extract must look like ONE cover/spine — hide board + right page
@@ -1341,7 +1406,7 @@ export function mountBookshelf(root, options = {}) {
1341
1406
  rightThick.style.opacity = "0";
1342
1407
  bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
1343
1408
  gutter.style.opacity = "0";
1344
- flyer.style.transform = closedCoverTransform(atRect, mid);
1409
+ applyPathPose(flyer, closedCoverTransform(atRect, mid));
1345
1410
  flyer.style.opacity = "1";
1346
1411
  }
1347
1412
  stage.appendChild(flyer);
@@ -1351,7 +1416,7 @@ export function mountBookshelf(root, options = {}) {
1351
1416
  function promote(el) {
1352
1417
  if (!el) return;
1353
1418
  el.classList.add("is-ready");
1354
- el.style.willChange = "transform, opacity";
1419
+ el.style.willChange = "translate, scale, opacity";
1355
1420
  }
1356
1421
 
1357
1422
  function demote(el) {
@@ -1505,18 +1570,21 @@ export function mountBookshelf(root, options = {}) {
1505
1570
  sourceEl.classList.add("is-extracting"); // CSS: suppress :hover transform while extracting
1506
1571
  if (fromExpanded) {
1507
1572
  sourceEl.classList.remove("is-expanded", "is-collapsing");
1508
- [
1509
- "width",
1510
- "height",
1511
- "min-width",
1512
- "min-height",
1513
- "flex-shrink",
1514
- ].forEach((p) => {
1573
+ // Drop the expanded cover's width. Do NOT drop a resting spine's height:
1574
+ // clearing it falls through to .s-mid/.s-short (152/136), shorter than the
1575
+ // face height the shelf is already showing, and that short box becomes
1576
+ // homeRect for extract and put-back.
1577
+ ["width", "min-width", "min-height", "flex-shrink"].forEach((p) => {
1515
1578
  try { sourceEl.style.removeProperty(p); } catch (_) {}
1516
1579
  });
1580
+ if (sourceEl.classList.contains("spine") && book && book.cover && !book.filler) {
1581
+ applyFaceSize(sourceEl, book);
1582
+ } else {
1583
+ try { sourceEl.style.removeProperty("height"); } catch (_) {}
1584
+ }
1517
1585
  expandedId = null;
1518
1586
  touchExpandedId = null;
1519
- void sourceEl.offsetWidth; // reflow to natural spine (transform already none)
1587
+ void sourceEl.offsetWidth; // reflow to resting spine (transform already none)
1520
1588
  } else {
1521
1589
  // Even spine-direct: pointer may still be hovering → :hover lift would skew freeze.
1522
1590
  void sourceEl.offsetWidth;
@@ -1636,13 +1704,13 @@ export function mountBookshelf(root, options = {}) {
1636
1704
  // Same dual: single diagonal to full — no hold-then-slide second segment.
1637
1705
  const aDualMove = dual.animate(
1638
1706
  [
1639
- { offset: 0, transform: T0 },
1640
- { offset: 0.12, transform: Tp1 },
1641
- { offset: 0.28, transform: Tp2 },
1642
- { offset: 0.44, transform: Tp3 },
1643
- { offset: 0.58, transform: Tfull },
1644
- { offset: 0.78, transform: Tfull },
1645
- { offset: 1.0, transform: Tfull },
1707
+ pathKey(0, T0),
1708
+ pathKey(0.12, Tp1),
1709
+ pathKey(0.28, Tp2),
1710
+ pathKey(0.44, Tp3),
1711
+ pathKey(0.58, Tfull),
1712
+ pathKey(0.78, Tfull),
1713
+ pathKey(1, Tfull),
1646
1714
  ],
1647
1715
  optsMotion
1648
1716
  );
@@ -1802,6 +1870,9 @@ export function mountBookshelf(root, options = {}) {
1802
1870
  try { a.cancel(); } catch (_) {}
1803
1871
  });
1804
1872
  } catch (_) {}
1873
+ // commitStyles does not keep the individual scale/translate pose. Park at
1874
+ // the open spread so close shrinks from 672×504 instead of jumping there.
1875
+ applyPathPose(dual, Tfull);
1805
1876
  dual.style.opacity = "0";
1806
1877
  dual.style.pointerEvents = "none";
1807
1878
  dual.style.visibility = "visible";
@@ -1887,6 +1958,79 @@ export function mountBookshelf(root, options = {}) {
1887
1958
  return;
1888
1959
  }
1889
1960
 
1961
+ // Clock first. Shelf measure, freeze, and rAF used to run before this and
1962
+ // the 0.12 Tfull keyframe then held 672×504 for ~240ms. Stash from open is
1963
+ // enough to start the return; height pin is already in those samples.
1964
+ const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
1965
+ const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
1966
+ const stashEarly = openBook.pathTs;
1967
+ let earlyMove = null;
1968
+ let earlyNote = null;
1969
+ let earlyLeft = null;
1970
+ let earlyRight = null;
1971
+ const heldEarly = openBook.heldFlyer;
1972
+ if (
1973
+ heldEarly &&
1974
+ heldEarly.isConnected &&
1975
+ stashEarly &&
1976
+ stashEarly.Tfull &&
1977
+ stashEarly.T0 &&
1978
+ stashEarly.Tp1 &&
1979
+ stashEarly.Tp2 &&
1980
+ stashEarly.Tp3
1981
+ ) {
1982
+ heldEarly.style.visibility = "visible";
1983
+ heldEarly.style.zIndex = "70";
1984
+ heldEarly.style.opacity = "1";
1985
+ promote(heldEarly);
1986
+ earlyMove = heldEarly.animate(
1987
+ [
1988
+ pathKey(0, stashEarly.Tfull),
1989
+ pathKey(0.28, stashEarly.Tp3),
1990
+ pathKey(0.44, stashEarly.Tp2),
1991
+ pathKey(0.60, stashEarly.Tp1),
1992
+ pathKey(0.78, stashEarly.T0),
1993
+ pathKey(0.90, stashEarly.T0),
1994
+ pathKey(1, stashEarly.T0),
1995
+ ],
1996
+ closeMotion
1997
+ );
1998
+ earlyNote = noteView.animate(
1999
+ [
2000
+ { offset: 0, opacity: 1 },
2001
+ { offset: 0.06, opacity: 0.85 },
2002
+ { offset: 0.12, opacity: 0.45 },
2003
+ { offset: 0.22, opacity: 0 },
2004
+ { offset: 1.0, opacity: 0 },
2005
+ ],
2006
+ closeOpts
2007
+ );
2008
+ if (heldEarly._left) {
2009
+ earlyLeft = heldEarly._left.animate(
2010
+ [
2011
+ { offset: 0, transform: "rotateY(8deg)" },
2012
+ { offset: 0.08, transform: "rotateY(20deg)" },
2013
+ { offset: 0.16, transform: "rotateY(70deg)" },
2014
+ { offset: 0.24, transform: "rotateY(140deg)" },
2015
+ { offset: 0.32, transform: "rotateY(180deg)" },
2016
+ { offset: 1.0, transform: "rotateY(180deg)" },
2017
+ ],
2018
+ closeMotion
2019
+ );
2020
+ }
2021
+ if (heldEarly._right) {
2022
+ earlyRight = heldEarly._right.animate(
2023
+ [
2024
+ { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
2025
+ { offset: 0.20, opacity: 1, transform: "rotateY(-4deg)" },
2026
+ { offset: 0.32, opacity: 0, transform: "rotateY(0deg)" },
2027
+ { offset: 1.0, opacity: 0, transform: "rotateY(0deg)" },
2028
+ ],
2029
+ closeMotion
2030
+ );
2031
+ }
2032
+ }
2033
+
1890
2034
  const mid = midRect(book);
1891
2035
  const originX = mid.left + mid.width / 2;
1892
2036
  const originY = mid.top + mid.height / 2;
@@ -1910,8 +2054,13 @@ export function mountBookshelf(root, options = {}) {
1910
2054
  slot = $(slotSel);
1911
2055
  }
1912
2056
  if (!slot) {
2057
+ try { earlyMove && earlyMove.cancel(); } catch (_) {}
2058
+ try { earlyNote && earlyNote.cancel(); } catch (_) {}
2059
+ try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
2060
+ try { earlyRight && earlyRight.cancel(); } catch (_) {}
1913
2061
  noteView.classList.remove("is-open", "is-preparing");
1914
2062
  noteView.hidden = true;
2063
+ noteView.style.opacity = "";
1915
2064
  openBook = null;
1916
2065
  busy = false;
1917
2066
  document.body.classList.remove("is-busy");
@@ -1934,7 +2083,12 @@ export function mountBookshelf(root, options = {}) {
1934
2083
  });
1935
2084
  slot.classList.remove("is-collapsing", "is-placeholder", "is-expanded");
1936
2085
  expandedId = null;
1937
- void slot.offsetWidth; // reflow to natural spine after class/inline clear
2086
+ // Spines: re-pin the resting face BEFORE reading the seat. A cleared inline
2087
+ // height is the shorter size-class box, and put-back would keep it.
2088
+ if (targetMode === "spines" && book && book.cover && !book.filler) {
2089
+ applyFaceSize(slot, book);
2090
+ }
2091
+ void slot.offsetWidth; // reflow to resting spine after class/inline clear
1938
2092
  const naturalW = slot.offsetWidth;
1939
2093
  const naturalH = slot.offsetHeight;
1940
2094
  slot.style.flexShrink = "0";
@@ -1990,6 +2144,17 @@ export function mountBookshelf(root, options = {}) {
1990
2144
  frozenHome.width = natW;
1991
2145
  frozenHome.height = natH;
1992
2146
  frozenHome.top = Math.round(bottom - natH);
2147
+ } else if (targetMode === "spines" && naturalH > 0) {
2148
+ // Seat and flyer use the resting spine just measured, not a short
2149
+ // cover-frame stash from extract.
2150
+ const bottom = frozenHome.top + frozenHome.height;
2151
+ frozenHome.width = naturalW;
2152
+ frozenHome.height = naturalH;
2153
+ frozenHome.top = Math.round(bottom - naturalH);
2154
+ if (openBook) {
2155
+ openBook.homeRect = copyRect(frozenHome);
2156
+ openBook.spineHome = copyRect(frozenHome);
2157
+ }
1993
2158
  }
1994
2159
  slot.style.width = frozenHome.width + "px";
1995
2160
  slot.style.minWidth = frozenHome.width + "px";
@@ -2027,8 +2192,10 @@ export function mountBookshelf(root, options = {}) {
2027
2192
  }
2028
2193
  flyer.style.zIndex = "70";
2029
2194
  flyer.style.visibility = "visible";
2030
- flyer.style.opacity = "0"; // stay hidden under note until reverse clock
2031
- flyer.style.transform = Tfull; // start at open end pose (transform-aligned)
2195
+ if (!earlyMove) {
2196
+ flyer.style.opacity = "0";
2197
+ applyPathPose(flyer, Tfull); // start at open end pose (transform-aligned)
2198
+ }
2032
2199
  promote(flyer);
2033
2200
  if (flyer._cover) {
2034
2201
  flyer._cover.classList.add("is-cover-ready");
@@ -2098,13 +2265,10 @@ export function mountBookshelf(root, options = {}) {
2098
2265
  `translate3d(${spineOutClose.x.toFixed(2)}px, ${spineOutClose.y.toFixed(2)}px, 0)`;
2099
2266
  }
2100
2267
 
2101
- // One frame then start reverse clock — note still fully covering.
2102
- await Promise.race([
2103
- new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))),
2104
- wait(32),
2105
- ]);
2268
+ // Start the reverse on this frame. A pre-clock rAF left the spread parked at
2269
+ // 672×504 while the note was still up (the ~240ms close-start hitch).
2106
2270
  void readyPulse;
2107
- flyer.style.opacity = "1"; // book layer present under note as reverse begins
2271
+ if (!earlyMove) flyer.style.opacity = "1";
2108
2272
 
2109
2273
  // ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
2110
2274
  const PUT_MS = 380;
@@ -2113,13 +2277,11 @@ export function mountBookshelf(root, options = {}) {
2113
2277
  const xfadeFrom = 1 - XFADE_MS / CLOSE_MS; // ~0.89
2114
2278
  const slotInAt = Math.max(0.86, xfadeFrom - 0.04);
2115
2279
  const flyerFadeAt = Math.min(0.97, slotInAt + 0.06);
2116
- const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
2117
- const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
2118
2280
 
2119
2281
  resetVeil();
2120
2282
 
2121
2283
  // ux13: note fades ON the book layer while reverse shrink starts — ban instant note unmount.
2122
- const aNoteOut = noteView.animate(
2284
+ const aNoteOut = earlyNote || noteView.animate(
2123
2285
  [
2124
2286
  { offset: 0, opacity: 1 },
2125
2287
  { offset: 0.06, opacity: 0.85 },
@@ -2130,24 +2292,21 @@ export function mountBookshelf(root, options = {}) {
2130
2292
  closeOpts
2131
2293
  );
2132
2294
 
2133
- // Open extract was 0→0.32 (T0→Tp1→Tp2→TdualClosed), grow 0.40→0.78.
2134
- // Close: reverse grow then reverse extract; end = T0 (frozen home) — no approach snap.
2135
- // TIME-REVERSE of open diagonal — same Ts, no L hang-then-drop.
2136
- const aFlyerMove = flyer.animate(
2295
+ // Shrink starts at offset 0. The old 0.12 Tfull key was ~228ms parked at 672×504.
2296
+ const aFlyerMove = earlyMove || flyer.animate(
2137
2297
  [
2138
- { offset: 0, transform: Tfull },
2139
- { offset: 0.12, transform: Tfull },
2140
- { offset: 0.28, transform: Tp3 },
2141
- { offset: 0.44, transform: Tp2 },
2142
- { offset: 0.60, transform: Tp1 },
2143
- { offset: 0.78, transform: T0 },
2144
- { offset: 0.90, transform: T0 },
2145
- { offset: 1.0, transform: T0 },
2298
+ pathKey(0, Tfull),
2299
+ pathKey(0.28, Tp3),
2300
+ pathKey(0.44, Tp2),
2301
+ pathKey(0.60, Tp1),
2302
+ pathKey(0.78, T0),
2303
+ pathKey(0.90, T0),
2304
+ pathKey(1, T0),
2146
2305
  ],
2147
2306
  closeMotion
2148
2307
  );
2149
2308
  // Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
2150
- const aLeftClose = flyer._left
2309
+ const aLeftClose = earlyLeft || (flyer._left
2151
2310
  ? flyer._left.animate(
2152
2311
  [
2153
2312
  { offset: 0, transform: "rotateY(8deg)" },
@@ -2159,8 +2318,8 @@ export function mountBookshelf(root, options = {}) {
2159
2318
  ],
2160
2319
  closeMotion
2161
2320
  )
2162
- : null;
2163
- const aRightClose = flyer._right
2321
+ : null);
2322
+ const aRightClose = earlyRight || (flyer._right
2164
2323
  ? flyer._right.animate(
2165
2324
  [
2166
2325
  { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
@@ -2170,7 +2329,7 @@ export function mountBookshelf(root, options = {}) {
2170
2329
  ],
2171
2330
  closeMotion
2172
2331
  )
2173
- : null;
2332
+ : null);
2174
2333
  const closedFaceOut = [
2175
2334
  { offset: 0, opacity: 1 },
2176
2335
  { offset: 0.18, opacity: 0.7 },
@@ -2347,7 +2506,7 @@ export function mountBookshelf(root, options = {}) {
2347
2506
  try { a.cancel(); } catch (_) {}
2348
2507
  });
2349
2508
  } catch (_) {}
2350
- flyer.style.transform = T0; // stay locked on frozen home until slot fully owns pixels
2509
+ applyPathPose(flyer, T0); // stay locked on frozen home until slot fully owns pixels
2351
2510
  flyer.style.opacity = "0";
2352
2511
  slot.style.opacity = "1";
2353
2512
  slot.style.transform = "none";
@@ -2376,6 +2535,21 @@ export function mountBookshelf(root, options = {}) {
2376
2535
  openFromExpanded = false;
2377
2536
  restoreSlot(slot, book);
2378
2537
  try { slot.classList.remove("is-extracting"); } catch (_) {}
2538
+ // Pointer is often still over the returned spine. :hover is translateY(-8px)
2539
+ // scale(1.06), which measures ~8–11px taller than neighbors. Hold that off
2540
+ // until the pointer leaves; drop immediately if it is not hovering.
2541
+ if (targetMode === "spines" && slot) {
2542
+ slot.classList.add("is-seat");
2543
+ const releaseSeat = () => {
2544
+ slot.classList.remove("is-seat");
2545
+ slot.removeEventListener("pointerleave", releaseSeat);
2546
+ };
2547
+ slot.addEventListener("pointerleave", releaseSeat);
2548
+ requestAnimationFrame(() => {
2549
+ if (!slot.isConnected) return;
2550
+ if (!slot.matches(":hover")) releaseSeat();
2551
+ });
2552
+ }
2379
2553
  // ux23: drop close-path inline cover pin; resting .img-* CSS owns the seat again
2380
2554
  if (targetMode === "covers" && slot) {
2381
2555
  ["background-image", "background-size", "background-position", "background-repeat", "background-color"].forEach((p) => {
@@ -2445,6 +2619,47 @@ export function mountBookshelf(root, options = {}) {
2445
2619
 
2446
2620
  listen($("#backBtn"), "click", () => closeBookFlow());
2447
2621
 
2622
+ // Browser Back from the full note restores this page from bfcache, still open.
2623
+ // Land on the shelf at the scroll we saved, without replaying the close clock.
2624
+ listen(window, "pageshow", (event) => {
2625
+ if (!event.persisted || !openBook) return;
2626
+ const home = scrollLock
2627
+ ? { x: scrollLock.x, y: scrollLock.y }
2628
+ : { x: scrollHome.x, y: scrollHome.y };
2629
+ try { releaseScrollLock(); } catch (_) {}
2630
+ openBook = null;
2631
+ openFromExpanded = false;
2632
+ expandedId = null;
2633
+ touchExpandedId = null;
2634
+ busy = false;
2635
+ flowSeq += 1;
2636
+ document.body.classList.remove("is-busy");
2637
+ noteView.classList.remove("is-open", "is-preparing");
2638
+ noteView.hidden = true;
2639
+ noteView.style.opacity = "";
2640
+ noteView.style.zIndex = "";
2641
+ if (stage) stage.replaceChildren();
2642
+ resetVeil();
2643
+ resetBackdrop();
2644
+ unparkShelf();
2645
+ shelfView.classList.remove("is-frozen", "is-dim", "is-parked");
2646
+ shelfView.style.opacity = "";
2647
+ try { shelfView.style.removeProperty("min-height"); } catch (_) {}
2648
+ renderShelf();
2649
+ writeScroll(home);
2650
+ requestAnimationFrame(() => writeScroll(home));
2651
+ });
2652
+
2653
+ // Leaving for the post page: remember the shelf scroll so Back lands on it.
2654
+ listen(root, "click", (event) => {
2655
+ const target = event.target;
2656
+ if (!target || !target.closest) return;
2657
+ const link = target.closest("a.flyer-page-more, a.note-read");
2658
+ if (!link || !root.contains(link)) return;
2659
+ const y = scrollLock ? scrollLock.y : readScroll().y;
2660
+ try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
2661
+ });
2662
+
2448
2663
  // Escape closes note
2449
2664
  listen(window, "keydown", (e) => {
2450
2665
  if (e.key === "Escape" && openBook && !busy) closeBookFlow();
@@ -0,0 +1,86 @@
1
+ export class BookshelfConfigError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.name = "BookshelfConfigError";
5
+ this.code = code;
6
+ }
7
+ }
8
+
9
+ const SIZES = new Set(["thin", "s-short", "s-mid", "s-tall"]);
10
+
11
+ function record(value, code, message) {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
13
+ return value;
14
+ }
15
+
16
+ function optionalString(value) {
17
+ if (typeof value !== "string") return undefined;
18
+ const trimmed = value.trim();
19
+ return trimmed ? trimmed : undefined;
20
+ }
21
+
22
+ function parseFiller(value, where) {
23
+ if (value == null || value === false) return undefined;
24
+ if (value === true || value === "sand" || value === "cream") return value;
25
+ throw new BookshelfConfigError("filler", `${where} 的 filler 只能是 true、sand 或 cream。`);
26
+ }
27
+
28
+ function parseSize(value, where) {
29
+ if (value == null || value === "") return undefined;
30
+ if (typeof value === "string" && SIZES.has(value)) return value;
31
+ throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
32
+ }
33
+
34
+ function parseBook(value, where, seen) {
35
+ const book = record(value, "book", `${where} 必须是映射。`);
36
+ const id = optionalString(book.id);
37
+ const title = optionalString(book.title);
38
+ if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
39
+ if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
40
+ seen.add(id);
41
+ return {
42
+ id,
43
+ title,
44
+ author: optionalString(book.author),
45
+ spine: optionalString(book.spine),
46
+ size: parseSize(book.size, where),
47
+ cover: optionalString(book.cover),
48
+ color: optionalString(book.color),
49
+ filler: parseFiller(book.filler, where),
50
+ post: optionalString(book.post),
51
+ note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
52
+ };
53
+ }
54
+
55
+ function parseShelf(value, index, seen) {
56
+ const where = `shelves[${index}]`;
57
+ const shelf = record(value, "shelf", `${where} 必须是映射。`);
58
+ const id = optionalString(shelf.id);
59
+ const name = optionalString(shelf.name);
60
+ if (!id || !name) throw new BookshelfConfigError("shelf", `${where} 需要 id 和 name。`);
61
+ if (!Array.isArray(shelf.books) || shelf.books.length === 0) {
62
+ throw new BookshelfConfigError("shelf", `${where} 的 books 必须是非空数组。`);
63
+ }
64
+ return {
65
+ id,
66
+ name,
67
+ books: shelf.books.map((book, bookIndex) => parseBook(book, `${where}.books[${bookIndex}]`, seen)),
68
+ };
69
+ }
70
+
71
+ /** Validate `content/bookshelf.yaml`. Throws `BookshelfConfigError` with a stable `code`. */
72
+ export function parseBookshelfConfig(input) {
73
+ const data = record(input, "invalid-root", "content/bookshelf.yaml 必须是映射。");
74
+ const title = optionalString(data.title);
75
+ if (!title) throw new BookshelfConfigError("missing-title", "content/bookshelf.yaml 缺少 title。");
76
+ if (!Array.isArray(data.shelves) || data.shelves.length === 0) {
77
+ throw new BookshelfConfigError("shelves", "content/bookshelf.yaml 的 shelves 必须是非空数组。");
78
+ }
79
+ const seen = new Set();
80
+ return {
81
+ title,
82
+ description: optionalString(data.description),
83
+ eyebrow: optionalString(data.eyebrow),
84
+ shelves: data.shelves.map((shelf, index) => parseShelf(shelf, index, seen)),
85
+ };
86
+ }