JavaScript Architecture

JavaScriptvanilla JSmodules

Complete documentation of the client-side JavaScript in /Users/weixiangzhang/Local_Dev/projects/bythewei/src/pages/index.astro.


1. Script Architecture

The page contains two <script> blocks, both IIFEs (Immediately Invoked Function Expressions), positioned after all HTML markup and before the <style> block.

Script Block 1 — Main Application (lines 288-1111)

<script>
  (function () {
    // ~820 lines: QOTD, Catalog, Journal, Reading Year, modals, event handling
  })();
</script>
  • Type: Standard Astro client-side <script> (no special directives).
  • Pattern: Single top-level IIFE wrapping all application logic.
  • Scope: All variables and functions are private to the closure. Nothing leaks to window.
  • Subsystems: QOTD fetch, Bookshelf Catalog, Reading Journal, Reading Year Modal, global Escape handler.

Script Block 2 — Kaomoji Rotation Engine (lines 1117-1146)

<script define:vars={{ kaomojis }}>
  (function () {
    // ~28 lines: date-seeded rotation of kaomoji faces
  })();
</script>
  • Type: Astro <script> with define:vars directive.
  • define:vars={{ kaomojis }}: Injects the server-side kaomojis array (imported from ../data/kaomojis.ts in the frontmatter) as a serialized JS variable available inside the script at runtime.
  • Pattern: Separate IIFE, completely independent from Script Block 1.
  • Why separate: It needs define:vars to receive the kaomoji dataset from the Astro build step. The main script block does not use define:vars.

Summary Table

BlockLinesDirectivePatternPurpose
1288-1111(none)IIFEAll interactive features
21117-1146define:varsIIFEKaomoji daily rotation

2. Date-Seeded Randomization (hashPick / hash)

Both script blocks implement the same deterministic pseudo-random system. Every visitor on the same calendar day sees the same selections worldwide. Selections rotate automatically at midnight with no deploy required.

Seed Calculation

const d = new Date();
const seed = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();

This produces a unique integer per calendar day (e.g., 20260219 for Feb 19, 2026). The formula encodes YYYYMMDD as a plain integer.

Hash Function (Knuth multiplicative hash variant)

Both blocks use the same algorithm, named hashPick in Block 1 and hash in Block 2:

function hashPick(arr, offset) {
  let x = (seed * 2654435761 + offset * 2246822519) >>> 0;   // Knuth golden ratio constants
  x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0;           // Mix bits (MurmurHash3 finalizer)
  x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0;           // Second round
  return arr[(x ^ (x >>> 16)) % arr.length];                 // Index into array
}
  • 2654435761: Knuth’s multiplicative hash constant (closest prime to 2^32 / phi).
  • 2246822519: Secondary prime for offset mixing.
  • 0x45d9f3b: MurmurHash3 finalizer constant — two rounds of xor-shift-multiply for avalanche.
  • >>> 0: Forces unsigned 32-bit integer (prevents negative indices).

QOTD Selection (Block 1)

const pool = entries.filter(e => e.highlights && e.highlights.length > 30);
const entry = hashPick(pool, 9999);
  • Filters for entries with highlight text longer than 30 characters.
  • Uses offset 9999 (arbitrary fixed constant) to select one entry per day.
  • The same quote shows all day for all visitors.

Kaomoji Selection (Block 2)

Uses a deduplication layer on top of the hash:

const used = new Set();

function pick(slotIndex) {
  let attempt = 0, idx;
  do {
    idx = hash(seed, slotIndex * 97 + attempt) % total;
    attempt++;
  } while (used.has(idx) && attempt < total);
  used.add(idx);
  return kaomojis[idx];
}
  • Each [data-kaomoji] element gets a unique kaomoji (no repeats within a page load).
  • slotIndex * 97 spreads the hash inputs to avoid clustering.
  • The attempt counter resolves collisions by probing the next hash value.
  • Falls through after total attempts (safety valve — practically never hit with 200 kaomojis and ~12 slots).

