hd-garden 0.9.1 → 0.9.3

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.
@@ -312,6 +312,30 @@ export function mountBookshelf(root, options = {}) {
312
312
  return Math.max(96, Math.round(w * (nat.h / nat.w)));
313
313
  }
314
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
+
315
339
  function applyFaceSize(el, book, { expanded = false } = {}) {
316
340
  if (!el || !book) return;
317
341
  const faceH = bookFaceHeight(book);
@@ -580,6 +604,7 @@ export function mountBookshelf(root, options = {}) {
580
604
  function restoreRestingSpine(el, bookId) {
581
605
  if (!el) return;
582
606
  el.classList.remove("is-expanded", "is-collapsing");
607
+ clearSeatCover(el);
583
608
  const found = bookById(bookId);
584
609
  if (found && found.book.cover && !found.book.filler) {
585
610
  applyFaceSize(el, found.book, { expanded: false });
@@ -621,6 +646,7 @@ export function mountBookshelf(root, options = {}) {
621
646
  el.classList.remove("is-collapsing");
622
647
  el.classList.add("is-expanded");
623
648
  applyFaceSize(el, found.book, { expanded: true });
649
+ mountSeatCover(el, found.book);
624
650
  }
625
651
  }
626
652
 
@@ -651,18 +677,31 @@ export function mountBookshelf(root, options = {}) {
651
677
  collapseToken += 1;
652
678
  }
653
679
 
680
+ // Multi-note books leave the shelf for /bookshelf/<id>. No extract flyer.
681
+ function leaveForHub(book) {
682
+ const posts = book && Array.isArray(book.posts) ? book.posts : [];
683
+ const href = book && typeof book.hub === "string" ? book.hub : "";
684
+ if (posts.length < 2 || !href) return false;
685
+ const y = scrollLock ? scrollLock.y : readScroll().y;
686
+ try { sessionStorage.setItem("garden-bookshelf-scroll", String(y)); } catch (_) {}
687
+ window.location.assign(href);
688
+ return true;
689
+ }
690
+
654
691
  async function onCoverClick(id, el) {
655
692
  if (busy) return;
656
693
  const found = bookById(id);
657
694
  if (!found || found.book.filler) return;
695
+ if (leaveForHub(found.book)) return;
658
696
  await openBookFlow(found.book, found.cat, el, { fromExpanded: false, asSpine: false });
659
697
  }
660
698
 
661
699
  async function onSpineClick(id, el, ev) {
662
700
  if (busy) return;
663
701
  const found = bookById(id);
664
- if (!found) return;
665
- if (found.book.filler || !found.book.cover) return;
702
+ if (!found || found.book.filler) return;
703
+ if (leaveForHub(found.book)) return;
704
+ if (!found.book.cover) return;
666
705
 
667
706
  const isTouch = ev && (ev.pointerType === "touch" || ("ontouchstart" in window && window.matchMedia("(hover: none)").matches));
668
707
 
@@ -686,7 +725,20 @@ export function mountBookshelf(root, options = {}) {
686
725
  $("#noteTitle").textContent = book.title;
687
726
  $("#noteAuthor").textContent = book.author || "";
688
727
  const coverEl = $("#noteCover");
689
- coverEl.className = `note-cover ${bkClass(book)} ${coverClass(book)}`;
728
+ // Intrinsic <img>, not the .img-* background. That background is
729
+ // background-size:cover inside a fixed 2:3 frame and clips landscape art.
730
+ coverEl.className = "note-cover";
731
+ coverEl.style.backgroundImage = "none";
732
+ coverEl.style.backgroundColor = "transparent";
733
+ coverEl.replaceChildren();
734
+ if (book.cover) {
735
+ const img = document.createElement("img");
736
+ img.className = "note-cover-img";
737
+ img.alt = "";
738
+ img.draggable = false;
739
+ img.src = coverSrc(book);
740
+ coverEl.appendChild(img);
741
+ }
690
742
  const note = NOTES[book.note];
691
743
  const read = $("#noteRead");
692
744
  if (read) {
@@ -969,6 +1021,42 @@ export function mountBookshelf(root, options = {}) {
969
1021
  return { offset, ...pathPose(transform) };
970
1022
  }
971
1023
 
1024
+ // Close samples. T0 still carries the separate sx/sy height pin.
1025
+ // The old first stop was Tp3 (only 25% of the way home) at 0.28, so the
1026
+ // book was still nearly full-size for the first ~1s and read as overlap.
1027
+ function returnMoveKeys(Tfull, Tp1, T0) {
1028
+ return [
1029
+ pathKey(0, Tfull),
1030
+ pathKey(0.12, lerpTS(Tfull, T0, 0.35)),
1031
+ pathKey(0.32, lerpTS(Tfull, T0, 0.62)),
1032
+ pathKey(0.52, Tp1),
1033
+ pathKey(0.74, T0),
1034
+ pathKey(0.88, T0),
1035
+ pathKey(1, T0),
1036
+ ];
1037
+ }
1038
+
1039
+ // Note stays out until the flyer leaves, then a short handoff.
1040
+ // Rising from 0.65 while the flyer stayed solid until 0.90 was the double exposure.
1041
+ function noteInKeys() {
1042
+ return [
1043
+ { offset: 0, opacity: 0 },
1044
+ { offset: 0.82, opacity: 0 },
1045
+ { offset: 0.90, opacity: 1 },
1046
+ { offset: 1, opacity: 1 },
1047
+ ];
1048
+ }
1049
+
1050
+ // Article is gone by ~0.12 (230ms). Shrink has already started on frame 0.
1051
+ function noteOutKeys() {
1052
+ return [
1053
+ { offset: 0, opacity: 1 },
1054
+ { offset: 0.05, opacity: 0.35 },
1055
+ { offset: 0.12, opacity: 0 },
1056
+ { offset: 1, opacity: 0 },
1057
+ ];
1058
+ }
1059
+
972
1060
  // ux14 geometric core: ONE diagonal arc slot-center ↔ screen-center.
973
1061
  // X, Y, AND scale on the SAME beat — ban L-path (lift-only then slide-only).
974
1062
  // Transforms are lerpTS(T0→Tfull) samples so WAAPI stays collinear in tx/ty/s.
@@ -1279,6 +1367,7 @@ export function mountBookshelf(root, options = {}) {
1279
1367
  "is-expanded",
1280
1368
  "is-collapsing"
1281
1369
  );
1370
+ clearSeatCover(el);
1282
1371
  // Explicit removeProperty — kills commitStyles leftovers like opacity:0 / transform
1283
1372
  [
1284
1373
  "width",
@@ -1324,6 +1413,9 @@ export function mountBookshelf(root, options = {}) {
1324
1413
 
1325
1414
  const board = document.createElement("div");
1326
1415
  board.className = "flyer-board";
1416
+ // ux27: never a solid shell behind the jacket. CSS also forces this off.
1417
+ board.style.opacity = "0";
1418
+ board.style.visibility = "hidden";
1327
1419
 
1328
1420
  const left = document.createElement("div");
1329
1421
  left.className = "flyer-leaf flyer-leaf-left";
@@ -1401,7 +1493,6 @@ export function mountBookshelf(root, options = {}) {
1401
1493
  left.style.transform = "rotateY(180deg)";
1402
1494
  right.style.transform = "rotateY(0deg)";
1403
1495
  right.style.opacity = "0";
1404
- board.style.opacity = "0";
1405
1496
  leftThick.style.opacity = "0";
1406
1497
  rightThick.style.opacity = "0";
1407
1498
  bookEl.style.transform = "rotateX(8deg) translate3d(0,0,0)";
@@ -1475,13 +1566,9 @@ export function mountBookshelf(root, options = {}) {
1475
1566
  noteView.classList.add("is-preparing");
1476
1567
  noteView.classList.remove("is-open");
1477
1568
  noteView.style.opacity = "0";
1478
- const coverEl = $("#noteCover");
1479
- const bg = coverEl && getComputedStyle(coverEl).backgroundImage;
1480
- const m = bg && bg.match(/url\(["']?([^"')]+)["']?\)/);
1481
- if (m) {
1569
+ const img = $("#noteCover") && $("#noteCover").querySelector(".note-cover-img");
1570
+ if (img && !coverImgReady(img)) {
1482
1571
  try {
1483
- const img = new Image();
1484
- img.src = m[1];
1485
1572
  const DECODE_MS = 400;
1486
1573
  const decodeOrLoad = img.decode
1487
1574
  ? img.decode()
@@ -1528,6 +1615,7 @@ export function mountBookshelf(root, options = {}) {
1528
1615
  busy = true;
1529
1616
  const myFlow = ++flowSeq;
1530
1617
  document.body.classList.add("is-busy");
1618
+ root.classList.remove("is-return");
1531
1619
  openBook = { book, cat, asSpine, fromExpanded: !!fromExpanded, openSeatRect: null };
1532
1620
  openFromExpanded = !!fromExpanded;
1533
1621
 
@@ -1568,6 +1656,11 @@ export function mountBookshelf(root, options = {}) {
1568
1656
  sourceEl.style.transition = "none";
1569
1657
  sourceEl.style.transform = "none"; // kill :hover translateY / is-expanded lift
1570
1658
  sourceEl.classList.add("is-extracting"); // CSS: suppress :hover transform while extracting
1659
+ // Keep the real jacket on the seat until the flyer exists. Dropping
1660
+ // is-expanded used to reveal the bk-* fill for the frames before the flyer.
1661
+ if (sourceEl.classList.contains("spine") && book && book.cover) {
1662
+ mountSeatCover(sourceEl, book);
1663
+ }
1571
1664
  if (fromExpanded) {
1572
1665
  sourceEl.classList.remove("is-expanded", "is-collapsing");
1573
1666
  // Drop the expanded cover's width. Do NOT drop a resting spine's height:
@@ -1722,14 +1815,14 @@ export function mountBookshelf(root, options = {}) {
1722
1815
  { offset: 0, opacity: 0 },
1723
1816
  { offset: 0.14, opacity: 0 },
1724
1817
  { offset: 0.22, opacity: 1 },
1725
- { offset: 0.90, opacity: 1 },
1726
- { offset: 0.96, opacity: 0.35 },
1818
+ { offset: 0.82, opacity: 1 },
1819
+ { offset: 0.90, opacity: 0 },
1727
1820
  { offset: 1.0, opacity: 0 },
1728
1821
  ]
1729
1822
  : [
1730
1823
  { offset: 0, opacity: 1 },
1731
- { offset: 0.90, opacity: 1 },
1732
- { offset: 0.96, opacity: 0.35 },
1824
+ { offset: 0.82, opacity: 1 },
1825
+ { offset: 0.90, opacity: 0 },
1733
1826
  { offset: 1.0, opacity: 0 },
1734
1827
  ],
1735
1828
  opts
@@ -1755,6 +1848,7 @@ export function mountBookshelf(root, options = {}) {
1755
1848
  const aExtractCoverIn = null;
1756
1849
 
1757
1850
  // Pose fade on SAME layer while diagonal continues — ban origin-swapping phase.
1851
+ // Board stays out: fading it in painted a wide shell behind the cover by ~1350ms.
1758
1852
  const closedFaceKf = [
1759
1853
  { offset: 0, opacity: 0 },
1760
1854
  { offset: 0.28, opacity: 0 },
@@ -1762,7 +1856,11 @@ export function mountBookshelf(root, options = {}) {
1762
1856
  { offset: 0.52, opacity: 1 },
1763
1857
  { offset: 1.0, opacity: 1 },
1764
1858
  ];
1765
- const aBoard = dual._board ? dual._board.animate(closedFaceKf, opts) : null;
1859
+ if (dual._board) {
1860
+ dual._board.style.opacity = "0";
1861
+ dual._board.style.visibility = "hidden";
1862
+ }
1863
+ const aBoard = null;
1766
1864
  const aRightFace = dual._right.animate(
1767
1865
  [
1768
1866
  { offset: 0, opacity: 0, transform: "rotateY(0deg)" },
@@ -1803,20 +1901,9 @@ export function mountBookshelf(root, options = {}) {
1803
1901
  )
1804
1902
  : null;
1805
1903
 
1806
- // §12: note main rise offset 0.70–0.92 (≥1.80s clear). FORBIDDEN: ≥0.95 at 0.34.
1807
- const aNote = noteView.animate(
1808
- [
1809
- { offset: 0, opacity: 0 },
1810
- { offset: 0.65, opacity: 0 },
1811
- { offset: 0.70, opacity: 0.15 },
1812
- { offset: 0.78, opacity: 0.55 },
1813
- { offset: 0.85, opacity: 0.90 },
1814
- { offset: 0.90, opacity: 0.99 },
1815
- { offset: 0.92, opacity: 1 },
1816
- { offset: 1.0, opacity: 1 },
1817
- ],
1818
- opts
1819
- );
1904
+ // Note stays at 0 while the flyer owns the flight, then hands off by unlock (0.90).
1905
+ // FORBIDDEN: note body readable while the flyer is still solid (double exposure).
1906
+ const aNote = noteView.animate(noteInKeys(), opts);
1820
1907
 
1821
1908
  const aBack = openBackdrop
1822
1909
  ? openBackdrop.animate(
@@ -1961,6 +2048,9 @@ export function mountBookshelf(root, options = {}) {
1961
2048
  // Clock first. Shelf measure, freeze, and rAF used to run before this and
1962
2049
  // the 0.12 Tfull keyframe then held 672×504 for ~240ms. Stash from open is
1963
2050
  // enough to start the return; height pin is already in those samples.
2051
+ // is-return puts the stage above the note so the shrink is not hidden
2052
+ // under an opaque article (that read as ~1s of overlap, then a jump).
2053
+ root.classList.add("is-return");
1964
2054
  const closeMotion = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
1965
2055
  const closeOpts = { duration: CLOSE_MS, easing: "linear", fill: "forwards" };
1966
2056
  const stashEarly = openBook.pathTs;
@@ -1982,29 +2072,21 @@ export function mountBookshelf(root, options = {}) {
1982
2072
  heldEarly.style.visibility = "visible";
1983
2073
  heldEarly.style.zIndex = "70";
1984
2074
  heldEarly.style.opacity = "1";
2075
+ if (heldEarly._board) {
2076
+ heldEarly._board.style.opacity = "0";
2077
+ heldEarly._board.style.visibility = "hidden";
2078
+ }
1985
2079
  promote(heldEarly);
1986
2080
  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
- ],
2081
+ returnMoveKeys(stashEarly.Tfull, stashEarly.Tp1, stashEarly.T0),
1996
2082
  closeMotion
1997
2083
  );
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
- );
2084
+ earlyNote = noteView.animate(noteOutKeys(), closeOpts);
2085
+ // Backup if the fade is dropped: the article must not still be up after ~230ms.
2086
+ wait(Math.round(CLOSE_MS * 0.12)).then(() => {
2087
+ if (myFlow !== flowSeq || !noteView) return;
2088
+ noteView.style.opacity = "0";
2089
+ });
2008
2090
  if (heldEarly._left) {
2009
2091
  earlyLeft = heldEarly._left.animate(
2010
2092
  [
@@ -2058,6 +2140,7 @@ export function mountBookshelf(root, options = {}) {
2058
2140
  try { earlyNote && earlyNote.cancel(); } catch (_) {}
2059
2141
  try { earlyLeft && earlyLeft.cancel(); } catch (_) {}
2060
2142
  try { earlyRight && earlyRight.cancel(); } catch (_) {}
2143
+ root.classList.remove("is-return");
2061
2144
  noteView.classList.remove("is-open", "is-preparing");
2062
2145
  noteView.hidden = true;
2063
2146
  noteView.style.opacity = "";
@@ -2081,7 +2164,10 @@ export function mountBookshelf(root, options = {}) {
2081
2164
  ].forEach((p) => {
2082
2165
  try { slot.style.removeProperty(p); } catch (_) {}
2083
2166
  });
2084
- slot.classList.remove("is-collapsing", "is-placeholder", "is-expanded");
2167
+ slot.classList.remove("is-collapsing", "is-expanded");
2168
+ // Keep the seat empty until handover. Dropping is-placeholder here let the
2169
+ // bk-* fill paint a solid rectangle under the returning cover.
2170
+ slot.classList.add("is-placeholder");
2085
2171
  expandedId = null;
2086
2172
  // Spines: re-pin the resting face BEFORE reading the seat. A cleared inline
2087
2173
  // height is the shorter size-class box, and put-back would keep it.
@@ -2116,13 +2202,13 @@ export function mountBookshelf(root, options = {}) {
2116
2202
  });
2117
2203
  shelfView.classList.add("is-frozen");
2118
2204
 
2119
- // ux13: prepare shelf+flyer UNDER opaque note — ban route hard-cut then mid-air resume.
2120
- // Keep note is-open + opacity 1 until reverse clock starts.
2205
+ // Note stays in the tree so its fade can finish, but must not cover the flyer.
2206
+ // is-return (set before the clock) owns stacking. Do not pin z-index 75.
2121
2207
  noteView.classList.add("is-open");
2122
2208
  noteView.classList.remove("is-preparing");
2123
2209
  noteView.hidden = false;
2124
- noteView.style.opacity = "1";
2125
- noteView.style.zIndex = "75";
2210
+ if (!earlyNote) noteView.style.opacity = "1";
2211
+ noteView.style.zIndex = "";
2126
2212
 
2127
2213
  unparkShelf();
2128
2214
  shelfView.style.opacity = "1";
@@ -2217,7 +2303,10 @@ export function mountBookshelf(root, options = {}) {
2217
2303
  flyer._right.style.transform = "rotateY(-8deg)";
2218
2304
  flyer._right.style.opacity = "1";
2219
2305
  }
2220
- if (flyer._board) flyer._board.style.opacity = "1";
2306
+ if (flyer._board) {
2307
+ flyer._board.style.opacity = "0";
2308
+ flyer._board.style.visibility = "hidden";
2309
+ }
2221
2310
  if (flyer._leftThick) flyer._leftThick.style.opacity = "1";
2222
2311
  if (flyer._rightThick) flyer._rightThick.style.opacity = "1";
2223
2312
  if (flyer._gutter) flyer._gutter.style.opacity = "1";
@@ -2280,29 +2369,13 @@ export function mountBookshelf(root, options = {}) {
2280
2369
 
2281
2370
  resetVeil();
2282
2371
 
2283
- // ux13: note fades ON the book layer while reverse shrink starts — ban instant note unmount.
2284
- const aNoteOut = earlyNote || noteView.animate(
2285
- [
2286
- { offset: 0, opacity: 1 },
2287
- { offset: 0.06, opacity: 0.85 },
2288
- { offset: 0.12, opacity: 0.45 },
2289
- { offset: 0.22, opacity: 0 },
2290
- { offset: 1.0, opacity: 0 },
2291
- ],
2292
- closeOpts
2293
- );
2372
+ // Note fades out in the first ~230ms. Shrink already started on frame 0.
2373
+ const aNoteOut = earlyNote || noteView.animate(noteOutKeys(), closeOpts);
2294
2374
 
2295
- // Shrink starts at offset 0. The old 0.12 Tfull key was ~228ms parked at 672×504.
2375
+ // Shrink starts at offset 0 and is already well under way by ~400ms.
2376
+ // A first stop on Tp3 (25% home) at 0.28 left the book looking full for ~1s.
2296
2377
  const aFlyerMove = earlyMove || flyer.animate(
2297
- [
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),
2305
- ],
2378
+ returnMoveKeys(Tfull, Tp1, T0),
2306
2379
  closeMotion
2307
2380
  );
2308
2381
  // Reverse hinge on SAME layer while shrink (open was 0.32→0.68 hinge).
@@ -2336,7 +2409,7 @@ export function mountBookshelf(root, options = {}) {
2336
2409
  { offset: 0.28, opacity: 0 },
2337
2410
  { offset: 1.0, opacity: 0 },
2338
2411
  ];
2339
- const aBoardClose = flyer._board ? flyer._board.animate(closedFaceOut, closeOpts) : null;
2412
+ const aBoardClose = null;
2340
2413
  const aThickLClose = flyer._leftThick ? flyer._leftThick.animate(closedFaceOut, closeOpts) : null;
2341
2414
  const aThickRClose = flyer._rightThick ? flyer._rightThick.animate(closedFaceOut, closeOpts) : null;
2342
2415
  const aGutterClose = flyer._gutter
@@ -2377,9 +2450,11 @@ export function mountBookshelf(root, options = {}) {
2377
2450
  const aFlyerFade = flyer.animate(
2378
2451
  targetMode === "spines"
2379
2452
  ? [
2453
+ // Stay through the shrink. Cutting at 0.58 dropped a still-large
2454
+ // cover and made the first ~1.1s look like overlap, then a pop.
2380
2455
  { offset: 0, opacity: 1 },
2381
- { offset: 0.50, opacity: 1 },
2382
- { offset: 0.58, opacity: 0 },
2456
+ { offset: 0.68, opacity: 1 },
2457
+ { offset: 0.80, opacity: 0 },
2383
2458
  { offset: 1.0, opacity: 0 },
2384
2459
  ]
2385
2460
  : [
@@ -2411,10 +2486,10 @@ export function mountBookshelf(root, options = {}) {
2411
2486
  ? spineSlotClose.animate(
2412
2487
  [
2413
2488
  { offset: 0, transform: outClose, opacity: 0 },
2414
- { offset: 0.50, transform: outClose, opacity: 0 },
2415
- { offset: 0.58, transform: outClose, opacity: 1 },
2416
- { offset: 0.78, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2417
- { offset: 0.90, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2489
+ { offset: 0.66, transform: outClose, opacity: 0 },
2490
+ { offset: 0.74, transform: outClose, opacity: 1 },
2491
+ { offset: 0.86, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2492
+ { offset: 0.94, transform: "translate3d(0px, 0px, 0)", opacity: 1 },
2418
2493
  { offset: 1.0, transform: "translate3d(0px, 0px, 0)", opacity: 0 },
2419
2494
  ],
2420
2495
  closeMotion
@@ -2429,8 +2504,8 @@ export function mountBookshelf(root, options = {}) {
2429
2504
  targetMode === "spines"
2430
2505
  ? [
2431
2506
  { offset: 0, opacity: 1 },
2432
- { offset: 0.50, opacity: 1 },
2433
- { offset: 0.58, opacity: 0 },
2507
+ { offset: 0.68, opacity: 1 },
2508
+ { offset: 0.80, opacity: 0 },
2434
2509
  { offset: 1, opacity: 0 },
2435
2510
  ]
2436
2511
  : [
@@ -2452,6 +2527,13 @@ export function mountBookshelf(root, options = {}) {
2452
2527
  slot.style.backgroundColor = "";
2453
2528
  }
2454
2529
 
2530
+ // Seat may show its real face only as the flyer hands off. Until then the
2531
+ // placeholder ban (transparent, invisible) blocks the solid bk plate.
2532
+ wait(Math.round(CLOSE_MS * Math.max(0, slotInAt - 0.02))).then(() => {
2533
+ if (myFlow !== flowSeq || !slot.isConnected) return;
2534
+ slot.classList.remove("is-placeholder");
2535
+ });
2536
+
2455
2537
  const aSlotIn = slot.animate(
2456
2538
  [
2457
2539
  { offset: 0, opacity: 0 },
@@ -2510,6 +2592,7 @@ export function mountBookshelf(root, options = {}) {
2510
2592
  flyer.style.opacity = "0";
2511
2593
  slot.style.opacity = "1";
2512
2594
  slot.style.transform = "none";
2595
+ root.classList.remove("is-return");
2513
2596
  noteView.classList.remove("is-open", "is-preparing");
2514
2597
  noteView.hidden = true;
2515
2598
  noteView.style.opacity = "";
@@ -4,9 +4,12 @@ export { Bookshelf } from "./Bookshelf";
4
4
  export { mountBookshelf } from "./engine";
5
5
  export type {
6
6
  BookshelfBook,
7
+ BookshelfBookPost,
7
8
  BookshelfFiller,
8
9
  BookshelfNote,
10
+ BookshelfNoteKind,
9
11
  BookshelfProps,
10
12
  BookshelfShelf,
11
13
  BookshelfSize,
14
+ BookshelfSource,
12
15
  } from "./types";
@@ -7,6 +7,7 @@ export class BookshelfConfigError extends Error {
7
7
  }
8
8
 
9
9
  const SIZES = new Set(["thin", "s-short", "s-mid", "s-tall"]);
10
+ const NOTE_KINDS = new Set(["overview", "excerpt"]);
10
11
 
11
12
  function record(value, code, message) {
12
13
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new BookshelfConfigError(code, message);
@@ -31,6 +32,71 @@ function parseSize(value, where) {
31
32
  throw new BookshelfConfigError("size", `${where} 的 size 只能是 thin、s-short、s-mid 或 s-tall。`);
32
33
  }
33
34
 
35
+ /** Same path identity as the shelf loader: drop content/posts and .md. */
36
+ function postKey(value) {
37
+ return value.trim().replaceAll("\\", "/").replace(/^(?:content\/)?posts\//u, "").replace(/\.md$/u, "");
38
+ }
39
+
40
+ function parsePostEntry(item, where, index) {
41
+ if (typeof item === "string") {
42
+ const path = optionalString(item);
43
+ if (!path) throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 必须是路径。`);
44
+ return { path };
45
+ }
46
+ const entry = record(item, "posts", `${where} 的 posts[${index}] 必须是路径或映射。`);
47
+ const path = optionalString(entry.path);
48
+ if (!path) throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 需要 path。`);
49
+ const kind = optionalString(entry.kind);
50
+ if (kind && !NOTE_KINDS.has(kind)) {
51
+ throw new BookshelfConfigError("posts", `${where} 的 posts[${index}] 的 kind 只能是 overview 或 excerpt。`);
52
+ }
53
+ return kind ? { path, kind } : { path };
54
+ }
55
+
56
+ /**
57
+ * `post` is shorthand for one note. `posts` is the list.
58
+ * When `posts` is set it is the list, and `post` may only repeat the first path.
59
+ */
60
+ function parsePostList(book, where) {
61
+ const single = optionalString(book.post);
62
+ if (book.posts == null) {
63
+ return single ? { post: single, posts: [{ path: single }] } : { post: undefined, posts: undefined };
64
+ }
65
+ if (!Array.isArray(book.posts)) {
66
+ throw new BookshelfConfigError("posts", `${where} 的 posts 必须是路径数组。`);
67
+ }
68
+ const posts = book.posts.map((item, index) => parsePostEntry(item, where, index));
69
+ if (posts.length === 0) throw new BookshelfConfigError("posts", `${where} 的 posts 不能为空。`);
70
+ const seenPaths = new Set();
71
+ for (const entry of posts) {
72
+ const key = postKey(entry.path);
73
+ if (seenPaths.has(key)) throw new BookshelfConfigError("posts", `${where} 的 posts 重复引用「${entry.path}」。`);
74
+ seenPaths.add(key);
75
+ }
76
+ if (single && postKey(single) !== postKey(posts[0].path)) {
77
+ throw new BookshelfConfigError("posts", `${where} 的 post 与 posts 的第一篇不是同一路径。多篇笔记只写 posts。`);
78
+ }
79
+ return { post: posts[0].path, posts };
80
+ }
81
+
82
+ function parseSources(value, where) {
83
+ if (value == null) return undefined;
84
+ if (!Array.isArray(value)) throw new BookshelfConfigError("sources", `${where} 的 sources 必须是数组。`);
85
+ if (value.length === 0) return undefined;
86
+ return value.map((item, index) => {
87
+ const source = record(item, "sources", `${where} 的 sources[${index}] 必须是映射。`);
88
+ const label = optionalString(source.label);
89
+ const href = optionalString(source.href);
90
+ if (!label || !href) {
91
+ throw new BookshelfConfigError("sources", `${where} 的 sources[${index}] 需要 label 和 href。`);
92
+ }
93
+ if (!/^(?:https?:\/\/|\/\/|\/(?!\/))/iu.test(href)) {
94
+ throw new BookshelfConfigError("sources", `${where} 的 sources[${index}] 只接受 http(s) 或以 / 开头的地址。`);
95
+ }
96
+ return { label, href };
97
+ });
98
+ }
99
+
34
100
  function parseBook(value, where, seen) {
35
101
  const book = record(value, "book", `${where} 必须是映射。`);
36
102
  const id = optionalString(book.id);
@@ -38,6 +104,7 @@ function parseBook(value, where, seen) {
38
104
  if (!id || !title) throw new BookshelfConfigError("book", `${where} 需要 id 和 title。`);
39
105
  if (seen.has(id)) throw new BookshelfConfigError("duplicate-id", `${where} 的 id「${id}」重复。`);
40
106
  seen.add(id);
107
+ const notes = parsePostList(book, where);
41
108
  return {
42
109
  id,
43
110
  title,
@@ -47,7 +114,9 @@ function parseBook(value, where, seen) {
47
114
  cover: optionalString(book.cover),
48
115
  color: optionalString(book.color),
49
116
  filler: parseFiller(book.filler, where),
50
- post: optionalString(book.post),
117
+ post: notes.post,
118
+ posts: notes.posts,
119
+ sources: parseSources(book.sources, where),
51
120
  note: typeof book.note === "string" && book.note.trim() ? book.note.trim() : undefined,
52
121
  };
53
122
  }
@@ -11,7 +11,9 @@ export type BookshelfConfigCode =
11
11
  | "size"
12
12
  | "filler"
13
13
  | "cover"
14
- | "post";
14
+ | "post"
15
+ | "posts"
16
+ | "sources";
15
17
 
16
18
  export interface BookshelfConfigError extends Error {
17
19
  readonly code: BookshelfConfigCode;
@@ -23,18 +25,54 @@ export const BookshelfConfigError = ConfigError as unknown as new (
23
25
  message: string,
24
26
  ) => BookshelfConfigError;
25
27
 
28
+ /** `overview` is the reading note (总览). `excerpt` is the quotation note (摘抄). */
29
+ export type BookshelfNoteKind = "overview" | "excerpt";
30
+
31
+ /** One note bound to a volume. A string in yaml is shorthand for `{ path }`. */
32
+ export type BookshelfFilePost = {
33
+ /** Path under `content/posts/`, without `.md`. */
34
+ path: string;
35
+ kind?: BookshelfNoteKind;
36
+ };
37
+
38
+ /** Outbound source on the book card. Rendered as a quiet chip, not a button. */
39
+ export type BookshelfFileSource = {
40
+ label: string;
41
+ href: string;
42
+ };
43
+
26
44
  /**
27
- * One volume in `content/bookshelf.yaml`.
45
+ * One volume in `content/bookshelf.yaml`. One yaml entry is one spine.
46
+ *
47
+ * Covers stay on two paths:
48
+ * - yaml `cover` is the book jacket (Douban). It is the only image for the
49
+ * shelf face, the open/close flyer, and the book-hub card.
50
+ * - a post's front matter `cover` is that note's hero on the article page.
51
+ * Never copy a note hero into the jacket slot.
52
+ * - omit yaml `cover` and the jacket falls back to the first note's cover.
28
53
  *
29
- * Site contract (what a blog drops in to replace the package demo):
30
- * - `post` is a path under `content/posts/`, without `.md`. The shelf renders that
31
- * post as the open-book note (front matter `description` is the short summary).
32
- * - `cover` is either a full URL (`https://…`, `//…`, or a site path starting
33
- * with `/`) or a file name. A file name is published at `/media/covers/<file>`,
34
- * the same URL `content/covers/` gets when a post's `cover` names that file.
35
- * Omit `cover` to reuse the post's own cover.
54
+ * Notes:
55
+ * - `post: path` is shorthand for `posts: [path]`.
56
+ * - `posts` is the note list, in order. `kind: overview | excerpt` labels the
57
+ * hub card 总览 or 摘抄. A bare string is a path with no kind.
58
+ * - when `posts` is set, `post` is optional. If both are set, `post` must be
59
+ * the same path as `posts[0]`. Otherwise the shelf is rejected.
60
+ * - one note: the shelf opens that note as it does today (extract flyer, note,
61
+ * return flyer). There is no book hub.
62
+ * - two or more notes: a spine click leaves the shelf for `/bookshelf/<id>`
63
+ * (本书). It does not open `posts[0]` and it does not play the flyer.
64
+ * The hub card is the locked book-multi layout: the jacket slot is 148px
65
+ * wide, the image fills that width, and the height follows the file's ratio.
66
+ * There is no plate behind the jacket. A Douban scan's white border is
67
+ * cropped in the jacket file before it is committed; the build copies that
68
+ * file into `/media/covers/` unchanged.
69
+ * 「我的笔记」 lists the notes. A note card is an ordinary link to that
70
+ * article; article ↔ list does not play the flyer. The flyer runs only when
71
+ * a single-note book is extracted from the shelf and put back.
72
+ * - `sources` are optional `{ label, href }` chips. The hub shows 豆瓣 and PDF
73
+ * only. The PDF chip carries the caption 「(仅分享,侵权删)」. No chapter list.
74
+ * - `note` is Markdown used only when the book has no posts.
36
75
  * - `color` (`#rgb` or `#rrggbb`) is the spine cloth and the open-page palette.
37
- * - `note` is Markdown used only when `post` is absent.
38
76
  */
39
77
  export type BookshelfFileBook = {
40
78
  id: string;
@@ -42,10 +80,14 @@ export type BookshelfFileBook = {
42
80
  author?: string;
43
81
  spine?: string;
44
82
  size?: BookshelfSize;
83
+ /** Book jacket. File name under `content/covers/`, or a full URL. */
45
84
  cover?: string;
46
85
  color?: string;
47
86
  filler?: BookshelfFiller;
87
+ /** Shorthand for a single note. Equal to `posts[0].path` after parsing. */
48
88
  post?: string;
89
+ posts?: BookshelfFilePost[];
90
+ sources?: BookshelfFileSource[];
49
91
  note?: string;
50
92
  };
51
93