hd-garden 0.9.0 → 0.9.2

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
 
@@ -298,6 +312,30 @@ export function mountBookshelf(root, options = {}) {
298
312
  return Math.max(96, Math.round(w * (nat.h / nat.w)));
299
313
  }
300
314
 
315
+ // Real jacket on the expanded spine. CSS background-image is stripped by
316
+ // `.spine[class*="img-"] { background-image: none !important }` and by the
317
+ // collapse rule, which left bk-* (#513688) as a solid pad at extract start.
318
+ function mountSeatCover(el, book) {
319
+ if (!el || !book || !book.cover || book.filler) return;
320
+ el.style.backgroundColor = "transparent";
321
+ let art = el.querySelector(":scope > .cover-art");
322
+ if (!art) {
323
+ art = document.createElement("img");
324
+ art.className = "cover-art";
325
+ art.alt = "";
326
+ art.draggable = false;
327
+ el.appendChild(art);
328
+ }
329
+ const src = coverSrc(book);
330
+ if (art.getAttribute("src") !== src) art.src = src;
331
+ }
332
+
333
+ function clearSeatCover(el) {
334
+ if (!el || !el.classList.contains("spine")) return;
335
+ el.querySelectorAll(":scope > .cover-art").forEach((node) => node.remove());
336
+ try { el.style.removeProperty("background-color"); } catch (_) {}
337
+ }
338
+
301
339
  function applyFaceSize(el, book, { expanded = false } = {}) {
302
340
  if (!el || !book) return;
303
341
  const faceH = bookFaceHeight(book);
@@ -323,12 +361,13 @@ export function mountBookshelf(root, options = {}) {
323
361
  return null;
324
362
  }
325
363
 
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.
364
+ // ux21 lock: mobile resting = all covers (全封面).
365
+ // Match CSS mobile shelf: (hover:none) OR max-width 640.
366
+ // DevTools「手机 393」often keeps hover:hover — width gate is required or autoDecide→spines.
328
367
  function isMobilePrimary() {
329
368
  return (
330
- window.matchMedia("(hover: none)").matches &&
331
- window.matchMedia("(pointer: coarse)").matches
369
+ window.matchMedia("(hover: none)").matches ||
370
+ window.matchMedia("(max-width: 640px)").matches
332
371
  );
333
372
  }
334
373
 
@@ -565,6 +604,7 @@ export function mountBookshelf(root, options = {}) {
565
604
  function restoreRestingSpine(el, bookId) {
566
605
  if (!el) return;
567
606
  el.classList.remove("is-expanded", "is-collapsing");
607
+ clearSeatCover(el);
568
608
  const found = bookById(bookId);
569
609
  if (found && found.book.cover && !found.book.filler) {
570
610
  applyFaceSize(el, found.book, { expanded: false });
@@ -606,6 +646,7 @@ export function mountBookshelf(root, options = {}) {
606
646
  el.classList.remove("is-collapsing");
607
647
  el.classList.add("is-expanded");
608
648
  applyFaceSize(el, found.book, { expanded: true });
649
+ mountSeatCover(el, found.book);
609
650
  }
610
651
  }
611
652
 
@@ -671,12 +712,37 @@ export function mountBookshelf(root, options = {}) {
671
712
  $("#noteTitle").textContent = book.title;
672
713
  $("#noteAuthor").textContent = book.author || "";
673
714
  const coverEl = $("#noteCover");
674
- coverEl.className = `note-cover ${bkClass(book)} ${coverClass(book)}`;
715
+ // Intrinsic <img>, not the .img-* background. That background is
716
+ // background-size:cover inside a fixed 2:3 frame and clips landscape art.
717
+ coverEl.className = "note-cover";
718
+ coverEl.style.backgroundImage = "none";
719
+ coverEl.style.backgroundColor = "transparent";
720
+ coverEl.replaceChildren();
721
+ if (book.cover) {
722
+ const img = document.createElement("img");
723
+ img.className = "note-cover-img";
724
+ img.alt = "";
725
+ img.draggable = false;
726
+ img.src = coverSrc(book);
727
+ coverEl.appendChild(img);
728
+ }
675
729
  const note = NOTES[book.note];
730
+ const read = $("#noteRead");
731
+ if (read) {
732
+ if (book.href) {
733
+ read.href = book.href;
734
+ read.hidden = false;
735
+ } else {
736
+ read.hidden = true;
737
+ read.removeAttribute("href");
738
+ }
739
+ }
676
740
  if (note) {
677
741
  $("#noteBody").innerHTML = note.body;
678
- } else {
742
+ } else if (usingDemo) {
679
743
  $("#noteBody").innerHTML = `<p class="placeholder">这本还没有完整笔记正文。Demo 占位:返回书架继续抽翻验收。</p><p>${book.title}${book.author ? " · " + book.author : ""}</p>`;
744
+ } else {
745
+ $("#noteBody").innerHTML = `<p class="placeholder">这本还没有笔记正文。</p><p>${escapeHtml(book.title)}${book.author ? " · " + escapeHtml(book.author) : ""}</p>`;
680
746
  }
681
747
  }
682
748
 
@@ -727,15 +793,23 @@ export function mountBookshelf(root, options = {}) {
727
793
  return `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})${extra ? " " + extra : ""}`;
728
794
  }
729
795
 
730
- // Closed book: cover on RIGHT half. Uniform scale only (non-uniform sx/sy = stretch jitter).
796
+ // Closed book: cover on RIGHT half.
797
+ // Covers stay uniform (sx ≈ sy). A narrow spine must NOT share that one scale:
798
+ // min(spineW / halfSpread, spineH / spreadH) ≈ 0.09 crushes height to ~45px.
799
+ // Spine slot: scale X and Y apart so the flyer box height stays the resting spine.
731
800
  function closedCoverTransform(slotRect, mid) {
732
801
  const halfW = mid.width / 2;
733
802
  const sx = slotRect.width / halfW;
734
803
  const sy = slotRect.height / mid.height;
804
+ const narrow = slotRect.width / Math.max(1, slotRect.height) < 0.42;
805
+ if (narrow) {
806
+ const tx = slotRect.left - mid.left - halfW * sx;
807
+ const ty = slotRect.top - mid.top;
808
+ return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
809
+ }
735
810
  const s = Math.min(sx, sy);
736
811
  const scaledHalfW = halfW * s;
737
812
  const scaledH = mid.height * s;
738
- // Center scaled right-half over slot
739
813
  const tx = slotRect.left - mid.left - halfW * s + (slotRect.width - scaledHalfW) / 2;
740
814
  const ty = slotRect.top - mid.top + (slotRect.height - scaledH) / 2;
741
815
  return `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px) scale(${s.toFixed(4)})`;
@@ -879,19 +953,27 @@ export function mountBookshelf(root, options = {}) {
879
953
  // Parse translate/scale from our path strings; lerp components so grow ≠ hard jump (PATH-SHAKE ~2.2s).
880
954
  function parseTS(transform) {
881
955
  const s = String(transform || "");
882
- let tx = 0, ty = 0, sc = 1;
956
+ let tx = 0, ty = 0, sx = 1, sy = 1;
883
957
  let m = s.match(/translate3d\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
884
958
  if (m) { tx = +m[1]; ty = +m[2]; }
885
959
  else {
886
960
  m = s.match(/translate\(\s*([\d.-]+)px\s*,\s*([\d.-]+)px/);
887
961
  if (m) { tx = +m[1]; ty = +m[2]; }
888
962
  }
889
- m = s.match(/scale\(\s*([\d.-]+)\s*(?:,\s*[\d.-]+)?\s*\)/);
890
- if (m) sc = +m[1];
891
- return { tx, ty, s: sc };
963
+ m = s.match(/scale\(\s*([\d.-]+)(?:\s*,\s*([\d.-]+))?\s*\)/);
964
+ if (m) {
965
+ sx = +m[1];
966
+ sy = m[2] != null ? +m[2] : sx;
967
+ }
968
+ return { tx, ty, sx, sy };
892
969
  }
893
970
  function formatTS(p) {
894
- return `translate(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px) scale(${p.s.toFixed(4)})`;
971
+ const sx = p.sx != null ? p.sx : p.s;
972
+ const sy = p.sy != null ? p.sy : sx;
973
+ const scale = Math.abs(sx - sy) < 1e-4
974
+ ? `scale(${sx.toFixed(4)})`
975
+ : `scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
976
+ return `translate(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px) ${scale}`;
895
977
  }
896
978
  function lerpTS(a, b, t) {
897
979
  const u = Math.max(0, Math.min(1, t));
@@ -900,10 +982,68 @@ export function mountBookshelf(root, options = {}) {
900
982
  return formatTS({
901
983
  tx: pa.tx + (pb.tx - pa.tx) * u,
902
984
  ty: pa.ty + (pb.ty - pa.ty) * u,
903
- s: pa.s + (pb.s - pa.s) * u,
985
+ sx: pa.sx + (pb.sx - pa.sx) * u,
986
+ sy: pa.sy + (pb.sy - pa.sy) * u,
904
987
  });
905
988
  }
906
989
 
990
+ // Pose for the flyer move. Same tx/ty/sx/sy as the ux26 string, but on the
991
+ // individual translate and scale properties. A combined transform matrix
992
+ // decomposes badly when sx ≠ sy (slot height pin) and the flight stutters.
993
+ function pathPose(transform) {
994
+ const p = parseTS(transform);
995
+ return {
996
+ translate: `${p.tx.toFixed(2)}px ${p.ty.toFixed(2)}px`,
997
+ scale: `${p.sx.toFixed(4)} ${p.sy.toFixed(4)}`,
998
+ };
999
+ }
1000
+ function applyPathPose(el, transform) {
1001
+ if (!el) return;
1002
+ const pose = pathPose(transform);
1003
+ el.style.transform = "none";
1004
+ el.style.translate = pose.translate;
1005
+ el.style.scale = pose.scale;
1006
+ }
1007
+ function pathKey(offset, transform) {
1008
+ return { offset, ...pathPose(transform) };
1009
+ }
1010
+
1011
+ // Close samples. T0 still carries the separate sx/sy height pin.
1012
+ // The old first stop was Tp3 (only 25% of the way home) at 0.28, so the
1013
+ // book was still nearly full-size for the first ~1s and read as overlap.
1014
+ function returnMoveKeys(Tfull, Tp1, T0) {
1015
+ return [
1016
+ pathKey(0, Tfull),
1017
+ pathKey(0.12, lerpTS(Tfull, T0, 0.35)),
1018
+ pathKey(0.32, lerpTS(Tfull, T0, 0.62)),
1019
+ pathKey(0.52, Tp1),
1020
+ pathKey(0.74, T0),
1021
+ pathKey(0.88, T0),
1022
+ pathKey(1, T0),
1023
+ ];
1024
+ }
1025
+
1026
+ // Note stays out until the flyer leaves, then a short handoff.
1027
+ // Rising from 0.65 while the flyer stayed solid until 0.90 was the double exposure.
1028
+ function noteInKeys() {
1029
+ return [
1030
+ { offset: 0, opacity: 0 },
1031
+ { offset: 0.82, opacity: 0 },
1032
+ { offset: 0.90, opacity: 1 },
1033
+ { offset: 1, opacity: 1 },
1034
+ ];
1035
+ }
1036
+
1037
+ // Article is gone by ~0.12 (230ms). Shrink has already started on frame 0.
1038
+ function noteOutKeys() {
1039
+ return [
1040
+ { offset: 0, opacity: 1 },
1041
+ { offset: 0.05, opacity: 0.35 },
1042
+ { offset: 0.12, opacity: 0 },
1043
+ { offset: 1, opacity: 0 },
1044
+ ];
1045
+ }
1046
+
907
1047
  // ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
908
1048
  // X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
909
1049
  // Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
@@ -1214,6 +1354,7 @@ export function mountBookshelf(root, options = {}) {
1214
1354
  "is-expanded",
1215
1355
  "is-collapsing"
1216
1356
  );
1357
+ clearSeatCover(el);
1217
1358
  // Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
1218
1359
  [
1219
1360
  "width",
@@ -1259,6 +1400,9 @@ export function mountBookshelf(root, options = {}) {
1259
1400
 
1260
1401
  const board = document.createElement("div");
1261
1402
  board.className = "flyer-board";
1403
+ // ux27: never a solid shell behind the jacket. CSS also forces this off.
1404
+ board.style.opacity = "0";
1405
+ board.style.visibility = "hidden";
1262
1406
 
1263
1407
  const left = document.createElement("div");
1264
1408
  left.className = "flyer-leaf flyer-leaf-left";
@@ -1328,7 +1472,7 @@ export function mountBookshelf(root, options = {}) {
1328
1472
  right.style.transform = "rotateY(-8deg)";
1329
1473
  bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
1330
1474
  gutter.style.opacity = "1";
1331
- flyer.style.transform = "translate3d(0px, -4px, 0) scale(1)";
1475
+ applyPathPose(flyer, "translate(0.00px, -4.00px) scale(1)");
1332
1476
  flyer.style.opacity = "1";
1333
1477
  } else {
1334
1478
  // ux10: closed extract must look like ONE cover/spine — hide board + right page
@@ -1336,12 +1480,11 @@ export function mountBookshelf(root, options = {}) {
1336
1480
  left.style.transform = "rotateY(180deg)";
1337
1481
  right.style.transform = "rotateY(0deg)";
1338
1482
  right.style.opacity = "0";
1339
- board.style.opacity = "0";
1340
1483
  leftThick.style.opacity = "0";
1341
1484
  rightThick.style.opacity = "0";
1342
1485
  bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
1343
1486
  gutter.style.opacity = "0";
1344
- flyer.style.transform = closedCoverTransform(atRect, mid);
1487
+ applyPathPose(flyer, closedCoverTransform(atRect, mid));
1345
1488
  flyer.style.opacity = "1";
1346
1489
  }
1347
1490
  stage.appendChild(flyer);
@@ -1351,7 +1494,7 @@ export function mountBookshelf(root, options = {}) {
1351
1494
  function promote(el) {
1352
1495
  if (!el) return;
1353
1496
  el.classList.add("is-ready");
1354
- el.style.willChange = "transform, opacity";
1497
+ el.style.willChange = "translate, scale, opacity";
1355
1498
  }
1356
1499
 
1357
1500
  function demote(el) {
@@ -1410,13 +1553,9 @@ export function mountBookshelf(root, options = {}) {
1410
1553
  noteView.classList.add("is-preparing");
1411
1554
  noteView.classList.remove("is-open");
1412
1555
  noteView.style.opacity = "0";
1413
- const coverEl = $("#noteCover");
1414
- const bg = coverEl && getComputedStyle(coverEl).backgroundImage;
1415
- const m = bg && bg.match(/url\(["']?([^"')]+)["']?\)/);
1416
- if (m) {
1556
+ const img = $("#noteCover") && $("#noteCover").querySelector(".note-cover-img");
1557
+ if (img && !coverImgReady(img)) {
1417
1558
  try {
1418
- const img = new Image();
1419
- img.src = m[1];
1420
1559
  const DECODE_MS = 400;
1421
1560
  const decodeOrLoad = img.decode
1422
1561
  ? img.decode()
@@ -1463,6 +1602,7 @@ export function mountBookshelf(root, options = {}) {
1463
1602
  busy = true;
1464
1603
  const myFlow = ++flowSeq;
1465
1604
  document.body.classList.add("is-busy");
1605
+ root.classList.remove("is-return");
1466
1606
  openBook = { book, cat, asSpine, fromExpanded: !!fromExpanded, openSeatRect: null };
1467
1607
  openFromExpanded = !!fromExpanded;
1468
1608
 
@@ -1503,20 +1643,28 @@ export function mountBookshelf(root, options = {}) {
1503
1643
  sourceEl.style.transition = "none";
1504
1644
  sourceEl.style.transform = "none"; // kill :hover translateY / is-expanded lift
1505
1645
  sourceEl.classList.add("is-extracting"); // CSS: suppress :hover transform while extracting
1646
+ // Keep the real jacket on the seat until the flyer exists. Dropping
1647
+ // is-expanded used to reveal the bk-* fill for the frames before the flyer.
1648
+ if (sourceEl.classList.contains("spine") && book && book.cover) {
1649
+ mountSeatCover(sourceEl, book);
1650
+ }
1506
1651
  if (fromExpanded) {
1507
1652
  sourceEl.classList.remove("is-expanded", "is-collapsing");
1508
- [
1509
- "width",
1510
- "height",
1511
- "min-width",
1512
- "min-height",
1513
- "flex-shrink",
1514
- ].forEach((p) => {
1653
+ // Drop the expanded cover's width. Do NOT drop a resting spine's height:
1654
+ // clearing it falls through to .s-mid/.s-short (152/136), shorter than the
1655
+ // face height the shelf is already showing, and that short box becomes
1656
+ // homeRect for extract and put-back.
1657
+ ["width", "min-width", "min-height", "flex-shrink"].forEach((p) => {
1515
1658
  try { sourceEl.style.removeProperty(p); } catch (_) {}
1516
1659
  });
1660
+ if (sourceEl.classList.contains("spine") && book && book.cover && !book.filler) {
1661
+ applyFaceSize(sourceEl, book);
1662
+ } else {
1663
+ try { sourceEl.style.removeProperty("height"); } catch (_) {}
1664
+ }
1517
1665
  expandedId = null;
1518
1666
  touchExpandedId = null;
1519
- void sourceEl.offsetWidth; // reflow to natural spine (transform already none)
1667
+ void sourceEl.offsetWidth; // reflow to resting spine (transform already none)
1520
1668
  } else {
1521
1669
  // Even spine-direct: pointer may still be hovering → :hover lift would skew freeze.
1522
1670
  void sourceEl.offsetWidth;
@@ -1636,13 +1784,13 @@ export function mountBookshelf(root, options = {}) {
1636
1784
  // Same dual: single diagonal to full — no hold-then-slide second segment.
1637
1785
  const aDualMove = dual.animate(
1638
1786
  [
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 },
1787
+ pathKey(0, T0),
1788
+ pathKey(0.12, Tp1),
1789
+ pathKey(0.28, Tp2),
1790
+ pathKey(0.44, Tp3),
1791
+ pathKey(0.58, Tfull),
1792
+ pathKey(0.78, Tfull),
1793
+ pathKey(1, Tfull),
1646
1794
  ],
1647
1795
  optsMotion
1648
1796
  );
@@ -1654,14 +1802,14 @@ export function mountBookshelf(root, options = {}) {
1654
1802
  { offset: 0, opacity: 0 },
1655
1803
  { offset: 0.14, opacity: 0 },
1656
1804
  { offset: 0.22, opacity: 1 },
1657
- { offset: 0.90, opacity: 1 },
1658
- { offset: 0.96, opacity: 0.35 },
1805
+ { offset: 0.82, opacity: 1 },
1806
+ { offset: 0.90, opacity: 0 },
1659
1807
  { offset: 1.0, opacity: 0 },
1660
1808
  ]
1661
1809
  : [
1662
1810
  { offset: 0, opacity: 1 },
1663
- { offset: 0.90, opacity: 1 },
1664
- { offset: 0.96, opacity: 0.35 },
1811
+ { offset: 0.82, opacity: 1 },
1812
+ { offset: 0.90, opacity: 0 },
1665
1813
  { offset: 1.0, opacity: 0 },
1666
1814
  ],
1667
1815
  opts
@@ -1687,6 +1835,7 @@ export function mountBookshelf(root, options = {}) {
1687
1835
  const aExtractCoverIn = null;
1688
1836
 
1689
1837
  // Pose fade on SAME layer while diagonal continues — ban origin-swapping phase.
1838
+ // Board stays out: fading it in painted a wide shell behind the cover by ~1350ms.
1690
1839
  const closedFaceKf = [
1691
1840
  { offset: 0, opacity: 0 },
1692
1841
  { offset: 0.28, opacity: 0 },
@@ -1694,7 +1843,11 @@ export function mountBookshelf(root, options = {}) {
1694
1843
  { offset: 0.52, opacity: 1 },
1695
1844
  { offset: 1.0, opacity: 1 },
1696
1845
  ];
1697
- const aBoard = dual._board ? dual._board.animate(closedFaceKf, opts) : null;
1846
+ if (dual._board) {
1847
+ dual._board.style.opacity = "0";
1848
+ dual._board.style.visibility = "hidden";
1849
+ }
1850
+ const aBoard = null;
1698
1851
  const aRightFace = dual._right.animate(
1699
1852
  [
1700
1853
  { offset: 0, opacity: 0, transform: "rotateY(0deg)" },
@@ -1735,20 +1888,9 @@ export function mountBookshelf(root, options = {}) {
1735
1888
  )
1736
1889
  : null;
1737
1890
 
1738
- // §12: note main rise offset 0.70–0.92 (≥1.80s clear). FORBIDDEN: ≥0.95 at 0.34.
1739
- const aNote = noteView.animate(
1740
- [
1741
- { offset: 0, opacity: 0 },
1742
- { offset: 0.65, opacity: 0 },
1743
- { offset: 0.70, opacity: 0.15 },
1744
- { offset: 0.78, opacity: 0.55 },
1745
- { offset: 0.85, opacity: 0.90 },
1746
- { offset: 0.90, opacity: 0.99 },
1747
- { offset: 0.92, opacity: 1 },
1748
- { offset: 1.0, opacity: 1 },
1749
- ],
1750
- opts
1751
- );
1891
+ // Note stays at 0 while the flyer owns the flight, then hands off by unlock (0.90).
1892
+ // FORBIDDEN: note body readable while the flyer is still solid (double exposure).
1893
+ const aNote = noteView.animate(noteInKeys(), opts);
1752
1894
 
1753
1895
  const aBack = openBackdrop
1754
1896
  ? openBackdrop.animate(
@@ -1802,6 +1944,9 @@ export function mountBookshelf(root, options = {}) {
1802
1944
  try { a.cancel(); } catch (_) {}
1803
1945
  });
1804
1946
  } catch (_) {}
1947
+ // commitStyles does not keep the individual scale/translate pose. Park at
1948
+ // the open spread so close shrinks from 672×504 instead of jumping there.
1949
+ applyPathPose(dual, Tfull);
1805
1950
  dual.style.opacity = "0";
1806
1951
  dual.style.pointerEvents = "none";
1807
1952
  dual.style.visibility = "visible";
@@ -1887,6 +2032,74 @@ export function mountBookshelf(root, options = {}) {
1887
2032
  return;
1888
2033
  }
1889
2034
 
2035
+ // Clock first. Shelf measure, freeze, and rAF used to run before this and
2036
+ // the 0.12 Tfull keyframe then held 672×504 for ~240ms. Stash from open is
2037
+ // enough to start the return; height pin is already in those samples.
2038
+ // is-return puts the stage above the note so the shrink is not hidden
2039
+ // under an opaque article (that read as ~1s of overlap, then a jump).
2040
+ root.classList.add("is-return");
2041
+ const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
2042
+ const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
2043
+ const stashEarly = openBook.pathTs;
2044
+ let earlyMove = null;
2045
+ let earlyNote = null;
2046
+ let earlyLeft = null;
2047
+ let earlyRight = null;
2048
+ const heldEarly = openBook.heldFlyer;
2049
+ if (
2050
+ heldEarly &&
2051
+ heldEarly.isConnected &&
2052
+ stashEarly &&
2053
+ stashEarly.Tfull &&
2054
+ stashEarly.T0 &&
2055
+ stashEarly.Tp1 &&
2056
+ stashEarly.Tp2 &&
2057
+ stashEarly.Tp3
2058
+ ) {
2059
+ heldEarly.style.visibility = "visible";
2060
+ heldEarly.style.zIndex = "70";
2061
+ heldEarly.style.opacity = "1";
2062
+ if (heldEarly._board) {
2063
+ heldEarly._board.style.opacity = "0";
2064
+ heldEarly._board.style.visibility = "hidden";
2065
+ }
2066
+ promote(heldEarly);
2067
+ earlyMove = heldEarly.animate(
2068
+ returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
2069
+ closeMotion
2070
+ );
2071
+ 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(() => {
2074
+ if (myFlow !== flowSeq || !noteView) return;
2075
+ noteView.style.opacity = "0";
2076
+ });
2077
+ 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
+ );
2089
+ }
2090
+ 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
+ );
2100
+ }
2101
+ }
2102
+
1890
2103
  const mid = midRect(book);
1891
2104
  const originX = mid.left + mid.width / 2;
1892
2105
  const originY = mid.top + mid.height / 2;
@@ -1910,8 +2123,14 @@ export function mountBookshelf(root, options = {}) {
1910
2123
  slot = $(slotSel);
1911
2124
  }
1912
2125
  if (!slot) {
2126
+ try { earlyMove && earlyMove.cancel(); } catch (_) {}
2127
+ try { earlyNote && earlyNote.cancel(); } catch (_) {}
2128
+ try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
2129
+ try { earlyRight && earlyRight.cancel(); } catch (_) {}
2130
+ root.classList.remove("is-return");
1913
2131
  noteView.classList.remove("is-open", "is-preparing");
1914
2132
  noteView.hidden = true;
2133
+ noteView.style.opacity = "";
1915
2134
  openBook = null;
1916
2135
  busy = false;
1917
2136
  document.body.classList.remove("is-busy");
@@ -1932,9 +2151,17 @@ export function mountBookshelf(root, options = {}) {
1932
2151
  ].forEach((p) => {
1933
2152
  try { slot.style.removeProperty(p); } catch (_) {}
1934
2153
  });
1935
- slot.classList.remove("is-collapsing", "is-placeholder", "is-expanded");
2154
+ slot.classList.remove("is-collapsing", "is-expanded");
2155
+ // Keep the seat empty until handover. Dropping is-placeholder here let the
2156
+ // bk-* fill paint a solid rectangle under the returning cover.
2157
+ slot.classList.add("is-placeholder");
1936
2158
  expandedId = null;
1937
- void slot.offsetWidth; // reflow to natural spine after class/inline clear
2159
+ // Spines: re-pin the resting face BEFORE reading the seat. A cleared inline
2160
+ // height is the shorter size-class box, and put-back would keep it.
2161
+ if (targetMode === "spines" && book && book.cover && !book.filler) {
2162
+ applyFaceSize(slot, book);
2163
+ }
2164
+ void slot.offsetWidth; // reflow to resting spine after class/inline clear
1938
2165
  const naturalW = slot.offsetWidth;
1939
2166
  const naturalH = slot.offsetHeight;
1940
2167
  slot.style.flexShrink = "0";
@@ -1962,13 +2189,13 @@ export function mountBookshelf(root, options = {}) {
1962
2189
  });
1963
2190
  shelfView.classList.add("is-frozen");
1964
2191
 
1965
- // ux13: prepare shelf+flyer UNDER opaque note — ban route hard-cut then mid-air resume.
1966
- // Keep note is-open + opacity 1 until reverse clock starts.
2192
+ // Note stays in the tree so its fade can finish, but must not cover the flyer.
2193
+ // is-return (set before the clock) owns stacking. Do not pin z-index 75.
1967
2194
  noteView.classList.add("is-open");
1968
2195
  noteView.classList.remove("is-preparing");
1969
2196
  noteView.hidden = false;
1970
- noteView.style.opacity = "1";
1971
- noteView.style.zIndex = "75";
2197
+ if (!earlyNote) noteView.style.opacity = "1";
2198
+ noteView.style.zIndex = "";
1972
2199
 
1973
2200
  unparkShelf();
1974
2201
  shelfView.style.opacity = "1";
@@ -1990,6 +2217,17 @@ export function mountBookshelf(root, options = {}) {
1990
2217
  frozenHome.width = natW;
1991
2218
  frozenHome.height = natH;
1992
2219
  frozenHome.top = Math.round(bottom - natH);
2220
+ } else if (targetMode === "spines" && naturalH > 0) {
2221
+ // Seat and flyer use the resting spine just measured, not a short
2222
+ // cover-frame stash from extract.
2223
+ const bottom = frozenHome.top + frozenHome.height;
2224
+ frozenHome.width = naturalW;
2225
+ frozenHome.height = naturalH;
2226
+ frozenHome.top = Math.round(bottom - naturalH);
2227
+ if (openBook) {
2228
+ openBook.homeRect = copyRect(frozenHome);
2229
+ openBook.spineHome = copyRect(frozenHome);
2230
+ }
1993
2231
  }
1994
2232
  slot.style.width = frozenHome.width + "px";
1995
2233
  slot.style.minWidth = frozenHome.width + "px";
@@ -2027,8 +2265,10 @@ export function mountBookshelf(root, options = {}) {
2027
2265
  }
2028
2266
  flyer.style.zIndex = "70";
2029
2267
  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)
2268
+ if (!earlyMove) {
2269
+ flyer.style.opacity = "0";
2270
+ applyPathPose(flyer, Tfull); // start at open end pose (transform-aligned)
2271
+ }
2032
2272
  promote(flyer);
2033
2273
  if (flyer._cover) {
2034
2274
  flyer._cover.classList.add("is-cover-ready");
@@ -2050,7 +2290,10 @@ export function mountBookshelf(root, options = {}) {
2050
2290
  flyer._right.style.transform = "rotateY(-8deg)";
2051
2291
  flyer._right.style.opacity = "1";
2052
2292
  }
2053
- if (flyer._board) flyer._board.style.opacity = "1";
2293
+ if (flyer._board) {
2294
+ flyer._board.style.opacity = "0";
2295
+ flyer._board.style.visibility = "hidden";
2296
+ }
2054
2297
  if (flyer._leftThick) flyer._leftThick.style.opacity = "1";
2055
2298
  if (flyer._rightThick) flyer._rightThick.style.opacity = "1";
2056
2299
  if (flyer._gutter) flyer._gutter.style.opacity = "1";
@@ -2098,13 +2341,10 @@ export function mountBookshelf(root, options = {}) {
2098
2341
  `translate3d(${spineOutClose.x.toFixed(2)}px, ${spineOutClose.y.toFixed(2)}px, 0)`;
2099
2342
  }
2100
2343
 
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
- ]);
2344
+ // Start the reverse on this frame. A pre-clock rAF left the spread parked at
2345
+ // 672×504 while the note was still up (the ~240ms close-start hitch).
2106
2346
  void readyPulse;
2107
- flyer.style.opacity = "1"; // book layer present under note as reverse begins
2347
+ if (!earlyMove) flyer.style.opacity = "1";
2108
2348
 
2109
2349
  // ux14: TIME-REVERSE of open pathTs — ban separately computed return / end-segment seek.
2110
2350
  const PUT_MS = 380;
@@ -2113,41 +2353,20 @@ export function mountBookshelf(root, options = {}) {
2113
2353
  const xfadeFrom = 1 - XFADE_MS / CLOSE_MS; // ~0.89
2114
2354
  const slotInAt = Math.max(0.86, xfadeFrom - 0.04);
2115
2355
  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
2356
 
2119
2357
  resetVeil();
2120
2358
 
2121
- // ux13: note fades ON the book layer while reverse shrink starts — ban instant note unmount.
2122
- const aNoteOut = noteView.animate(
2123
- [
2124
- { offset: 0, opacity: 1 },
2125
- { offset: 0.06, opacity: 0.85 },
2126
- { offset: 0.12, opacity: 0.45 },
2127
- { offset: 0.22, opacity: 0 },
2128
- { offset: 1.0, opacity: 0 },
2129
- ],
2130
- closeOpts
2131
- );
2359
+ // Note fades out in the first ~230ms. Shrink already started on frame 0.
2360
+ const aNoteOut = earlyNote || noteView.animate(noteOutKeys(), closeOpts);
2132
2361
 
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(
2137
- [
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 },
2146
- ],
2362
+ // Shrink starts at offset 0 and is already well under way by ~400ms.
2363
+ // A first stop on Tp3 (25% home) at 0.28 left the book looking full for ~1s.
2364
+ const aFlyerMove = earlyMove || flyer.animate(
2365
+ returnMoveKeys(Tfull, Tp1, T0),
2147
2366
  closeMotion
2148
2367
  );
2149
2368
  // Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
2150
- const aLeftClose = flyer._left
2369
+ const aLeftClose = earlyLeft || (flyer._left
2151
2370
  ? flyer._left.animate(
2152
2371
  [
2153
2372
  { offset: 0, transform: "rotateY(8deg)" },
@@ -2159,8 +2378,8 @@ export function mountBookshelf(root, options = {}) {
2159
2378
  ],
2160
2379
  closeMotion
2161
2380
  )
2162
- : null;
2163
- const aRightClose = flyer._right
2381
+ : null);
2382
+ const aRightClose = earlyRight || (flyer._right
2164
2383
  ? flyer._right.animate(
2165
2384
  [
2166
2385
  { offset: 0, opacity: 1, transform: "rotateY(-8deg)" },
@@ -2170,14 +2389,14 @@ export function mountBookshelf(root, options = {}) {
2170
2389
  ],
2171
2390
  closeMotion
2172
2391
  )
2173
- : null;
2392
+ : null);
2174
2393
  const closedFaceOut = [
2175
2394
  { offset: 0, opacity: 1 },
2176
2395
  { offset: 0.18, opacity: 0.7 },
2177
2396
  { offset: 0.28, opacity: 0 },
2178
2397
  { offset: 1.0, opacity: 0 },
2179
2398
  ];
2180
- const aBoardClose = flyer._board ? flyer._board.animate(closedFaceOut, closeOpts) : null;
2399
+ const aBoardClose = null;
2181
2400
  const aThickLClose = flyer._leftThick ? flyer._leftThick.animate(closedFaceOut, closeOpts) : null;
2182
2401
  const aThickRClose = flyer._rightThick ? flyer._rightThick.animate(closedFaceOut, closeOpts) : null;
2183
2402
  const aGutterClose = flyer._gutter
@@ -2218,9 +2437,11 @@ export function mountBookshelf(root, options = {}) {
2218
2437
  const aFlyerFade = flyer.animate(
2219
2438
  targetMode === "spines"
2220
2439
  ? [
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.
2221
2442
  { offset: 0, opacity: 1 },
2222
- { offset: 0.50, opacity: 1 },
2223
- { offset: 0.58, opacity: 0 },
2443
+ { offset: 0.68, opacity: 1 },
2444
+ { offset: 0.80, opacity: 0 },
2224
2445
  { offset: 1.0, opacity: 0 },
2225
2446
  ]
2226
2447
  : [
@@ -2252,10 +2473,10 @@ export function mountBookshelf(root, options = {}) {
2252
2473
  ? spineSlotClose.animate(
2253
2474
  [
2254
2475
  { offset: 0, transform: outClose, opacity: 0 },
2255
- { offset: 0.50, transform: outClose, opacity: 0 },
2256
- { offset: 0.58, transform: outClose, opacity: 1 },
2257
- { offset: 0.78, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2258
- { offset: 0.90, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2476
+ { offset: 0.66, transform: outClose, opacity: 0 },
2477
+ { offset: 0.74, transform: outClose, opacity: 1 },
2478
+ { offset: 0.86, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2479
+ { offset: 0.94, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2259
2480
  { offset: 1.0, transform: "translate3d(0px, 0px, 0)", opacity: 0 },
2260
2481
  ],
2261
2482
  closeMotion
@@ -2270,8 +2491,8 @@ export function mountBookshelf(root, options = {}) {
2270
2491
  targetMode === "spines"
2271
2492
  ? [
2272
2493
  { offset: 0, opacity: 1 },
2273
- { offset: 0.50, opacity: 1 },
2274
- { offset: 0.58, opacity: 0 },
2494
+ { offset: 0.68, opacity: 1 },
2495
+ { offset: 0.80, opacity: 0 },
2275
2496
  { offset: 1, opacity: 0 },
2276
2497
  ]
2277
2498
  : [
@@ -2293,6 +2514,13 @@ export function mountBookshelf(root, options = {}) {
2293
2514
  slot.style.backgroundColor = "";
2294
2515
  }
2295
2516
 
2517
+ // Seat may show its real face only as the flyer hands off. Until then the
2518
+ // placeholder ban (transparent, invisible) blocks the solid bk plate.
2519
+ wait(Math.round(CLOSE_MS * Math.max(0, slotInAt - 0.02))).then(() => {
2520
+ if (myFlow !== flowSeq || !slot.isConnected) return;
2521
+ slot.classList.remove("is-placeholder");
2522
+ });
2523
+
2296
2524
  const aSlotIn = slot.animate(
2297
2525
  [
2298
2526
  { offset: 0, opacity: 0 },
@@ -2347,10 +2575,11 @@ export function mountBookshelf(root, options = {}) {
2347
2575
  try { a.cancel(); } catch (_) {}
2348
2576
  });
2349
2577
  } catch (_) {}
2350
- flyer.style.transform = T0; // stay locked on frozen home until slot fully owns pixels
2578
+ applyPathPose(flyer, T0); // stay locked on frozen home until slot fully owns pixels
2351
2579
  flyer.style.opacity = "0";
2352
2580
  slot.style.opacity = "1";
2353
2581
  slot.style.transform = "none";
2582
+ root.classList.remove("is-return");
2354
2583
  noteView.classList.remove("is-open", "is-preparing");
2355
2584
  noteView.hidden = true;
2356
2585
  noteView.style.opacity = "";
@@ -2376,6 +2605,21 @@ export function mountBookshelf(root, options = {}) {
2376
2605
  openFromExpanded = false;
2377
2606
  restoreSlot(slot, book);
2378
2607
  try { slot.classList.remove("is-extracting"); } catch (_) {}
2608
+ // Pointer is often still over the returned spine. :hover is translateY(-8px)
2609
+ // scale(1.06), which measures ~8–11px taller than neighbors. Hold that off
2610
+ // until the pointer leaves; drop immediately if it is not hovering.
2611
+ if (targetMode === "spines" && slot) {
2612
+ slot.classList.add("is-seat");
2613
+ const releaseSeat = () => {
2614
+ slot.classList.remove("is-seat");
2615
+ slot.removeEventListener("pointerleave", releaseSeat);
2616
+ };
2617
+ slot.addEventListener("pointerleave", releaseSeat);
2618
+ requestAnimationFrame(() => {
2619
+ if (!slot.isConnected) return;
2620
+ if (!slot.matches(":hover")) releaseSeat();
2621
+ });
2622
+ }
2379
2623
  // ux23: drop close-path inline cover pin; resting .img-* CSS owns the seat again
2380
2624
  if (targetMode === "covers" && slot) {
2381
2625
  ["background-image", "background-size", "background-position", "background-repeat", "background-color"].forEach((p) => {
@@ -2445,6 +2689,47 @@ export function mountBookshelf(root, options = {}) {
2445
2689
 
2446
2690
  listen($("#backBtn"), "click", () => closeBookFlow());
2447
2691
 
2692
+ // Browser Back from the full note restores this page from bfcache, still open.
2693
+ // Land on the shelf at the scroll we saved, without replaying the close clock.
2694
+ listen(window, "pageshow", (event) => {
2695
+ if (!event.persisted || !openBook) return;
2696
+ const home = scrollLock
2697
+ ? { x: scrollLock.x, y: scrollLock.y }
2698
+ : { x: scrollHome.x, y: scrollHome.y };
2699
+ try { releaseScrollLock(); } catch (_) {}
2700
+ openBook = null;
2701
+ openFromExpanded = false;
2702
+ expandedId = null;
2703
+ touchExpandedId = null;
2704
+ busy = false;
2705
+ flowSeq += 1;
2706
+ document.body.classList.remove("is-busy");
2707
+ noteView.classList.remove("is-open", "is-preparing");
2708
+ noteView.hidden = true;
2709
+ noteView.style.opacity = "";
2710
+ noteView.style.zIndex = "";
2711
+ if (stage) stage.replaceChildren();
2712
+ resetVeil();
2713
+ resetBackdrop();
2714
+ unparkShelf();
2715
+ shelfView.classList.remove("is-frozen", "is-dim", "is-parked");
2716
+ shelfView.style.opacity = "";
2717
+ try { shelfView.style.removeProperty("min-height"); } catch (_) {}
2718
+ renderShelf();
2719
+ writeScroll(home);
2720
+ requestAnimationFrame(() => writeScroll(home));
2721
+ });
2722
+
2723
+ // Leaving for the post page: remember the shelf scroll so Back lands on it.
2724
+ listen(root, "click", (event) => {
2725
+ const target = event.target;
2726
+ if (!target || !target.closest) return;
2727
+ const link = target.closest("a.flyer-page-more, a.note-read");
2728
+ if (!link || !root.contains(link)) return;
2729
+ const y = scrollLock ? scrollLock.y : readScroll().y;
2730
+ try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
2731
+ });
2732
+
2448
2733
  // Escape closes note
2449
2734
  listen(window, "keydown", (e) => {
2450
2735
  if (e.key === "Escape" && openBook && !busy) closeBookFlow();