3. Data Loading

bookmarks.clean.json (eager fetch)

fetch('/data/bookmarks.clean.json')
  .then(r => r.json())
  .then(entries => {
    allEntries = entries;
    // ... populate QOTD ...
  })
  .catch(() => { /* fallback message */ });
  • Timing: Fetched immediately on page load (inside the IIFE, not gated by any user action).
  • Storage: Cached in the closure variable allEntries (module-level array).
  • Dual purpose: Powers both QOTD (immediate) and Catalog/Journal (on demand).
  • Error handling: Sets loading text to a fallback kaomoji message.

File location: /Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/bookmarks.clean.json

reading-log.json (lazy fetch)

function openReading() {
  readingModal.hidden = false;
  document.body.style.overflow = 'hidden';
  if (!readingBuilt) {
    fetch('/data/reading-log.json')
      .then(r => r.json())
      .then(books => buildReadingYear(books))
      .catch(() => { readingLoading.textContent = 'failed to load reading data (CVV_CV)'; });
  }
}
  • Timing: Fetched only on first open of the Reading Year modal.
  • Guard: readingBuilt flag prevents re-fetching on subsequent opens.
  • No caching variable: The data flows directly into buildReadingYear() which builds the DOM; the raw array is not stored.

File location: /Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/reading-log.json


4. Modal System

Three modals exist, each following the same pattern but with slight variations.

ModalIDTriggerZ-order
Catalogcatalog-modal#shelf-trigger (hidden pin button)Base
Reading Journaljournal-modalSpine click in Catalog / QOTD bookAbove Catalog
Reading Yearreading-modal#reading-trigger (sticky button)Independent

Open Pattern

Every modal open function follows:

function openX() {
  xModal.hidden = false;
  document.body.style.overflow = 'hidden';   // scroll lock
  // optional: lazy-build content
}

Close Pattern

function closeX() {
  xModal.hidden = true;
  document.body.style.overflow = '';          // restore scroll
  // optional: save state (Journal saves scroll position)
}

Shared Behaviors

  • Backdrop click: Each modal listens for clicks on the .modal-backdrop itself (not children) to close.
    catalogModal.addEventListener('click', e => { if (e.target === catalogModal) closeCatalog(); });
  • Close button: Each has a .modal-close button with a dedicated listener.
  • Body scroll lock: document.body.style.overflow = 'hidden' on open, restored to '' on close.

Escape Key Handling (Global)

document.addEventListener('keydown', e => {
  if (e.key !== 'Escape') return;
  if (!journalModal.hidden) closeJournal();
  else if (!readingModal.hidden) { reviewTip.hidden = true; closeReading(); }
  else if (!catalogModal.hidden) closeCatalog();
});

Priority order: Journal > Reading Year > Catalog. This handles the stacking case where Journal opens on top of Catalog — pressing Escape closes Journal first, then a second press closes Catalog.

The Reading Year close also explicitly hides the reviewTip tooltip.

The Catalog-to-Journal transition is the only stacking case:

// Spine click in catalog:
spine.addEventListener('click', () => {
  closeCatalog(false);                    // false = don't restore scroll yet
  setTimeout(() => openJournal(...), 200); // 200ms delay for visual transition
});

// Journal back button:
journalBack.addEventListener('click', () => { closeJournal(); openCatalog(true); });

The closeCatalog(restoreScroll) parameter controls whether body.overflow is restored:

  • false when transitioning to Journal (keeps scroll locked).
  • true (default) when closing to the main page.

5. Catalog (Bookshelf)

groupBooks() — Title Normalization and Grouping

normTitle()

function normTitle(t) {
  return (t ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 40);
}
  • Lowercases the title.
  • Strips all non-alphanumeric characters (handles punctuation, spaces, unicode).
  • Truncates to 40 characters (prevents long-title key bloat).
  • Purpose: Deduplicates entries that have the same book with slightly different title formatting or author names.

groupBooks()

function groupBooks() {
  const books = {};
  for (const e of allEntries) {
    if (!e.highlights || e.highlights.length < 10) continue;     // skip trivial entries
    const key = normTitle(e.book_title);
    if (!books[key]) {
      books[key] = { title: e.book_title, author: e.author, entries: [] };
    } else {
      // Keep the longer/more complete author name
      if ((e.author || '').length > (books[key].author || '').length) {
        books[key].author = e.author;
      }
    }
    books[key].entries.push(e);
  }
  return Object.values(books).sort((a, b) => b.entries.length - a.entries.length);
}
  • Groups by normalized title (title-only key, ignoring author variants).
  • Filters out entries with highlights shorter than 10 characters.
  • When merging, keeps the longest author name (handles cases like “Nghi Vo” vs “Nghi Vo (Author)”).
  • Returns sorted by annotation count descending (most-highlighted books first).

spineWidth(count) — Width Encoding

function spineWidth(count) {
  if (count <= 2)  return 40;
  if (count <= 10) return 48;
  if (count <= 25) return 56;
  if (count <= 60) return 64;
  return 72;
}

Maps annotation count to pixel width in 5 tiers. More highlights = wider spine = more visual prominence on the shelf.

spineColors(count) — Color Encoding

function spineColors(count) {
  if (count === 0)  return ['#f0ece0', '#bbb6a8'];   // ghost: cream
  if (count <= 2)   return ['#e8d5b5', '#8a7a62'];   // ivory
  if (count <= 10)  return ['#c4956a', '#fff8ef'];   // warm tan
  if (count <= 25)  return ['#a0522d', '#ffe8d0'];   // sienna
  if (count <= 60)  return ['#7a3020', '#ffd4b8'];   // deep rust
  return ['#4a1a1a', '#f0c090'];                      // near-black mahogany
}

Returns [background, text-color] tuple. The progression goes from light cream (barely annotated) to dark mahogany (heavily read). Six tiers total.

spineGold(count) — Gold Shimmer

function spineGold(count) { return count >= 26; }

Books with 26+ annotations get a spine-gold CSS class (presumably a shimmer/glow effect defined in CSS).

Shelf Row Packing Algorithm

const SHELF_WIDTH = bookcase.clientWidth || 1060;
const SPINE_GAP   = 2;

const rows = [];
let currentRow = [];
let rowWidth   = 0;

for (const book of books) {
  const w = spineWidth(book.entries.length) + SPINE_GAP;
  if (rowWidth + w > SHELF_WIDTH && currentRow.length > 0) {
    rows.push(currentRow);
    currentRow = [];
    rowWidth   = 0;
  }
  currentRow.push(book);
  rowWidth += w;
}
if (currentRow.length) rows.push(currentRow);
  • Algorithm: Greedy first-fit bin packing, left to right.
  • SHELF_WIDTH: Measured from the actual bookcase element width (fallback 1060px).
  • SPINE_GAP: 2px between spines.
  • Books are already sorted by annotation count (descending), so the most-highlighted books appear on the top shelf.
  • Each row is rendered as a .shelf-row containing .shelf-spines and a .shelf-plank divider.

Tooltip System

A single tooltip element (#spine-tooltip) is shared across all spines and repositioned on hover.

showTooltip(spine, event)

function showTooltip(spine, e) {
  document.getElementById('st-title').textContent  = spine.dataset.title;
  document.getElementById('st-author').textContent = spine.dataset.author;
  const count = parseInt(spine.dataset.count, 10);
  const max   = parseInt(spine.dataset.maxCount, 10);
  document.getElementById('st-count').textContent  = `${count} mark${count !== 1 ? 's' : ''}`;
  document.getElementById('st-bar').style.width    = `${Math.round((count / max) * 100)}%`;
  document.getElementById('st-teaser').textContent = spine.dataset.teaser;
  spineTooltip.hidden = false;
  positionTooltip(e);
}

Populates five fields from data-* attributes: title, author, count (with pluralization), a proportional bar (scaled to maxCount = 91), and a teaser quote.

hideTooltip()

Simply sets spineTooltip.hidden = true.

positionTooltip(event)

function positionTooltip(e) {
  if (spineTooltip.hidden) return;
  const tw = spineTooltip.offsetWidth  || 240;
  const th = spineTooltip.offsetHeight || 120;
  let x = e.clientX - tw / 2;
  let y = e.clientY - th - 16;
  x = Math.max(8, Math.min(window.innerWidth  - tw - 8, x));
  y = Math.max(8, Math.min(window.innerHeight - th - 8, y));
  spineTooltip.style.left = `${x}px`;
  spineTooltip.style.top  = `${y}px`;
}
  • Centers horizontally on cursor.
  • Places above cursor with 16px gap.
  • Clamps to viewport edges with 8px margin.
  • Follows mouse via mousemove listener on each spine.

Spine Event Bindings

spine.addEventListener('mouseenter', (e) => showTooltip(spine, e));
spine.addEventListener('mouseleave', hideTooltip);
spine.addEventListener('mousemove',  (e) => positionTooltip(e));
spine.addEventListener('click', () => {
  closeCatalog(false);
  setTimeout(() => openJournal(book.title, book.author), 200);
});

6. Reading Journal

openJournal(bookTitle, author)

function openJournal(bookTitle, author) {
  buildJournal(bookTitle, author);
  journalModal.hidden = false;
  journalPages.scrollTop = 0;
  document.body.style.overflow = 'hidden';
}

Called from: spine click (Catalog), QOTD book title button.

buildJournal(bookTitle, author) — Page Rendering

  1. Filters allEntries by normalized title match (same normTitle() dedup).
  2. Sets header (title + author).
  3. For each entry, renders:
    • Entry number: Zero-padded two-digit index.
    • Date: From the entry’s date field.
    • Text: Split into a bold “first sentence” (via firstSentence()) and the remainder.
    • Dog-ear button: Toggle for bookmarking individual passages.

firstSentence(text)

function firstSentence(text) {
  const m = text.match(/^.{20,}?[.!?]["'\u201D]?\s/);
  return m ? m[0].trim() : text.slice(0, 120);
}

Matches the first sentence (minimum 20 chars, ending at a sentence-terminal punctuation mark optionally followed by a closing quote). Falls back to first 120 characters.

Dog-ear localStorage Persistence

function dogKey(title, i) {
  return `de-${title.replace(/\W/g,'').slice(0,20)}-${i}`;
}
  • Key format: de-{sanitized-title-20chars}-{index}.
  • Toggle behavior: clicking the dog-ear button adds/removes a localStorage entry.
  • Visual: adds/removes dog-eared class on the entry element.
  • Side effect: calls buildTOC() to refresh the TOC panel (dogeared items get a pin icon).

TOC Panel

function buildTOC(entries, bookTitle) {
  tocList.innerHTML = '';
  entries.forEach((e, i) => {
    const dogeared = localStorage.getItem(dogKey(bookTitle, i));
    const tocItem = document.createElement('button');
    tocItem.className = `toc-item${dogeared ? ' toc-dogeared' : ''}`;
    tocItem.textContent = `${dogeared ? '(pin) ' : ''}${i + 1}. ${(e.date ?? '').slice(0, 10) || '---'}`;
    tocItem.addEventListener('click', () => {
      document.getElementById(`je-${i}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
      tocPanel.hidden = true;
    });
    tocList.appendChild(tocItem);
  });
}
  • Toggleable via #toc-toggle button (tocPanel.hidden = !tocPanel.hidden).
  • Each item scrolls to the corresponding entry with smooth animation.
  • Dogeared entries are visually distinguished with a pin prefix and toc-dogeared class.
  • Clicking a TOC item auto-closes the TOC panel.

Ribbon Scroll Indicator

journalPages.addEventListener('scroll', () => {
  const pct = journalPages.scrollTop / (journalPages.scrollHeight - journalPages.clientHeight);
  journalRibbon.style.height = `${Math.min(100, pct * 100)}%`;
});

A vertical ribbon that grows as you scroll through the journal, acting as a progress indicator along the spine.

Scroll Position Persistence

  • Save: On closeJournal(), saves journalPages.scrollTop to localStorage key js-scroll-{title-20chars}.
  • Restore: On buildJournal(), reads and restores the saved scroll position.

7. Reading Year Modal

buildReadingYear(books) — Full Breakdown

Called once (guarded by readingBuilt flag) when the modal first opens. Receives the parsed reading-log.json array.

Stats Strip

readingStats.innerHTML = [
  [`${books.length}`, 'books'],
  [`${totalPages.toLocaleString()}`, 'pages'],
  [`${avgRating}`, 'avg rating'],
  [`${fiveStars}`, 'five-stars'],
  [`${genres}`, 'genres'],
].map(([v, l]) => `<div class="rs-card"><strong>${v}</strong><span>${l}</span></div>`).join('');

Five stat cards: total books, total pages, average rating, five-star count, genre count.

Monthly Wall

  • Groups books by YYYY-MM key from date_finished.
  • Sorts month keys chronologically.
  • Each month column (.wall-month) contains:
    • A stack (.wall-stack) of colored mini-blocks (.wall-mini), one per book.
    • Each mini is colored by genre (genreColor(b.genre)).
    • Each mini has a small random rotation (Math.random() * 6 - 3 degrees) for a hand-placed aesthetic.
    • Title attribute: "Book Title --- (star rating)" for native tooltip on hover.
    • A month label (e.g., “Feb ‘19”) and count.

Genre Bars

  • Counts books per genre, sorts descending.
  • Renders horizontal bar chart rows.
  • Bar width: (count / maxGenre * 100)%.
  • Bar color: from genreColor() mapping.

Diversity Bars

Two stacked bar sections:

  1. Gender: Proportional flexbox segments for F/M/N.
  2. Authors of Color: Proportional flexbox segments for yes/no.

Each segment shows the label and count.

Emotion Receipt

  • Aggregates emotions arrays from all books (normalizes “Angry” to “Anger”).
  • Renders as a receipt-style monospaced list.
  • Includes:
    • Sorted emotion/count rows.
    • NET EMOTION: POSITIVE or NEGATIVE based on majority of emotional_output field.
    • TOTAL PAGES and TOTAL BOOKS summary.
    • A .receipt-tear div at the bottom for visual effect.

Top Books (“The Fives”)

  • Filters for 5-star books, sorted by page count descending.
  • Shows up to 20 entries.
  • Books with reviews get top-clickable class and a click handler for the review tooltip.

Book List with Search Filtering

blSearch.addEventListener('input', () => {
  const q = blSearch.value.toLowerCase().trim();
  booklistEl.querySelectorAll('.bl-row').forEach(row => {
    const b = titleToRow[row.dataset.title]?.book;
    const haystack = [
      b.title, b.author, b.genre, b.fiction,
      b.review || '', b.gender === 'F' ? 'female woman' : b.gender === 'M' ? 'male man' : 'nonbinary',
      b.poc ? 'poc' : '',
    ].join(' ').toLowerCase();
    row.hidden = q.length > 0 && !haystack.includes(q);
  });
});
  • Full-text search across title, author, genre, fiction type, review text, expanded gender terms, and POC flag.
  • Simple String.includes() matching (case-insensitive).
  • Real-time filtering on every keystroke.

Bidirectional Hover Highlighting

highlightViz(book) — From Book List Row or Wall Mini

function highlightViz(b) {
  // Dim all wall-minis except matching; highlight matching
  readingWall.querySelectorAll('.wall-mini').forEach(m => {
    m.classList.toggle('wall-dim', !m.title.startsWith(b.title));
    if (m.title.startsWith(b.title)) m.classList.add('wall-highlight');
  });
  // Highlight matching genre bar
  genreBars.querySelectorAll('.bar-row').forEach(r => {
    const label = r.querySelector('.bar-label');
    r.classList.toggle('bar-active', label && label.textContent === b.genre);
  });
  // Highlight matching gender segment
  // Highlight matching POC segment
}

Cross-highlights across four visualizations simultaneously: monthly wall, genre bars, gender bars, and POC bars.

clearHighlights()

Removes all wall-dim, wall-highlight, bar-active, div-bar-active, and bl-active classes across all visualizations.

Direction 1: Book List Row -> Visualizations

row.addEventListener('mouseenter', () => {
  row.classList.add('bl-active');
  highlightViz(b);
});
row.addEventListener('mouseleave', () => {
  row.classList.remove('bl-active');
  clearHighlights();
});

Direction 2: Wall Mini -> Book List

mini.addEventListener('mouseenter', () => {
  if (wallLocked) return;
  const bookTitle = mini.title.split(' --- ')[0];
  const match = titleToRow[bookTitle];
  if (match) {
    match.row.classList.add('bl-active');
    highlightViz(match.book);
  }
  // Dim other minis, highlight this one
});

Uses titleToRow lookup map (title string -> { row, book }) for O(1) reverse lookup.

Wall-Mini Click: Lock + Scroll

mini.addEventListener('click', () => {
  const bookTitle = mini.title.split(' --- ')[0];
  const match = titleToRow[bookTitle];
  if (!match) return;

  clearHighlights();
  match.row.classList.add('bl-active');
  highlightViz(match.book);
  // ... dim/highlight wall minis ...

  // Scroll book list panel to the matching row
  const listContainer = document.getElementById('reading-booklist');
  const rowTop = match.row.offsetTop - listContainer.offsetTop;
  listContainer.scrollTo({ top: rowTop - 40, behavior: 'smooth' });

  // Lock hover for 4 seconds
  wallLocked = true;
  setTimeout(() => {
    wallLocked = false;
    clearHighlights();
  }, 4000);
});
  • Clicking a wall mini “locks” the highlight state.
  • Scrolls the book list panel (not the page) to show the matching row.
  • wallLocked = true suppresses hover interactions for 4 seconds.
  • After timeout, clears all highlights and re-enables hover.

Review Tooltip System

Two sources can trigger the review tooltip:

  1. Top Books panel (top-clickable items).
  2. Book List panel (bl-clickable rows).

Both share the same dynamically-created tooltip element:

const reviewTip = document.createElement('div');
reviewTip.className = 'review-tooltip';
reviewTip.hidden = true;
reviewTip.innerHTML = '<div class="rt-title"></div><div class="rt-meta"></div><div class="rt-body"></div>';
document.body.appendChild(reviewTip);

Toggle behavior: Clicking the same book again hides the tooltip. Clicking a different book repositions it.

Positioning: Near the clicked element, clamped to viewport edges. Attempts below the element first, flips above if insufficient space.

Dismissal: Global click listener closes the tooltip when clicking outside both the tooltip itself and any clickable book row.

Genre Color Mapping

const GENRE_COLORS = {
  'Fantasy': '#4ecdc4',          'History': '#5dade2',
  'Contemporary': '#f0b847',     'Autobiography': '#bb8fce',
  'Technology Studies': '#5499c7','Productivity': '#f5b041',
  'Race Studies': '#ec7063',     'Science Fiction': '#48c9b0',
  'Philosophy': '#af7ac5',      'Sociology': '#f1948a',
  'Psychology': '#f7dc6f',      'Mythology': '#45b39d',
  'Historical Fiction': '#e59866','Medical': '#76d7c4',
  'Graphic Novel': '#aed6f1',   'Poetry': '#d2b4de',
  'Horror': '#cd6155',
};
function genreColor(g) { return GENRE_COLORS[g] || '#8B6914'; }

17 named genre colors with a brown/gold fallback for unmapped genres.


8. Kaomoji Rotation Engine (Script Block 2)

How Daily Rotation Works

  1. At build time, Astro imports kaomojis from ../data/kaomojis.ts (200 entries, categorized).
  2. define:vars={{ kaomojis }} serializes the array into the client script.
  3. On page load, the IIFE calculates today’s seed and replaces every [data-kaomoji] element’s text content.
  4. A Set tracks used indices to guarantee no duplicate kaomojis on the same page.

Kaomoji Dataset Structure

File: /Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/kaomojis.ts

export const kaomojis: string[] = [
  // 200 entries across 12 categories:
  // HAPPY / WHOLESOME (20)
  // EXCITED / CELEBRATING (20)
  // SURPRISED / SHOCKED (20)
  // ANGRY / TABLE FLIP (20)
  // SAD / CRYING (20)
  // COOL / SMUG / UNBOTHERED (20)
  // FIGHTING / DETERMINED (20)
  // LOVE / AFFECTIONATE (20)
  // SHRUG / WHATEVER (10)
  // ANIMALS (15)
  // MAGIC / SPARKLE (10)
  // TIRED / DONE (10)
  // RUNNING / CHAOS (10)
];

Approximately 200 kaomojis covering the full emotional spectrum. Categories are comments only — the array is flat at runtime.

Slot Assignment

document.querySelectorAll('[data-kaomoji]').forEach(function (el, i) {
  el.textContent = pick(i);
});

Each [data-kaomoji] element in the HTML has a default kaomoji in the Astro template (e.g., (|(*^_^*)|)). These are server-rendered defaults that get replaced client-side. The i parameter (DOM order index) determines which kaomoji each slot receives for that day.


9. Helper Functions

escHtml(s)

function escHtml(s) {
  return (s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

Basic HTML entity escaping. Null-safe via ??. Used everywhere user data is interpolated into innerHTML.

stars(n)

function stars(n) { return '(filled star)'.repeat(n) + '(empty star)'.repeat(5 - n); }

Generates a 5-star rating string from an integer (e.g., stars(3) returns 3 filled + 2 empty stars).

genderBadge(g)

function genderBadge(g) {
  const map = { F: ['F','bl-badge-f'], M: ['M','bl-badge-m'], N: ['N','bl-badge-n'] };
  const [label, cls] = map[g] || ['?',''];
  return `<span class="bl-badge ${cls}">${label}</span>`;
}

Returns an HTML badge span with gender-specific styling. Maps F/M/N to labeled, classed badges.

pocBadge(poc)

function pocBadge(poc) {
  return poc ? `<span class="bl-badge bl-badge-poc">POC</span>` : '';
}

Returns a “POC” badge or empty string.

firstSentence(text)

function firstSentence(text) {
  const m = text.match(/^.{20,}?[.!?]["'\u201D]?\s/);
  return m ? m[0].trim() : text.slice(0, 120);
}

Extracts the first sentence for the bold lead in journal entries.

getTeaser(entries)

function getTeaser(entries) {
  const e = [...entries]
    .sort((a, b) => a.highlights.length - b.highlights.length)
    .find(e => e.highlights.length > 40);
  const t = e?.highlights ?? '';
  return t.length > 100 ? t.slice(0, 97).trimEnd() + '...' : t;
}

Finds the shortest meaningful highlight (>40 chars) for spine tooltip teasers. Truncates to 100 chars with ellipsis.

genreColor(g)

function genreColor(g) { return GENRE_COLORS[g] || '#8B6914'; }

Lookup with brown/gold fallback.

normTitle(t)

function normTitle(t) {
  return (t ?? '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 40);
}

Title normalization for dedup key generation.

dogKey(title, i)

function dogKey(title, i) {
  return `de-${title.replace(/\W/g,'').slice(0,20)}-${i}`;
}

localStorage key for dog-ear state.


10. Event Handling

Direct Binding (element-specific listeners)

ElementEventHandler
#shelf-triggerclickopenCatalog()
#catalog-closeclickcloseCatalog()
#catalog-modalclickClose if target is backdrop itself
#journal-backclickcloseJournal(); openCatalog(true)
#journal-closeclickcloseJournal()
#journal-modalclickClose if target is backdrop itself
#journal-pagesscrollUpdate ribbon progress
#toc-toggleclickToggle TOC panel visibility
#reading-triggerclickopenReading()
#reading-closeclickcloseReading()
#reading-modalclickClose if target is backdrop itself
#qotd-bookclickopenJournal() with book data
#bl-searchinputFilter book list rows
documentkeydownGlobal Escape handler (priority: J > R > C)
documentclickClose review tooltip on outside click

Per-Element Binding (generated elements)

Element ClassEventHandler
.book-spinemouseentershowTooltip()
.book-spinemouseleavehideTooltip()
.book-spinemousemovepositionTooltip()
.book-spineclickClose catalog, open journal after 200ms delay
.je-dogearclickToggle dog-ear state + rebuild TOC
.toc-itemclickScroll to entry + close TOC panel
.bl-rowmouseenterHighlight row + highlightViz()
.bl-rowmouseleaveClear all highlights
.bl-clickableclickToggle review tooltip
.wall-minimouseenterHighlight matching book row + viz (if not locked)
.wall-minimouseleaveClear highlights (if not locked)
.wall-miniclickLock highlights + scroll book list to match
.top-clickableclickToggle review tooltip

Delegation vs Direct

The codebase uses exclusively direct binding — no event delegation. Every dynamically created element gets its own addEventListener call at creation time. This is viable because:

  • The element count is bounded (150 books, ~200 bookmarks).
  • Elements are created once and never destroyed/recreated.

11. State Management

All state lives in closure variables within the main IIFE. Nothing is exposed globally.

Closure Variables

VariableTypePurpose
allEntriesArrayAll bookmark entries from bookmarks.clean.json
catalogBuiltBooleanGuard: prevents rebuilding the bookshelf on re-open
readingBuiltBooleanGuard: prevents re-fetching/rebuilding Reading Year
wallLockedBooleanSuppresses wall-mini hover during 4-second lock period
titleToRowObjectMap of book title -> { row: HTMLElement, book: Object }
reviewTipElementThe shared review tooltip DOM element
reviewTimeoutVariableDeclared but unused (likely leftover from earlier iteration)
ROTATIONSArrayCSS rotation classes ['r1'..'r7'] (declared, not used in current code)
GENRE_COLORSObjectGenre name -> hex color mapping
MONTHSArrayShort month names for wall labels

localStorage Keys

PatternPurpose
de-{title20}-{index}Dog-ear state for a journal passage
js-scroll-{title20}Journal scroll position per book

Build Guards

Both catalogBuilt and readingBuilt follow the same pattern:

  1. Start as false.
  2. Checked at the top of the build function.
  3. Set to true before any DOM rendering.
  4. Never reset — the built content persists for the page session.

Data Flow

Page Load
  |
  v
fetch bookmarks.clean.json --> allEntries (closure)
  |                               |
  v                               v
QOTD (hashPick)            Catalog (on demand)
                                  |
                                  v
                            Journal (on spine click)

User clicks "150 books" sticky
  |
  v
fetch reading-log.json --> buildReadingYear()
  |
  v
Stats + Wall + Genres + Diversity + Receipt + Top Books + Book List

Appendix: File Map

FilePurpose
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/pages/index.astroMain page: HTML + 2 script blocks + styles
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/kaomojis.ts200 kaomojis in 12 categories
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/bookmarks.clean.jsonKindle/reading highlights (title, author, highlights, date)
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/reading-log.json150 books with rating, genre, gender, poc, emotions, review, pages
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/data/sprint.jsonSprint board content (meta, stats, columns, stickies)
/Users/weixiangzhang/Local_Dev/projects/bythewei/src/layouts/Layout.astroBase layout wrapper