diff --git a/.agents/skills/close-read-sheet/SKILL.md b/.agents/skills/close-read-sheet/SKILL.md new file mode 100644 index 0000000..5007d82 --- /dev/null +++ b/.agents/skills/close-read-sheet/SKILL.md @@ -0,0 +1,102 @@ +--- +name: close-read-sheet +description: Create a guided-learning Close Read scrollytelling sheet (data/*.json) from a base text reference (e.g. "Pirkei Avot 2:5"), a Sefaria source sheet (URL or numeric ID), or a Sefaria topic. Use whenever the user asks to "build a Close Read on …", "make a guided learning of …", "turn this sheet into a scrolly", "create a close-read for [Mishnah/parsha/sugya/topic]", or similar. Produces a fully populated data/.json plus an index.json entry, verified in the browser. +--- + +# Close Read sheet generator + +This skill turns a piece of Torah/Mishnah/Talmud — or a Sefaria source sheet or topic — into a scrollytelling **Close Read** sheet (`data/.json`) that runs on the engine in this repo. + +## When to invoke + +The user gives one of four inputs: + +1. **A base text reference** — e.g. `Pirkei Avot 2:5`, `Genesis 22:1-19`, `Berakhot 7a:14`, `Mishnah Berakhot 1:1`. +2. **A Sefaria source sheet** — a URL like `https://www.sefaria.org/sheets/160545` or a bare numeric ID. +3. **A Sefaria topic** — e.g. `Akedah`, `Prayer`, `Tochacha`. +4. **A parsha name with "Nechama" framing** — e.g. "Nechama on Beha'alotcha", "Nechama Leibowitz for Shelach", "build the Nasso experience". This is the multi-decade-rendering Mode 4 — see `references/pedagogical-arcs.md`. + +Output: a complete `data/.json` and an entry appended to `data/index.json`. Verify in the browser before reporting done. + +For Mode 4 (Nechama parsha), there is also a research pack at +`research/parshiyot/.md` — produce it as the Stage-1/2 deliverable and **stop for +user confirmation before harvesting** all selected gilyonot. + +## Required reading before you start + +Open these in this order. They're short: + +- [docs/data-format.md](../../../docs/data-format.md) — full schema for the JSON +- [docs/conversion-guide.md](../../../docs/conversion-guide.md) — practical conversion recipe +- [docs/architecture.md](../../../docs/architecture.md) — how the engine renders the JSON, including comparison mode and verse-change semantics +- [references/pedagogical-arcs.md](references/pedagogical-arcs.md) — how to design the 4–5 section arc for each input mode +- [references/hard-rules.md](references/hard-rules.md) — non-negotiable rules: Hebrew cleaning, word-group constraints, source-color mapping, when to use comparison vs inline quote + +Canonical examples to imitate (read at least one before drafting): + +- [data/pirkei-avot-1-11.json](../../../data/pirkei-avot-1-11.json) — five sub-sections sharing a single pinned Mishnah; commentators old-to-new; Hasidic + modern closing +- [data/yitro-5730.json](../../../data/yitro-5730.json) — three sections including one comparison-mode pair of biblical verses +- [data/shemini-5731.json](../../../data/shemini-5731.json) — two sections drilling into one verse from multiple angles + +## Workflow + +### Phase 1 — Fetch source material + +Use the Sefaria MCP tools (`get_text`, `get_links_between_texts`, `get_topic_details`, `get_english_translations`, `text_search`). For source sheets the MCP may not have a direct tool. Fall back to `curl -sL -A "Sefaria/close-read" https://www.sefaria.org/api/sheets/`, or Python `urllib` with the same header, rather than `WebFetch`, whose User-Agent we don't control. `Sefaria/` is the in-house User-Agent convention. Never send a bare `Mozilla/5.0`: Cloudflare challenges it with a 403. + +What to grab depends on the input mode: + +- **Base text ref:** the text in both languages; `get_links_between_texts` to discover available commentary; then fetch each commentary you plan to use. +- **Source sheet:** the sheet JSON (sources, author commentary, structure). The sheet's *order* and *highlighting* are usable directly — the author has already done the pedagogical sequencing. +- **Topic:** `get_topic_details` for the anchor texts; then fetch each anchor text and the most-cited commentaries for it. + +### Phase 2 — Design the arc, then **stop and confirm with the user** + +Before writing 30+ steps of content, present: + +- Title, subtitle (English; Hebrew if the corpus supports it) +- Section titles in order (typically 3–5) +- Per section: primary text, the 2–3 commentators or sources you'll use, and the question that closes the section +- Total step estimate + +Use `AskUserQuestion` if there's a meaningful design decision (which commentators to feature, whether to include a Hasidic/modern voice, how dense the sheet should be, whether to address the original Hebrew or stay English-only). + +See [references/pedagogical-arcs.md](references/pedagogical-arcs.md) for the arc templates for each input mode. + +### Phase 3 — Write the JSON + +Build the file in one pass using a template based on the closest canonical example. Hold to these invariants: + +- **Hebrew text must be cantillation-stripped, nikkud-preserved.** Python recipe in [references/hard-rules.md](references/hard-rules.md). Texts from `Torat Emet 357` (Mishnah/Avot) already lack cantillation; Tanakh versions from `Miqra according to the Masorah` need stripping. +- **Word groups within one verse cannot overlap.** Section A might split a Mishnah into 8 phrase-keys; sub-sections that re-use the same `primaryText` MUST use compatible (non-overlapping) word maps. The engine wraps once and reuses across the group. +- **Consecutive sections that share `primaryText.ref` + `he` collapse into one sticky panel.** The user reads the same text continuously while the sidebar advances through sub-sections. This is the preferred shape for a deep dive on a single base text. +- **Short prooftexts (e.g. a single biblical verse quoted by a commentary) stay inline within the commentary card or its annotation.** Do not use `verse-change` to take over the main panel for them. +- **`verse-change` is for substantive parallel/contrast texts** the user will study at length. Use sparingly. Always return to the base text before the next section ends. +- **Comparison mode is for short verse pairs only** (biblical pasuk vs pasuk). Do not use it for full mishnayot or long passages — it cramps the laptop layout. For parallel mishnayot, embed the second one as a commentary card. +- **Closing section, when the corpus allows it,** carries the idea forward through one Hasidic voice (Likutei Moharan, Toldot Yaakov Yosef, Ba'al Shem Tov) and one modern voice (Rav Uziel's Hegyonei Uziel, Rav Kook, Nechama Leibowitz, etc.). Two cards plus framing narration. +- **Source-label colors:** existing CSS tokens are `Rashi`, `Ramban`, `Ibn Ezra`, `Bereshit Rabbah` / `Aggadat Bereishit` / `Tanchuma` (midrash purple), `Mekhilta`, `Talmud`. Other sources fall back to neutral warm-brown. Don't add CSS tokens in this skill — keep it data-only. +- **Narration voice:** didactic guide. The narrator names the puzzles; the commentaries do the work. Don't moralize. Don't summarize commentaries before quoting them. +- **English-only is fine for narration/annotation.** If the corpus has a strong Hebrew tradition (Mishnah, Tanakh, Talmud) keep the primary text and direct commentary quotations bilingual; the rest can be English-only. The engine's title/section/subtitle/source-label/annotation/narration fields all gracefully omit the `.he` field if absent. + +### Phase 4 — Register and verify + +1. Append a new entry to `data/index.json` with `{slug, title, author, description}`. Author is the original Sefaria author/anthologist when there is one (e.g. for a sheet); otherwise the text's tradition (e.g. `"Avtalyon"` for Avot 1:11) or omit if unclear. +2. Validate the JSON parses: `python3 -c "import json; json.load(open('data/.json'))"`. +3. Validate every word-group Hebrew phrase actually occurs in its verse text — script in [references/hard-rules.md](references/hard-rules.md). +4. Start the preview server (via `preview_start` against the launch config) and load `?sheet=`. Use `preview_eval` to confirm: + - The sheet renders (no `undefined` in DOM) + - All sections appear (check `.cr-section` count and `.section-dot` count) + - Primary panel stays pinned across same-text sub-sections (sample `panelVisible` at several scroll positions) + - Highlights fire on a card with a `highlight` array (check `.word-group.highlighted`) + - No errors in console (`preview_console_logs` filtered to `error`) + +### Phase 5 — Report + +Tell the user the slug, the section structure, and the URL `?sheet=`. Note any commentaries you considered but dropped, and why. + +## Things to skip + +- Adding new CSS color tokens. Use the fallback for un-tokenized sources. Mention it as a follow-up if a sheet leans heavily on a single un-tokenized commentator. +- Modifying the engine. The data format is expressive; if you find yourself wanting a new step type, the answer is almost always to use existing types differently. +- Long Hebrew narration cards. The engine renders them but reading bilingual narration alongside primary text overloads the eye. Hebrew belongs in the primary panel and in direct commentary quotations. +- Adding Hebrew titles when none are natural. Section titles can be English-only (`{ "en": "..." }`). diff --git a/.agents/skills/close-read-sheet/references/hard-rules.md b/.agents/skills/close-read-sheet/references/hard-rules.md new file mode 100644 index 0000000..7b7734e --- /dev/null +++ b/.agents/skills/close-read-sheet/references/hard-rules.md @@ -0,0 +1,158 @@ +# Hard rules — what the engine does and does not tolerate + +Read this once before writing your first card. + +## Hebrew text cleaning + +The engine renders `he` fields as-is into RTL HTML. Cantillation marks distract; nikkud is essential. + +**Strip cantillation, keep nikkud.** Unicode ranges to strip: + +- U+0591 – U+05AF (most cantillation) +- U+05BD (meteg, when it's a cantillation marker) +- U+05C0 (paseq) +- U+05C3 (sof pasuq, ׃) +- U+05C6 (nun hafukha) + +Keep U+05B0 – U+05BC (sheva, nikkud, dagesh). + +Python recipe: + +```python +import re +def strip_cantillation(text): + return re.sub(r'[֑-ֽ֯׀׃׆]', '', text) +``` + +Which Sefaria versions need stripping: + +| Version | Cantillation present? | +|---|---| +| `Torat Emet 357` (Mishnah, Avot) | No — use as-is | +| `Tanach with Nikkud` | No | +| `Miqra according to the Masorah` | **Yes — strip** | +| `Tanach with Ta'amei Hamikra` | **Yes — strip** | +| `Tanach with Text Only` | No (also has no nikkud, less ideal) | + +Commentary texts (Rashi, Rambam, Bartenura, etc.) usually have no cantillation. The Vilna edition Rashi may include some markers in the verse-headers (the bold-formatted lemma fragments) — strip those. + +## Word groups within a verse + +The engine wraps each phrase in ``. Two rules: + +1. **No overlapping phrases within the same verse's `words` map.** If `chachamim` is `חֲכָמִים` (one word) and `chachamim-hizaharu` is `חֲכָמִים, הִזָּהֲרוּ` (two words), the second wrap will collide with the existing span around the first. Result: one of them silently fails to wrap, and its highlight will not render. Pick *one* granularity per phrase. + +2. **English phrases must be unique substrings within the verse's English.** The wrap uses a global regex; if the phrase occurs twice, both get wrapped — and the highlight will hit both, which is almost always wrong. Test before relying on a generic phrase like `"the children"` or `"and die"`. + +For comparison-mode word maps, add `"side": "left"` or `"side": "right"` to disambiguate phrases that appear on both sides. + +## Sections sharing a primary text + +The engine collapses consecutive sections whose `primaryText.ref` and `he` are identical into one sticky panel. This is the default smooth-continuity behavior — usually what you want. + +When sections share `primaryText`, their `words` maps are **merged into a union** for the single rendered panel. Therefore: + +- Same key across sections must have the same `he` value. If section A defines `mayim-raim: וְתִגְלוּ לִמְקוֹם מַיִם הָרָעִים` and section B redefines `mayim-raim: לִמְקוֹם מַיִם הָרָעִים`, the union takes whichever wins the object-merge — which is hard to predict. Just keep them consistent. +- Don't introduce a wider key in a later section that overlaps an existing narrower key (the overlap rule above). If section A has `chachamim: חֲכָמִים` and section E wants to highlight `חֲכָמִים, הִזָּהֲרוּ בְדִבְרֵיכֶם` together, the right move is to give section E a *highlight array* of `["chachamim", "hizaharu-bedivreikhem"]`, not a new wider key. + +## Source labels and colors + +The engine routes `source` → CSS color via `data-source` attribute. Existing tokens: + +| `source` value | Visual family | +|---|---| +| `Rashi` | warm brown (the default Rashi token) | +| `Ramban` | green | +| `Ibn Ezra` | blue | +| `Bereshit Rabbah`, `Aggadat Bereishit`, `Tanchuma` | midrash purple | +| `Mekhilta` | (neutral) | +| `Talmud` | (neutral) | +| `Abarbanel`, `Shem Olam` | (neutral) | +| *anything else* | neutral warm-brown fallback | + +Don't add new tokens in this skill. If a sheet leans hard on a single un-tokenized commentator (e.g. Bartenura is the workhorse), mention it as a follow-up — the user can add a CSS rule later. + +For `sourceLabel`: +- Use the commentator's Hebrew name when the corpus is bilingual: `{ "he": "רש״י", "en": "Rashi" }` +- English-only label is fine when the corpus is mostly English (modern voices, source-sheet derivations) +- Use the label to disambiguate when the same `source` field carries multiple voices: `"Mekhilta DeRabbi Yishmael"` vs `"Mekhilta DeRashbi"` both with `source: "Mekhilta"` + +## When to use comparison mode vs inline quotation + +| Situation | Format | +|---|---| +| Two short biblical verses in direct tension (e.g. Ex 20:5 vs Deut 24:16) | Comparison mode in `primaryText` | +| One short biblical verse cited by a commentary as a prooftext | Inline within the commentary's `text` or `annotation` | +| A full parallel mishnah or sugya being discussed | Inline as a `commentary` card with its own `sourceLabel` | +| A substantial parallel passage the user will dwell on | `verse-change` to a new single-mode primary, then return | + +Comparison mode crowds the laptop layout when either side runs longer than ~3 short lines. For full mishnayot or paragraphs of Tanakh, always use inline or `verse-change`. + +## When to use verse-change + +- The new text is **substantive** and the user will study it (not just glance at a prooftext) +- The new text is **short enough** to fit comfortably in the sticky panel (one verse, one short paragraph) +- You can return to the base text within the same section + +Counter-cases (use inline instead): +- A one-line prooftext quoted to support a commentary's reading +- A long passage you only want to gesture at +- A text the user will *compare* rather than study (in which case: comparison mode, if both are short enough) + +## Validation script + +Before declaring the sheet done, run this against the JSON: + +```python +import json, re + +d = json.load(open('data/.json')) + +def check_words(verse, label): + text = verse.get('he', '') + sides = {'left': verse.get('left', {}).get('he', ''), + 'right': verse.get('right', {}).get('he', '')} + for key, w in verse.get('words', {}).items(): + phrase = w['he'] + side = w.get('side') + target = sides[side] if side else text + pattern = re.escape(phrase).replace(r'\ ', r'[\s​]+').replace(r'\-', r'[\s־\-]') + if not re.search(pattern, target): + print(f" MISSING in {label}: key={key!r} phrase={phrase!r}") + +def check_en(verse, label): + text = verse.get('en', '') + sides = {'left': verse.get('left', {}).get('en', ''), + 'right': verse.get('right', {}).get('en', '')} + for key, w in verse.get('words', {}).items(): + phrase = w['en'] + side = w.get('side') + target = sides[side] if side else text + n = target.count(phrase) + if n == 0: + print(f" EN MISSING in {label}: key={key!r} phrase={phrase!r}") + elif n > 1: + print(f" EN AMBIGUOUS in {label}: key={key!r} ({n} matches) phrase={phrase!r}") + +for s in d['sections']: + check_words(s['primaryText'], s['id'] + ' primary') + check_en(s['primaryText'], s['id'] + ' primary') + for step in s['steps']: + if step.get('type') == 'verse-change': + check_words(step['newVerse'], s['id'] + ' verse-change ' + step['id']) + check_en(step['newVerse'], s['id'] + ' verse-change ' + step['id']) +print("done") +``` + +If anything prints, fix it before browser verification — most often the issue is a typo in the phrase or an English phrase that occurs twice. + +## Browser verification checklist + +Use `preview_start` then `preview_eval`. Confirm: + +1. `document.querySelectorAll('.cr-section').length` — equals number of section *groups* (consecutive same-primary sections merge into one) +2. `document.querySelectorAll('.section-dot').length` — equals the total number of sections in JSON +3. `document.querySelectorAll('.section-divider').length` — equals total sections minus number of groups (the dividers shown for sub-sections beyond the first in each group) +4. `document.body.innerHTML.includes('undefined')` — must be `false` +5. Scroll to a card with a `highlight` array; verify `document.querySelectorAll('.word-group.highlighted, .word-group.glow, .word-group.pulse')` returns matching word keys +6. `preview_console_logs` filtered to `error` — must return nothing diff --git a/.agents/skills/close-read-sheet/references/pedagogical-arcs.md b/.agents/skills/close-read-sheet/references/pedagogical-arcs.md new file mode 100644 index 0000000..4ddf3e1 --- /dev/null +++ b/.agents/skills/close-read-sheet/references/pedagogical-arcs.md @@ -0,0 +1,120 @@ +# Pedagogical arcs + +Three input modes, three different arc shapes. All three converge on the same JSON output format, but the *design move* differs. + +## Mode 1 — Base text reference + +Use when the user gives a single text reference (e.g. `Pirkei Avot 2:5`, `Genesis 22:1-19`). + +Default arc — **5 sections, ~30–40 steps, all sub-sections sharing one pinned primary text:** + +### Section A — A First Reading + +Phrase-by-phrase plain reading. + +- Primary panel: the base text (single mode) +- Word groups: one per natural clause (5–8 groups) +- Step pattern: + - 1 narration: who said this / where it sits in its corpus (no highlight) + - N narration cards, one per clause, each highlighting the corresponding word group with a careful, **deliberately under-resolved** English rendering + - 1 transition narration (no highlight) naming the 2–3 puzzles the words create + - 1 `question` card listing those puzzles + +### Sections B, C, D — One crux per section + +Each interpretive puzzle becomes its own section. Walk through commentators **oldest to newest** within each section. The primary panel stays pinned — same `primaryText.ref` and `he` as Section A; the engine collapses them. + +- Section title in the sidebar names the puzzle +- 1 intro narration (no highlight) +- 3–5 commentary cards in chronological order. Typical genres: + - **Tannaitic/midrashic layer** (Avot DeRabbi Natan, Mekhilta, Sifrei, Bereshit Rabbah, Tosefta) + - **Talmudic** (Bavli or Yerushalmi sugya) + - **Geonim / Early Rishonim** (Rashi, Rashbam, Ibn Ezra) + - **Later Rishonim** (Rambam, Ramban, Rabbeinu Yonah, Bartenura on Mishnah) + - **Early Acharonim** (Tosafot Yom Tov, Maharal, Shem Olam, Abarbanel) +- Each commentary card gets an `annotation` framing what shifts in this voice +- Optional short prooftexts (a biblical verse, a parallel mishnah) live **inline** within the commentary's text or annotation, NOT as `verse-change` +- 1 closing `question` card per section + +### Section E — The Idea Carried Forward + +Two voices: one Hasidic, one modern. Bridges from ancient to present-day. + +- 1 intro narration (no highlight) +- 1 Hasidic commentary card — Likutei Moharan, Toldot Yaakov Yosef, Ba'al Shem Tov, Sefat Emet, etc. +- 1 modern commentary card — Rav Uziel (Hegyonei Uziel), Rav Kook, Heschel, Nechama Leibowitz, etc. +- 1 synthesis narration: name what changes in each direction (inward vs outward, mystical vs practical, etc.) +- 1 closing `question` aimed at the present-day reader + +Canonical example of this mode: `data/pirkei-avot-1-11.json`. + +## Mode 2 — Sefaria source sheet + +Use when the user gives a sheet URL or numeric ID. + +The sheet's *own* structure becomes the arc backbone. The author has already done the pedagogical sequencing — your job is to render their move through the close_read format. + +- Fetch the sheet JSON (`/api/sheets/`). It contains an ordered list of sources, each with `type` (`source` / `comment` / `outsideText` / `outsideBiText`) and `text` content. +- Group consecutive sources into sections by topical break (often signaled by the author's own `comment` blocks or by `outsideText` headers). +- The sheet usually has 1–3 base verses being explored. Each base verse anchors a section's `primaryText`. +- The author's `comment` blocks become `narration` cards. +- The author's questions (often `outsideText` or formatted with `?`) become `question` cards. +- Each cited source becomes a `commentary` card using its `ref` and the appropriate `sourceLabel`. + +Canonical examples of this mode: `data/yitro-5730.json`, `data/shemini-5731.json`, `data/kedoshim-5731.json`. + +When the sheet is from a known *author tradition* (Nechama Leibowitz, Klitsner, Hirschfield), match the existing per-sheet voice. Nechama sheets are *question-forward*; Klitsner is *literary-analytical*; Hirschfield is *halakhic-overview*. + +## Mode 3 — Sefaria topic + +Use when the user gives a topic slug or name. + +- Use `get_topic_details` to retrieve the anchor texts and the topic description. +- Identify 1–3 *anchor texts* (the most-cited or most-central). These become the `primaryText` of one or more sections. +- Identify thematic angles within the topic. Each angle becomes a section. + +Two common shapes: + +**Shape A — single anchor, multiple angles:** One verse or mishnah is the topic's anchor (e.g. Prayer → Brachot 26b). The sections are different angles: timing, language, intentionality, communal vs private. All sections share `primaryText`. + +**Shape B — multiple anchors, chronological:** The topic has no single anchor (e.g. Akedah → Genesis 22, but also Pirkei Avot 5:3, Brachot 32b, etc.). Each section anchors on a different text. The arc walks chronologically from biblical to rabbinic to medieval. + +For Shape B, sections do NOT collapse (different primary texts), so the section-title cards display prominently between them — as they did before the grouping change. + +Topic mode has the most design freedom. Use `AskUserQuestion` to confirm shape (single anchor vs multi-anchor) and section count before writing. + +## Mode 4 — Nechama parsha (weekly) + +A multi-decade rendering of Nechama Leibowitz's actual gilyonot for one parsha, as a +branching garden. This has graduated into its **own dedicated skill** — see +[`nechama-parsha`](../../nechama-parsha/SKILL.md). Invoke that skill when the user asks +to "build the Nechama flow for [parsha]", "do this week's parsha", "Nechama on [parsha]", +or similar. + +In brief: inventory her sheets (Sefaria user 54380) → cluster into themes (Level 0) → +pick dated gilyonot per theme (Level 1) → render each sheet faithfully as a leaf. Two +artifacts: a research pack (`research/parshiyot/.md`) and `data/.json`. +The worked example is `data/nasso.json`. The editorial guidance (voice, source density, +faithfulness) and the quality/error discipline live in that skill's `references/`. + +--- + +## Crux selection (all modes) + +A "crux" — what becomes a section after the plain reading — is a place where the text's words demand interpretation. Signs you've found a crux: + +- Commentators disagree visibly about it (multiple distinct readings exist) +- A literal reading is troubling, embarrassing, or contradicts a known principle +- The text shifts register (genre / tone / speaker / addressee) mid-sentence +- A word is unexpected, a phrase repeats, or a clause seems missing +- The corpus tradition keeps returning to this spot + +Don't manufacture cruxes. If a base text only has one real interpretive question, use 3 sections (A: plain reading; B: the question; C: forward), not 5. + +## Length calibration + +- Quick read: 3 sections, ~15–20 steps. Single crux. Skip the Carried Forward section. +- Standard: 4 sections, ~25–30 steps. Two cruxes. Carried Forward optional. +- Deep dive: 5 sections, ~35–45 steps. 2–3 cruxes plus Carried Forward. + +Steps that look pedagogically required but bloat the count without value: phrase-by-phrase cards for phrases that aren't doing interpretive work, "tying-the-bow" narration cards, restating the question after a commentator already named it. Cut these. diff --git a/.agents/skills/nechama-parsha/SKILL.md b/.agents/skills/nechama-parsha/SKILL.md new file mode 100644 index 0000000..7b3b207 --- /dev/null +++ b/.agents/skills/nechama-parsha/SKILL.md @@ -0,0 +1,138 @@ +--- +name: nechama-parsha +description: Build a weekly Parsha flow — a branching Close Read scrollytelling sheet grown from Nechama Leibowitz's actual gilyonot on Sefaria. Use whenever the user asks to "build the Nechama flow for [parsha]", "do this week's parsha", "Nechama on [parsha]", "make the [parsha] garden", "build the weekly parsha", or similar. Produces a research pack (audit trail) and a data/.json that runs on the forking-paths engine. This is the repeatable weekly cycle; the worked example is data/bereshit.json (built with the scan-checked, quote-don't-type pipeline). +--- + +# nechama-parsha: build a weekly Parsha flow + +This skill turns **Nechama Leibowitz's gilyonot for one parsha** into a branching +Close Read — a "garden of forking paths" where the reader chooses a theme, then +chooses one of her angles across the decades, then walks that actual sheet with the +verse pinned and her commentators lighting up phrases as her question unfolds. + +The structure grows out of **her** oeuvre: she returned to each parsha many times +over thirty years, clustering around a handful of passages. Those clusters become the +themes; her individual dated sheets become the leaves. + +## The one-sentence philosophy + +> Render *her* work faithfully and densely. The narrator names the puzzle and the +> voice about to speak, then gets out of the way. Everything interpretive comes from +> the sources Nechama herself chose. + +If you remember nothing else: **the default failure mode is a sheet that's too thin — +one commentary per leaf, narrator paraphrase doing the work.** Lean the other way. +See [references/voice-and-fidelity.md](references/voice-and-fidelity.md). + +## What it produces + +Two artifacts per parsha, and the JSON cites the pack: + +1. `research/parshiyot/.md` — the **research pack**: full inventory of her + sheets, the design (themes + chosen gilyonot + chosen cruxes), per-leaf source + notes. This is the audit trail. Every claim about Nechama in the JSON traces here. +2. `data/.json` — the **Close Read** that runs on the engine, plus an entry in + `data/index.json`. + +## Required reading before you start + +- [references/voice-and-fidelity.md](references/voice-and-fidelity.md) — **the heart of + this skill.** Voice, source density, and faithfulness to her material. +- [references/quality-and-errors.md](references/quality-and-errors.md) — thoroughness + and error checking: validation script, browser verification, the engine gotchas. +- [../close-read-sheet/references/hard-rules.md](../close-read-sheet/references/hard-rules.md) + — the engine mechanics (Hebrew cleaning, word-group constraints, source colors). Don't + re-derive these; they're shared with the general close-read-sheet skill. + +The canonical worked example is **Bereshit**: `data/bereshit.json`, the pack +`research/parshiyot/bereshit.md`, the scan checks in `bereshit-scancheck/`, and the leaf +trails in `bereshit-leaves/`. Read the pack's §2 (how Sefaria's digitization departs from her +scans) and `bereshit-leaves/BRIEF.md` (the drafting rules) before building a new parsha. +**Don't copy Nasso.** It predates this pipeline, and some of its question cards are English +paraphrases rather than her questions, which is exactly the fabrication this pipeline exists +to prevent. + +## The source + +Nechama's sheets are on Sefaria as user **54380** (the Gilyonot Nechama collection). + +- All her sheets: `https://www.sefaria.org/api/sheets/user/54380/`. **Keep the trailing + slash.** Without it the server answers with a 301 redirect and an empty body, and any + client that doesn't follow redirects sees nothing. +- **Send `User-Agent: Sefaria/close-read`**, the in-house `Sefaria/` convention, so + our traffic is counted as first-party. Never send a bare `Mozilla/5.0`: Cloudflare + challenges it with a 403 "Just a moment" HTML page. For example: + `curl -sL -A "Sefaria/close-read" https://www.sefaria.org/api/sheets/user/54380/ -o /tmp/nechama/all-sheets.json` + (the scripts in `research/scripts/` already set it). +- Filter to one parsha client-side on `topics[].slug == "parashat-"`. +- One sheet's full body: `https://www.sefaria.org/api/sheets/` — returns an ordered + `sources[]`, each item either an `outsideText` (her prose / section-header / question) + or a `ref` + `text` (a source she chose: verse, Rashi, midrash, Rambam, …). + +Her sheets are internally organized as **Hebrew-letter sections** (`א.`, `ב.`, `ג.` …), +each a crux with the sources she chose, her framing prose, and her numbered questions. +That internal structure is the raw material for a leaf. + +## Workflow (five stages) + +The rule underneath every stage is that **her Hebrew is never typed**. It's sliced from text +that has been checked against her original scans, and a test proves it. + +### Stage 1 — Snapshot and survey + +- `research/scripts/snapshot_parsha.py ` freezes every sheet into + `parshiyot/-raw/`, with sha256s. Verify the snapshot against the live API and + record that in `verification.json`. +- `survey_parsha.py ` writes `-survey.{json,md}`: every item + with its `[i]`, a summary table, verse coverage, and a year check. +- Write the pack (`parshiyot/.md`): provenance, inventory, tentative clusters, and + absences. Absences are only *candidates* until the scans confirm them. + +### Stage 2 — Design, then STOP and confirm + +Pick 4–6 themes and 3 gilyonot per theme, with temporal spread and interpretive contrast, +and list the sections each leaf renders. **Present the outline and wait for the user.** +Leningrad images (`mode: image`) only where the page layout is itself the evidence, once +or twice per parsha. + +### Stage 3 — Scan check, triage, harvest + +- **Scan check.** For every chosen section, check the digitization against her scan + (`https://www.nechama.org.il/pdf/.pdf`, every page; most are two). Method, format and + calibration are in `bereshit-scancheck/README.md`. It's verification, not + transcription. Spot-check one finding per checker yourself. +- **Transcribe** any sheet whose digitization lost her questions, and have a person verify + the part you'll use. +- **Triage.** Settle every word-level difference with evidence in `triage.json`. The cited + source usually decides it: a reference she gives, or the commentator's own text. + Citation detail the digitizers *added* is editorial, not an error (Lev). +- **Harvest.** `harvest_.py` (copy `harvest_bereshit.py`), plus `overrides.json` for + items the digitization lost. `REVIEW.md` must be empty before drafting. +- **Verses.** `fetch_verses.py` caches MAM Hebrew and JPS English, with footnotes and + variant notes stripped. + +### Stage 4 — Draft + +- Leaves go in `research/scripts/_leaves/.py`, with helpers in + `bereshit_lib.py`. Cards quote harvest items by `cite`, slices are verbatim, and verse + word groups are found by their bare letters. +- Follow `bereshit-leaves/BRIEF.md` and + [references/voice-and-fidelity.md](references/voice-and-fidelity.md). **The reader + experiences the content, not her sheet**: no narration about how her sheet is built. +- Check one theme in isolation with `check_leaves.py `. Each theme's paper trail goes + in `-leaves/.md`. +- Assemble with `build_.py`. + +### Stage 5 — Validate & ship + +- Run `python3 -m pytest`. It includes `test_provenance.py`, and the render suite walks + every path at desktop and phone sizes. +- Take Playwright screenshots of the hard cards: images, comparisons, LTR quotes. +- Bump the asset `?v=` strings, then open the PR. See + [references/quality-and-errors.md](references/quality-and-errors.md). + +## Cadence + +The scan check and triage are the expensive, essential part. Bereshit's 15 leaves took +five parallel checkers and five parallel drafters. The scripts are templates: a new parsha +needs its slug, chosen sheets and sections, theme intros, and its leaves. diff --git a/.agents/skills/nechama-parsha/references/quality-and-errors.md b/.agents/skills/nechama-parsha/references/quality-and-errors.md new file mode 100644 index 0000000..7ef588a --- /dev/null +++ b/.agents/skills/nechama-parsha/references/quality-and-errors.md @@ -0,0 +1,140 @@ +# Quality & error checking + +Thoroughness is part of fidelity: a sheet with a silently-broken highlight or an +overflowing panel misrepresents the material. This file is the discipline that catches +those, plus the specific engine traps this format has already fallen into. Treat the +checklist as mandatory, not optional. + +The mechanical rules (Hebrew cantillation cleaning, word-group constraints, source-color +tokens, comparison-vs-inline) live in +[../../close-read-sheet/references/hard-rules.md](../../close-read-sheet/references/hard-rules.md). +Read that too. This file adds the Nechama-flow-specific checks and the gotchas. + +--- + +## The validation script (run before every browser check) + +Extends the basic word-group check with **overlap detection** — two word-group phrases +where one contains the other cause a silent wrap failure (the inner one never renders). + +```python +import json, re + +d = json.load(open('data/.json')) +problems = 0 + +def check(verse, label): + global problems + he, en = verse.get('he',''), verse.get('en','') + items = list(verse.get('words', {}).items()) + for key, w in items: + # existence: HE phrase present (tolerant of spaces/maqaf) + pat = re.escape(w['he']).replace(r'\ ', r'[\s​]+').replace(r'\-', r'[\s־\-]') + if not re.search(pat, he): + print(f' MISSING HE {label}: {key} = {w["he"]!r}'); problems += 1 + # EN must appear exactly once (else the global wrap hits the wrong spot) + n = en.count(w['en']) + if n == 0: print(f' MISSING EN {label}: {key} = {w["en"]!r}'); problems += 1 + elif n > 1: print(f' AMBIGUOUS EN {label}: {key} ({n}x) = {w["en"]!r}'); problems += 1 + # overlap: no phrase may contain another (same verse) + for i,(k1,w1) in enumerate(items): + for k2,w2 in items[i+1:]: + if w1['he']!=w2['he'] and (w1['he'] in w2['he'] or w2['he'] in w1['he']): + print(f' HE OVERLAP {label}: {k1} vs {k2}'); problems += 1 + if w1['en']!=w2['en'] and (w1['en'] in w2['en'] or w2['en'] in w1['en']): + print(f' EN OVERLAP {label}: {k1} vs {k2}'); problems += 1 + +for s in d['sections']: + if 'primaryText' in s: + check(s['primaryText'], s['id']) +print(f'problems: {problems}') +``` + +Must print `problems: 0`. A real example caught this way: `yissa` = +"May the Lord lift up His face upon you and grant you peace" fully contained `shalom` = +"and grant you peace" — so highlighting `shalom` silently did nothing. Fix by trimming +the longer phrase so they don't overlap. + +--- + +## Browser verification checklist + +Start the preview, load `?sheet=`, and confirm each — these all correspond to +bugs that have actually shipped in this format: + +1. **All leaves reachable.** Click each root-fork branch, then each theme-fork branch; + confirm the leaf's section becomes visible and the URL path updates + (`?path=/`). Deep-link a path directly and confirm it lands. +2. **Highlights fire on the RIGHT panel.** Scroll a card with a `highlight` into view. + Confirm the lit phrase is in *this section's* pinned panel and that the un-highlighted + text in *this* panel dims. (See the multi-panel gotcha below — this broke once.) +3. **Ref label clears the breadcrumb.** The `NUMBERS x:y` label at the top of the pinned + panel must not be covered by the fixed breadcrumb pill. +4. **No vertical overflow.** The Hebrew + English caption must fit the panel without + clipping the last line. (See fit-to-panel below.) +5. **No `undefined` in the DOM**, no errors in `preview_console_logs` filtered to `error`. +6. **Highlight coverage sanity.** Spot-check that question cards `pulse` and that the + theme-intro "look at the whole structure" cards light up the verses they describe. + +--- + +## Engine gotchas (each one cost real debugging time) + +### Many `.primary-text-content.active` elements at once +A sheet with N sections has **N** panels, each with its own `.primary-text-content` that +carries `.active` (it just means "the visible content in this section"). A 13-leaf sheet +has ~18 of them simultaneously. So **never use `document.querySelector('.primary-text- +content.active')`** to find "the" active panel — it returns the first in DOM order (the +overview's), not the one the reader is on. `TextEffects.highlight()`/`reset()` take a +`sectionEl` parameter and scope to it; `activateStep`/`deactivateStep` pass it through. If +highlights "don't show up" in a leaf, this scoping is the first suspect. + +### CSS clamp can't measure content height → fit-to-panel +`font-size: clamp(…, cqi, …)` scales the Hebrew to the panel *width*, but a long passage +still overflows the panel *height* — CSS has no "shrink to fit available height." The +engine's `TextEffects.fitToPanel()` measures content vs. the panel's padded area and +shrinks the font until it fits (with floors). It runs after init, on path change (newly +revealed leaves), and on debounced resize. Two sub-traps: +- **It bails on hidden panels** (`clientHeight === 0`) — a leaf hidden behind an + un-chosen fork has no measurable height; fit it when it becomes visible, via the + `applyVisibility` hook, not at init. +- **Schedule with `setTimeout`, not `requestAnimationFrame`.** rAF callbacks don't fire + in background/inactive tabs, so a panel measured via rAF can stay full-size. The + symptom: "it resizes when I switch to the tab but not on first load." + +The *first* defense against height overflow is still trimming `primaryText` to the +verses the leaves use (see voice-and-fidelity.md) — fit-to-panel is the safety net, not +the primary tool. + +### `container-type: size` breaks the sticky panel +The panel needs `container-type: inline-size` (for `cqi` width units), **not** `size`. +Full size-containment zeroes out the sticky `height: 100vh` and the panel collapses to +nothing. For height-awareness use a `vh` term inside the font `clamp()`, not `cqb`. + +### `align-items: center` + tall content pushes the ref label off-screen +When content exceeds the panel's padded area, plain `center` distributes overflow equally +above and below — sliding the ref label up under the breadcrumb. Use `align-items: safe +center` (falls back to top-align on overflow) and keep `padding-top` ≥ 4.5rem to clear +the breadcrumb. + +### Local-dev caching +`python3 -m http.server` sends no `Cache-Control` headers, so the browser holds stale +JS/CSS/HTML. Symptom: "I fixed it but still see the bug." Defenses, all in place: +- No-cache `` tags in `index.html`. +- Asset `?v=` query strings bumped to an epoch timestamp on **every** CSS/JS edit + (`sed -i '' -E "s|close-read.css\?v=[0-9]+|close-read.css?v=$(date +%s)|" index.html`). +- When verifying, hard-reload (`Cmd+Shift+R`) or load `/index.html?bust=&sheet=…` so + even a cached index.html is bypassed once. + +--- + +## Definition of done + +- Validation script prints `problems: 0`. +- All leaves reachable; deep-links work; back/forward works. +- Highlights fire on the correct panel and dim the rest of *that* panel. +- No panel overflows; ref labels clear the breadcrumb; no console errors. +- Every commentary card's text matches the Sefaria source it cites; every attribution is + era/school-correct. +- The research pack accounts for every leaf and every cited source. +- Committed with a semantic message; assets cache-busted. diff --git a/.agents/skills/nechama-parsha/references/voice-and-fidelity.md b/.agents/skills/nechama-parsha/references/voice-and-fidelity.md new file mode 100644 index 0000000..27262d6 --- /dev/null +++ b/.agents/skills/nechama-parsha/references/voice-and-fidelity.md @@ -0,0 +1,130 @@ +# Voice & fidelity — the editorial heart + +This is the part that makes a Nechama parsha flow good rather than merely functional. +Three intertwined commitments: **voice**, **source density**, and **faithfulness to her +material**. They reinforce each other — faithful rendering at high density is what lets +the narrator's voice stay quiet. + +--- + +## 1. Faithfulness to her material + +The whole premise: this is **her** work, rendered — not a parsha sheet inspired by her. +Hold the line on every one of these: + +- **Every commentary card is a source she actually chose.** Open her gilayon + (`/api/sheets/`), see which sources she cited under each lettered section, and use + those. Don't substitute a commentator you find more convenient or famous. If her + section ד quotes the Sifra and Ibn Ezra, the leaf quotes the Sifra and Ibn Ezra. +- **Her order is the leaf's order.** She sequenced the sources deliberately — usually + problem → first reading → counter-reading → resolution. Preserve that arc. +- **Her questions are her questions.** The numbered questions at the bottom of each + section are the gold. Translate them faithfully into the `question` cards (with + `questionLabel: "Nechama asks"`). Don't smooth them into something blander, and don't + invent questions she didn't pose. +- **Don't invent a "carry-forward."** The general close-read format likes to end with a + Hasidic + modern voice. Many of her sheets have no such thing. If she didn't reach for + Rav Kook or the Sefat Emet, don't graft them on. Let the leaf end where she ended — + usually on a question. +- **Get attributions right.** This is where it's easiest to embarrass yourself. Hizkuni + is a 13th-c. French Rishon, not a Hasidic master. The Akeidat Yitzhak is R. Yitzhak + Arama (15th c. Spain). Ibn Kaspi is 14th-c. Provence. If you're not certain of a + commentator's era/school, look it up before you label them in narration. A wrong + attribution undermines the whole "faithful to the sources" promise. +- **`primaryText` = the verses her leaves actually anchor on, trimmed honestly.** A theme + might span Num 6:1–21, but if the leaves only touch verses 2, 5, 7, 11, show only + those — join the gaps with `…` and set `ref` to the true range (`Numbers 6:1-3, 5, 7, + 11`). Never pad the panel with verses no card references. +- **The research pack is the audit trail.** Before drafting a leaf, the pack should + record: which gilayon (year + sheet ID), which lettered sections you're rendering, the + sources in each, and her question. Every claim in the JSON traces back to a line in the + pack. If you can't cite it, don't ship it. + +When her material is genuinely thin for a theme, that's a finding — say so in the pack +and pick a denser theme, rather than inventing to fill space. + +--- + +## 2. Source density — lean toward more of her + +**The default failure mode is a thin sheet**: one commentary per leaf, the narrator +paraphrasing the move instead of letting the sources make it. The prototype Nasso +skeleton had exactly this problem. Correct *against* it. + +- **3+ of her cruxes per leaf.** A gilayon typically has 3–11 lettered sections. Render + at least 3. Pick the pedagogically rich ones (below), but don't reduce a leaf to a + single idea — she didn't. +- **Prefer cruxes where she pits 2+ commentators against each other.** Her signature move + is the mahloket — Rashi vs. Ramban, the Sifra vs. the plain sense, R. Yashia vs. R. + Yonatan. A leaf that stages a real disagreement is doing her work; a leaf with one + voice is doing less. +- **Use her full panel, not just the famous four.** She cited the Sifra, Sifrei, Bamidbar + Rabbah, Rambam (both Mishneh Torah and the Moreh), Ibn Kaspi, Malbim, Akeidat Yitzhak, + the Alshich, Hizkuni, Ha'amek Davar, R. Hirsch, and more. Bring them in. The breadth of + her sourcing *is* the experience. +- **A short prooftext she cites lives inline** in the commentary card's text or + annotation — not as its own `verse-change`. (See close-read-sheet/hard-rules.md.) + +Which cruxes to render (when a sheet has more than 3): + +- Prefer cruxes with a sharp question and a highlightable verse phrase. +- Prefer cruxes that *cohere* — three angles on one thread, not three scattered shots. +- Skip pure-grammar cruxes ("why hifil not piel") unless the grammar is the point. +- It's fine for different leaves in a theme to render different cruxes from the same + passage — that's the across-the-decades contrast working. + +--- + +## 3. Voice — the narrator names, then disappears + +**The reader experiences the content, not her sheet** (Lev, 2026-10-06): "We're taking her +thinking from one form into another." Her sources, her questions and the verse are the +experience. How her sheet was built (which section, where the questions sat, what she added, +what we left out) is the paper trail, and it goes in the research pack, never on screen. + +The narrator is a guide to the text, not to the sheet, and not an interpreter. It says as +little as possible and steps aside so the sources speak. + +**What the narrator does:** +- Points at the verse: *"The end of the sixth day."* *"'And on the seventh day God finished.'"* +- Says what's on a page or image the reader is looking at (a manuscript's layout). + +**What the narrator doesn't do:** +- **Never narrates her sheet.** No "Gilayon תשי״ג…", "her next section…", "she adds two + commentators…", "her questions stand at the end…". The year belongs in the leaf's title + and branch label, not in a card. +- **Never states her question for her,** above all when her question asks the student to + find the question. +- Doesn't name who speaks next; the source label does that. + +**What the narrator never does:** +- **Never moralizes.** No life lessons, no "and so we learn that…". The reader draws the + lesson; the text and the question set it up. +- **Never paraphrases what the commentary is about to say.** Don't pre-chew. Let the card + land, then (in the `annotation`) name what shifted — that's where interpretation of the + source belongs, tightly. +- **Never substitutes for a commentator.** If a move is interesting, find the source she + used to make it and quote that. Narrator-as-pseudo-commentator is the thin-sheet trap. + +**Mechanics of voice:** +- **One thought per card.** Let it breathe. This format is for noticing and sitting with; + compressed multi-clause cards work against it. If a card carries two thoughts, split it. +- **English-only narration.** Hebrew lives in the pinned primary text and in direct + commentary quotations. Bilingual narration overloads the eye. +- **Her questions get `questionLabel: "Nechama asks"`** (set once in the title block). + This is what marks the difference between the narrator's framing and her actual query. +- **Cut the tying-the-bow cards.** Restating a question after a commentator already named + it, or a card that just transitions, adds length without value. Trim them. +- **Bind narration to the verse.** When a card names a phrase that's in the primary text, + give it a `highlight`. The reader's eye should move between the sidebar claim and the + pinned phrase. Aim for the majority of cards carrying a highlight (the Nasso build runs + ~two-thirds). Use `glow` for the crescendo card in a crux, `pulse` on the question. + +--- + +## The test + +Read a finished leaf and ask: *Could a knowledgeable reader tell which moves are +Nechama's and which are mine?* If the answer is "it's all hers, I just pointed" — the +leaf is right. If the narrator is doing interpretive work the sources should be doing, +go back and find the source she used. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f83fb18 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# Close Read + +A data-driven scrollytelling engine for Torah study in the style of NYT's "Close Read" format. Primary verses stay pinned on screen while commentary cards scroll past, triggering word-level highlights, verse transitions, and side-by-side comparisons. + +Built for Nechama Leibowitz's study sheets. + +Per-module detail (engine, text effects, CSS, data contract, Nechama workflow, gotchas) lives in the Sefaria wiki at `sefaria-wiki/wiki/repos/close-read/` — start at `_index.md`. Update those pages when you change a module; they replaced the old `agent_docs/` tree. + +## Key Documentation + +- **[Data Format](docs/data-format.md)** — Full schema for JSON data files. Covers title metadata, sections, verse data (single and comparison modes), word groups for highlighting, and all four step types (narration, commentary, question, verse-change). +- **[Architecture](docs/architecture.md)** — How the JS engine works: initialization flow, ScrollTrigger setup, word highlighting pipeline, verse crossfade mechanism, CSS architecture, and known constraints. +- **[Conversion Guide](docs/conversion-guide.md)** — Step-by-step checklist for turning a Sefaria source sheet into a JSON data file. Covers naming conventions, text sourcing, structure mapping, and testing. + +## Running Locally + +Requires a web server (fetch won't work from file://): + +```bash +cd close_read +python3 -m http.server 8080 +# Open http://localhost:8080 +``` + +The default page (`/`) shows an index of available sheets. Load a specific sheet: `http://localhost:8080/?sheet=other-sheet-name` + +When adding a new sheet, also add an entry to `data/index.json`, with a homepage `category` and a `shows` line saying what it demonstrates that the others don't (see [Conversion Guide](docs/conversion-guide.md#testing)). Retire a sheet from the homepage with `"listed": false`; it stays loadable and tested. The homepage hero video is recorded from the live Bereshit reading by `python3 scripts/record_hero.py` (needs ffmpeg); re-run it when Bereshit or the engine changes. + +## Tests + +```bash +pip install -r requirements-dev.txt && python3 -m playwright install chromium +python3 -m pytest # everything (~1 min) +python3 -m pytest tests/test_data.py # JSON checks only, no browser (<1 s) +python3 -m pytest -k "nasso and mobile" +``` + +- `tests/test_data.py` checks the JSON alone. It replays the engine's word wrap, so overlapping or nested word groups, unresolvable highlights, broken branch targets and unreachable sections are caught without a browser. +- `tests/test_render.py` serves the site and opens every leaf path of every sheet in `data/index.json` at desktop (1440×900) and mobile (390×844). It scrolls each card into reading position and checks: + - the active card is the only active one; + - the panel shows the right verse, the right words are lit with the rest dimmed, and nothing leaks into other sections; + - highlighted words are on screen, uncovered and not clipped; + - the verse fits the screen and the ref label sits clear of the breadcrumb; + - the card is readable and not covered by the sticky panel; + - fixed nav doesn't overlap text, and there are no `undefined`s or console errors. + It also clicks through the branches and walks back with history. +- `tests/sheetmodel.py` mirrors the engine's grouping, branching and active-verse rules. Change it when you change `groupSections`, `pathToVisibleSet` or `activateStep`. +- New sheets are picked up from `data/index.json` automatically. CI runs both layers on every PR (`.github/workflows/test.yml`). + +## Stack + +Static HTML/CSS/JS. No build step. GSAP + ScrollTrigger from CDN. Google Fonts (Frank Ruhl Libre, Crimson Text, Inter). + +## Design Decisions + +- **Left/right layout**: Commentary cards scroll on the left (40%), primary text is sticky on the right (60%). Mobile stacks vertically. +- **Light theme**: Warm cream background (#faf8f4), brown accent (#8b5e3c). Dark mode was tried and rejected — hard to read with bilingual text. +- **Verse breathing**: Intro steps (no `highlight` array) show the verse at full opacity before any dimming begins. This is deliberate — don't add highlights to intro steps. +- **Dimming approach**: Uses `color` change (to --color-text-muted) rather than `opacity` reduction. Prevents double-dimming when both word-group and container styles apply. +- **Mobile panel**: the sticky verse panel is a fixed 45vh so cards stay readable below it, and `fitToPanel` shrinks long passages into it. A passage that won't fit even at the minimum sizes (14px Hebrew / 12px English) shows Hebrew only (or English only, on a sheet with `"primaryLanguage": "en"`); currently that's Nasso's Nazir passage, Yitro's comparison, and the Tanhuma passage in Learning to Read Midrash (English kept). On phones the breadcrumb shortens earlier crumbs and keeps the current one whole. +- **Font sizes**: Base 18px, Hebrew primary text clamp(1.5rem, 2.8vw, 2.2rem). Erring on the larger side for readability. + +## Working With the Data + +To create a new sheet: + +1. Create `data/your-sheet.json` following the schema in [data-format.md](docs/data-format.md) +2. Visit `?sheet=your-sheet` +3. The engine builds everything from the JSON — no HTML or JS changes needed + +Key things to get right: +- Hebrew text in `words` entries must exactly match the verse text (including nikkud) +- First step in each section should be a `narration` with no `highlight` — lets the verse breathe +- `verse-change` steps must appear before any steps that reference the new verse's word groups +- Word group IDs in `highlight` arrays must match keys in the active verse's `words` map + +## Extending + +- **New source colors**: Unknown sources get a neutral warm-brown fallback. For a custom color, add `.source-label[data-source="Name"] { ... }` to CSS +- **New step types**: Add a case in `buildStepCard()` in engine.js and matching CSS +- **New highlight effects**: Add a case in the `highlight()` method in text-effects.js and matching CSS class +- **Images in the panel**: use `"mode": "image"` VerseData with fractional `regions` (see [Data Format](docs/data-format.md#image)); steps highlight region ids like word groups diff --git a/assets/hero/bereshit-walkthrough.jpg b/assets/hero/bereshit-walkthrough.jpg index 335c63e..9c77524 100644 Binary files a/assets/hero/bereshit-walkthrough.jpg and b/assets/hero/bereshit-walkthrough.jpg differ diff --git a/assets/hero/bereshit-walkthrough.mp4 b/assets/hero/bereshit-walkthrough.mp4 index 5d0267a..edcdf53 100644 Binary files a/assets/hero/bereshit-walkthrough.mp4 and b/assets/hero/bereshit-walkthrough.mp4 differ diff --git a/assets/hero/bereshit-walkthrough.webm b/assets/hero/bereshit-walkthrough.webm index 3032579..39bac71 100644 Binary files a/assets/hero/bereshit-walkthrough.webm and b/assets/hero/bereshit-walkthrough.webm differ diff --git a/css/close-read.css b/css/close-read.css index 8c6e26d..d6e3c44 100644 --- a/css/close-read.css +++ b/css/close-read.css @@ -1205,33 +1205,11 @@ body.is-home .section-nav { display: none; } } .home-main { - max-width: 1120px; + max-width: 1160px; margin: 0 auto; padding: 0 clamp(1.25rem, 4vw, 2.5rem); } -.home-points { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: clamp(1.5rem, 3vw, 3rem); - padding: clamp(3rem, 6vw, 5rem) 0; - border-bottom: 1px solid var(--color-border); -} - -.home-point-title { - font-family: var(--font-english); - font-size: 1.3rem; - font-weight: 600; - line-height: 1.25; - margin-bottom: 0.5rem; -} - -.home-point p, -.home-proof-item p { - color: var(--color-text-secondary); - font-size: 1rem; -} - .home-section-title { font-family: var(--font-ui); font-size: 0.78rem; @@ -1239,39 +1217,71 @@ body.is-home .section-nav { display: none; } letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-accent); - margin-bottom: 2rem; + margin-bottom: 0.6rem; } -.home-readings { - padding: clamp(3rem, 6vw, 5rem) 0 1rem; - scroll-margin-top: 1rem; +.home-pitch { + padding: clamp(3rem, 6vw, 4.5rem) 0; + border-bottom: 1px solid var(--color-border); +} + +.home-pitch-head { margin-bottom: 2rem; } + +.home-pitch-title { + font-family: var(--font-english); + font-size: clamp(1.6rem, 2.6vw, 2.2rem); + font-weight: 600; + line-height: 1.2; + color: var(--color-text); } -.home-category { +.home-points { display: grid; - grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(1.5rem, 3vw, 3rem); - padding-bottom: clamp(2.5rem, 5vw, 4rem); } -.home-category-title { +.home-point-title { font-family: var(--font-english); - font-size: 1.55rem; + font-size: 1.25rem; font-weight: 600; - line-height: 1.2; - margin-bottom: 0.7rem; + line-height: 1.25; + margin-bottom: 0.45rem; } -.home-category-intro { +.home-point p { color: var(--color-text-secondary); font-size: 1rem; } +/* Readings: six tiles that fit one desktop screen. */ +.home-readings { + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + padding: clamp(2.5rem, 5vw, 3.5rem) 0; + scroll-margin-top: 0; +} + +.home-readings-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem 2rem; + margin-bottom: 1.5rem; +} + +.home-readings-head p { + color: var(--color-text-muted); + font-style: italic; +} + .home-card-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); - gap: 1.25rem; - align-content: start; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; } .home-card { @@ -1289,94 +1299,71 @@ body.is-home .section-nav { display: none; } } .home-card .index-card { - display: block; + display: flex; + flex-direction: column; flex: 1; - padding: 1.5rem 1.6rem 1.2rem; + padding: 1.15rem 1.3rem 1rem; color: inherit; text-decoration: none; } -.home-card-title-he { - font-family: var(--font-hebrew); - font-size: 1.35rem; - font-weight: 700; - direction: rtl; - text-align: right; - line-height: 1.4; +.home-card-tag { + align-self: flex-start; + font-family: var(--font-ui); + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-accent); + background: var(--color-accent-soft); + border-radius: 999px; + padding: 0.2rem 0.6rem; + margin-bottom: 0.7rem; +} + +.home-card[data-category="single"] .home-card-tag { + color: var(--color-highlight-2); + background: var(--color-highlight-2-bg); +} + +.home-card[data-category="range"] .home-card-tag { + color: var(--color-nechama); + background: var(--color-nechama-bg); } .home-card-title-en { - font-size: 1.2rem; + font-size: 1.15rem; font-weight: 600; - line-height: 1.3; - margin-top: 0.15rem; + line-height: 1.25; } .home-card-author { font-family: var(--font-ui); - font-size: 0.8rem; - color: var(--color-accent); - margin-top: 0.35rem; + font-size: 0.78rem; + color: var(--color-text-muted); + margin-top: 0.25rem; } .home-card-shows { - font-size: 1rem; - line-height: 1.55; - margin-top: 0.9rem; - padding-top: 0.8rem; - border-top: 1px solid var(--color-border); -} - -.home-card-desc { - font-size: 0.92rem; - font-style: italic; - color: var(--color-text-muted); - margin-top: 0.5rem; + font-size: 0.98rem; line-height: 1.5; -} - -.home-card-shows-label { - display: block; - font-family: var(--font-ui); - font-size: 0.68rem; - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--color-text-muted); - margin-bottom: 0.2rem; + color: var(--color-text-secondary); + margin-top: 0.6rem; } .home-card-demo { display: block; font-family: var(--font-ui); - font-size: 0.82rem; + font-size: 0.8rem; font-weight: 500; color: var(--color-accent); text-decoration: none; - padding: 0.75rem 1.6rem; + padding: 0.6rem 1.3rem; border-top: 1px solid var(--color-border); } .home-card-demo:hover { background: var(--color-accent-soft); } -.home-proof { - padding: clamp(3rem, 6vw, 5rem) 0; - border-top: 1px solid var(--color-border); -} - -.home-proof-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2rem clamp(2rem, 4vw, 4rem); -} - -.home-proof-item h3 { - font-family: var(--font-english); - font-size: 1.2rem; - font-weight: 600; - margin-bottom: 0.35rem; -} - .home-footer { text-align: center; padding: 3rem 1.5rem 4rem; @@ -1391,6 +1378,10 @@ body.is-home .section-nav { display: none; } .home-error { color: var(--color-accent); } +@media (max-width: 1100px) { + .home-card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + @media (max-width: 900px) { .home-hero { grid-template-columns: 1fr; @@ -1398,9 +1389,12 @@ body.is-home .section-nav { display: none; } padding: 2.5rem 16px 2.5rem; } .home-points { grid-template-columns: 1fr; } - .home-category { grid-template-columns: 1fr; } - .home-proof-grid { grid-template-columns: 1fr; } .home-main { padding: 0 16px; } + .home-readings { min-height: 0; } +} + +@media (max-width: 600px) { + .home-card-grid { grid-template-columns: 1fr; } } /* --- Reduced Motion --- */ diff --git a/data/index.json b/data/index.json index 9e54c84..7b15f55 100644 --- a/data/index.json +++ b/data/index.json @@ -11,7 +11,7 @@ }, "description": "Fifteen gilyonot, five passages, thirty years. Choose a passage, choose a year, and read it through her sources and questions.", "category": "branching", - "shows": "The flagship. More than forty sources, and pages of the Leningrad Codex that zoom to the line under discussion. Every quote of Nechama’s cites the sheet it came from.", + "shows": "Fifteen of Nechama’s lessons across five passages and thirty years, with manuscript pages that zoom to the line.", "demoPath": "creation/cr-1963", "demoLabel": "Creation → 1963" }, @@ -27,7 +27,7 @@ }, "description": "Fifteen gilyonot, five passages, thirty years. Choose a passage, choose a year, and read it through her sources and questions.", "category": "branching", - "shows": "Built the same way as Bereshit, from her original scans. Questions the online digitization dropped or changed are restored, and her own comparison tables sit side by side in the panel.", + "shows": "Checked against her original scans, with her comparison tables side by side.", "demoPath": "flood/fl-1952", "demoLabel": "The Flood → 1952" }, @@ -43,9 +43,9 @@ }, "description": "Twenty-eight gilyonot, four themes, thirty years of return. Choose a theme, choose her angle, walk the sheet.", "category": "branching", - "shows": "The second garden, built the same way. It branches by theme rather than by passage, then by the angle she took in a given year. It shows the method carrying from one parsha to the next.", + "shows": "The first garden, branching by theme and then by the year she taught it.", "demoPath": "birkat/b-1955", - "demoLabel": "The Priestly Blessing → 1955" + "demoLabel": "Priestly Blessing → 1955" }, { "slug": "yitro-5730", @@ -59,7 +59,7 @@ }, "description": "Visiting the Iniquity of Parents upon Children — Exodus 20:5", "category": "single", - "shows": "One gilayon, read start to finish. When the sheet turns on two verses that seem to contradict each other, Exodus 20:5 and Deuteronomy 24:16, they appear side by side." + "shows": "A single gilayon read start to finish, with two verses that seem to contradict each other side by side." }, { "slug": "sinai-opt-in", @@ -73,7 +73,7 @@ }, "description": "Over and over and over: from Jacob's deathbed to the morning Shema, every generation chooses the covenant again.", "category": "single", - "shows": "A theme sheet, not a single passage. It moves from Genesis to the siddur, changing the pinned verse at every stop and lighting the words that come back each time: a gathering, a covenant scroll read aloud, an age of 110." + "shows": "A theme sheet that moves from Genesis to the siddur, changing the pinned verse at every stop." }, { "slug": "learning-to-read-midrash-2", @@ -85,7 +85,7 @@ }, "description": "How a mashal works — a six-step reading of the Tanhuma's parable on “Take, please” (Genesis 22:2)", "category": "range", - "shows": "English-first. A chapter of a published book by a contemporary teacher, walking through the author’s six-step method for reading a mashal." + "shows": "An English-first book chapter that teaches a six-step method for reading a mashal." }, { "slug": "pirkei-avot-1-11", @@ -97,7 +97,7 @@ }, "description": "A guided close reading of Pirkei Avot 1:11 — oldest layers to modern voices", "category": "range", - "shows": "Built from a single Mishnah instead of an existing sheet. Four readings of “evil waters,” from the oldest to the newest." + "shows": "Built from one Mishnah rather than a sheet, with readings from the oldest to the newest." }, { "slug": "minhag-overview", @@ -111,7 +111,7 @@ }, "description": "The authority of custom in Jewish law — Talmud Pesachim", "category": "range", - "shows": "Talmud, not Torah. The Bavli and the Yerushalmi read against each other, from a Pardes Institute sheet." + "shows": "Talmud: the Bavli and the Yerushalmi read against each other." }, { "slug": "kedoshim-5731", diff --git a/index.html b/index.html index 2d9ee36..95f0d3f 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ Close Read - + @@ -17,7 +17,7 @@ - + @@ -41,9 +41,9 @@ - - - + + + diff --git a/js/home.js b/js/home.js index 7e729ec..3880053 100644 --- a/js/home.js +++ b/js/home.js @@ -1,63 +1,48 @@ // Close Read homepage -// Makes the case for the method, then lists the readings grouped by what each -// one shows about the tool. Readings come from data/index.json; entries with -// "listed": false are retired from this page but still load by URL (and are -// still tested). +// Makes the case twice: for learners (what reading one is like) and for +// teachers (what making one takes), then shows the readings on one screen. +// Readings come from data/index.json; entries with "listed": false are retired +// from this page but still load by URL (and are still tested). +// Tile order and tag for each index.json `category`. const HOME_CATEGORIES = [ - { - id: 'branching', - title: 'One teacher’s whole body of work, as a single reading', - intro: 'Nechama Leibowitz wrote on each parsha for thirty years, returning to the same verses with new questions. On Sefaria those lessons sit as dozens of separate sheets. Here the reader chooses a passage, then a year, and walks that lesson. One reading holds many lessons.', - }, - { - id: 'single', - title: 'One source sheet, taught', - intro: 'The basic unit is one sheet turned into a guided reading. The verse stays in view, and each source and question lights the words it is about.', - }, - { - id: 'range', - title: 'Other teachers, other texts, either language', - intro: 'Nothing in the engine is specific to Nechama or to Torah. These readings test it on Midrash, Mishnah and Talmud, English-first as well as Hebrew-first, from other teachers.', - }, + { id: 'branching', tag: 'Branching paths' }, + { id: 'single', tag: 'One sheet' }, + { id: 'range', tag: 'Other texts & teachers' }, ]; -const HOME_POINTS = [ +const HOME_LEARNER_POINTS = [ { - title: 'The text never leaves the screen', - body: 'A source sheet lists texts one after another, and the verse scrolls away. Here the verse is pinned. Each commentary card lights the exact words it discusses and dims the rest.', + title: 'Engaging like video, paced like reading', + body: 'The verse stays on screen. Each scroll brings the next source or question and lights the words it is about. The reader moves on when they are ready, not when the video does.', }, { - title: 'The reader chooses the path', - body: 'Where a teacher returned to a passage many times, the reader picks which lesson to take. Bereshit holds fifteen of Nechama’s gilyonot in one reading.', + title: 'Paths to follow, and every branch pays off', + body: 'The reader chooses a passage, then a year of the teacher’s lessons on it. Each branch is a complete lesson with its own sources and questions, not a teaser for the next one.', }, { - title: 'The primary sources, in place', - body: 'Manuscript pages zoom to the line under discussion, and conflicting verses sit side by side. All of it comes from Sefaria’s library.', + title: 'Impossible to get lost', + body: 'A breadcrumb always shows where you are and how you got there. The text never scrolls away, and Back takes you to your last choice.', }, ]; -const HOME_PROOF = [ +const HOME_TEACHER_POINTS = [ { - title: 'New readings are data, not code', - body: 'Each reading is a single JSON file. The engine builds everything from it, with no build step and no per-reading code.', + title: 'Built in hours', + body: 'Readings like these used to need a designer and a developer for every highlight, transition and branch. Now AI builds them from the teacher’s material. A whole parsha of Nechama’s lessons takes about six hours.', }, { - title: 'Every quote is traceable', - body: 'In Bereshit, Nechama’s Hebrew is cut from her digitized sheets, never retyped. Each card cites its source sheet, and the digitization was checked against scans of the originals. Tests check that every quoted card is a verbatim slice of the sheet it cites.', + title: 'The teacher’s Torah, kept intact', + body: 'The sources and questions are the teacher’s own. In Bereshit every quote is cut word for word from her sheets and cited, and the tests fail if one drifts.', }, { - title: 'Tested on every path', - body: 'On every change, each branch of each reading is opened at desktop and phone sizes. The tests check that the right words light up, that nothing covers the text, and that history and back-navigation work.', - }, - { - title: 'A repeatable weekly cycle', - body: 'Building a parsha is a scripted pipeline: snapshot her gilyonot, survey them, harvest the sources and questions, then build. At Nasso’s depth that is about six hours of work per parsha.', + title: 'A new way to reach students', + body: 'A source sheet becomes something students open on a phone and follow on their own. Teachers get a new medium without having to learn one.', }, ]; function buildHome() { - document.title = 'Close Read: guided close readings of Jewish texts'; + document.title = 'Close Read: Torah study that moves at the learner’s pace'; document.body.classList.add('is-home'); const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -68,15 +53,15 @@ function buildHome() { header.innerHTML = `
Close Read קריאה מקרוב
-

A source sheet, taught.

+

The pull of video, at the learner’s pace.

- Close Read turns a teacher’s source sheet into a guided reading. The verse stays on screen while - her sources and questions scroll past it, each one lighting the words it is about. Where she taught a - passage many times, the reader chooses which lesson to follow. + Close Read turns a teacher’s Torah into a guided reading. The text stays in view while sources + and questions arrive one at a time, lighting the words they discuss. Learners choose their path, + get a full lesson down every branch, and never lose their place.

@@ -96,29 +81,35 @@ function buildHome() {
`; + const points = list => list.map(p => ` +
+

${p.title}

+

${p.body}

+
`).join(''); + const main = document.getElementById('main-content'); main.className = 'home-main'; main.innerHTML = ` -
- ${HOME_POINTS.map(p => ` -
-

${p.title}

-

${p.body}

-
`).join('')} +
+
+
For learners
+

A lesson they follow, not a page they scroll past.

+
+
${points(HOME_LEARNER_POINTS)}
-
-

What each reading shows

-
+
+
+
For teachers
+

The teacher brings the Torah. We make it compelling.

+
+
${points(HOME_TEACHER_POINTS)}
-
-

Why it can scale

-
- ${HOME_PROOF.map(p => ` -
-

${p.title}

-

${p.body}

-
`).join('')} +
+
+

The readings

+

Each one is here for something the others don’t show.

+
`; @@ -136,53 +127,36 @@ function buildHome() { }) .then(sheets => renderReadings(sheets.filter(s => s.listed !== false))) .catch(err => { - document.querySelector('.home-categories').innerHTML = + document.querySelector('.home-card-grid').innerHTML = `

Could not load the readings. ${err.message}

`; }); } function renderReadings(sheets) { - const wrap = document.querySelector('.home-categories'); - HOME_CATEGORIES.forEach(cat => { - const inCat = sheets.filter(s => s.category === cat.id); - if (!inCat.length) return; - const section = document.createElement('div'); - section.className = 'home-category'; - section.innerHTML = ` -
-

${cat.title}

-

${cat.intro}

-
-
- `; - const grid = section.querySelector('.home-card-grid'); - inCat.forEach(sheet => grid.appendChild(buildReadingCard(sheet))); - wrap.appendChild(section); - }); - // Anything listed but uncategorized still gets shown, last. - const known = new Set(HOME_CATEGORIES.map(c => c.id)); - const rest = sheets.filter(s => !known.has(s.category)); - if (rest.length) { - const grid = document.createElement('div'); - grid.className = 'home-card-grid'; - rest.forEach(sheet => grid.appendChild(buildReadingCard(sheet))); - wrap.appendChild(grid); - } + const grid = document.querySelector('.home-card-grid'); + const rank = id => { + const i = HOME_CATEGORIES.findIndex(c => c.id === id); + return i < 0 ? HOME_CATEGORIES.length : i; + }; + [...sheets] + .sort((a, b) => rank(a.category) - rank(b.category)) + .forEach(sheet => grid.appendChild(buildReadingCard(sheet))); } function buildReadingCard(sheet) { + const cat = HOME_CATEGORIES.find(c => c.id === sheet.category); const card = document.createElement('article'); card.className = 'home-card'; + card.dataset.category = sheet.category || ''; const href = `?sheet=${sheet.slug}`; card.innerHTML = ` - ${sheet.title.he ? `
${sheet.title.he}
` : ''} -
${sheet.title.en}
-
${sheet.author.en}
- ${sheet.description ? `

${sheet.description}

` : ''} - ${sheet.shows ? `

What it shows${sheet.shows}

` : ''} + ${cat ? `${cat.tag}` : ''} + ${sheet.title.en} + ${sheet.author.en} + ${sheet.shows ? `${sheet.shows}` : ''}
- ${sheet.demoPath ? `Try it: ${sheet.demoLabel}` : ''} + ${sheet.demoPath ? `Try: ${sheet.demoLabel}` : ''} `; return card; } diff --git a/scripts/record_hero.py b/scripts/record_hero.py index ccf30c2..5f5b1a1 100644 --- a/scripts/record_hero.py +++ b/scripts/record_hero.py @@ -8,8 +8,17 @@ python3 scripts/record_hero.py # writes assets/hero/bereshit-walkthrough.{mp4,webm,jpg} Needs playwright (requirements-dev.txt) and ffmpeg on PATH. + +The video is rendered frame by frame rather than screen-recorded: real-time +capture in headless Chromium drops frames, which made the scrolling judder. +For each frame we set the scroll position (a steady, wheel-like glide) and +take a screenshot. Screenshots are slower than real time, so the page's own +animations (CSS transitions and animations, GSAP tweens) are slowed by SLOWDOWN and each +frame is captured on a fixed real-time schedule; in the video they play at +normal speed. """ +import base64 import shutil import socket import subprocess @@ -23,108 +32,170 @@ ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "assets" / "hero" SIZE = {"width": 1280, "height": 800} - -# A visible pointer: Playwright's recorder doesn't draw the mouse, and the -# decision clicks are the point of the first half. -CURSOR_JS = """ -(() => { +FPS = 30 +SLOWDOWN = 5 # real seconds per video second while capturing +SPEED = 520 # glide speed, px per video second, like steady wheel scrolling +RAMP = 0.35 # seconds to ease into and out of each glide + +# A visible pointer for the decision clicks (the recorder draws no mouse). +SETUP_JS = """ +(slow) => { + document.documentElement.style.scrollBehavior = 'auto'; + // The engine smooth-scrolls to a branch after a click; the recorder glides there itself. + Element.prototype.scrollIntoView = function () {}; + if (window.gsap) gsap.globalTimeline.timeScale(1 / slow); + // Slow every CSS transition/animation the frame it starts (CDP's + // Animation.setPlaybackRate misses compositor transitions like the zoom). + const seen = new WeakSet(); + (function tick() { + for (const a of document.getAnimations()) { + if (!seen.has(a)) { seen.add(a); a.playbackRate = a.playbackRate / slow; } + } + requestAnimationFrame(tick); + })(); const c = document.createElement('div'); c.id = '__cursor'; c.style.cssText = 'position:fixed;left:0;top:0;width:22px;height:22px;margin:-11px 0 0 -11px;' + 'border-radius:50%;background:rgba(139,94,60,.35);border:2px solid #8b5e3c;z-index:99999;' + - 'pointer-events:none;transition:transform .15s ease;opacity:0'; + 'pointer-events:none;opacity:0'; document.body.appendChild(c); - window.__moveCursor = (x, y, ms) => new Promise(res => { - c.style.opacity = 1; - const r = c.getBoundingClientRect(); - const x0 = r.left + 11, y0 = r.top + 11, t0 = performance.now(); - const ease = t => t < .5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2; - (function step(now) { - const t = Math.min(1, (now - t0) / ms), e = ease(t); - c.style.left = (x0 + (x - x0) * e) + 'px'; - c.style.top = (y0 + (y - y0) * e) + 'px'; - t < 1 ? requestAnimationFrame(step) : res(); - })(t0); - }); - window.__press = () => { c.style.transform = 'scale(.7)'; setTimeout(() => c.style.transform = '', 180); }; - window.__hideCursor = () => { c.style.opacity = 0; }; -})(); -""" - -GLIDE_JS = """ -([y, ms]) => new Promise(res => { - const y0 = window.scrollY, t0 = performance.now(); - const ease = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; - (function step(now) { - const t = Math.min(1, (now - t0) / ms); - window.scrollTo(0, y0 + (y - y0) * ease(t)); - t < 1 ? requestAnimationFrame(step) : res(); - })(t0); -}) +} """ -def glide_to(page, selector, ms, at=0.42): - """Smooth-scroll so the element's top sits at `at` of the viewport height.""" - y = page.evaluate( - "([s, at]) => { const e = document.querySelector(s); return e.getBoundingClientRect().top + scrollY - innerHeight * at; }", - [selector, at]) - page.evaluate(GLIDE_JS, [max(0, y), ms]) +class Recorder: + def __init__(self, page, cdp, frames_dir): + self.page, self.cdp, self.dir = page, cdp, frames_dir + self.n = 0 + self.t0 = None + self.cursor = None # (x, y) in viewport px, or None when hidden + + def snap(self): + """Capture one frame on schedule: frame n is taken at n/FPS * SLOWDOWN real seconds.""" + if self.t0 is None: + self.t0 = time.monotonic() + due = self.t0 + self.n / FPS * SLOWDOWN + wait = due - time.monotonic() + if wait > 0: + time.sleep(wait) + shot = self.cdp.send("Page.captureScreenshot", {"format": "jpeg", "quality": 92}) + (self.dir / f"{self.n:05d}.jpg").write_bytes(base64.b64decode(shot["data"])) + self.n += 1 + + @property + def video_time(self): + return self.n / FPS + + def hold(self, seconds): + for _ in range(round(seconds * FPS)): + self.snap() + + def scroll_y(self): + return self.page.evaluate("scrollY") + + def target_y(self, selector, at): + return self.page.evaluate( + "([s, at]) => { const e = document.querySelector(s);" + " const y = e.getBoundingClientRect().top + scrollY - innerHeight * at;" + " return Math.max(0, Math.min(y, document.documentElement.scrollHeight - innerHeight)); }", + [selector, at]) + + def glide_to(self, selector, at=0.42): + """Scroll at a steady speed, easing in and out, like a reader rolling the wheel.""" + y0, y1 = self.scroll_y(), self.target_y(selector, at) + dist = abs(y1 - y0) + if dist < 1: + return + cruise = max(0.0, dist / SPEED - RAMP) # ramps cover half their length at cruise speed + total = cruise + 2 * RAMP + frames = max(2, round(total * FPS)) + for i in range(1, frames + 1): + t = i / frames * total + # Distance covered under a trapezoid velocity profile, normalised to 1. + if t < RAMP: + d = t * t / (2 * RAMP) + elif t < RAMP + cruise: + d = RAMP / 2 + (t - RAMP) + else: + u = total - t + d = (RAMP + cruise) - u * u / (2 * RAMP) + frac = d / (RAMP + cruise) + self.page.evaluate("y => window.scrollTo(0, y)", y0 + (y1 - y0) * frac) + self.snap() + + def move_cursor(self, x, y, seconds): + x0, y0 = self.cursor or (x - 140, y + 90) + frames = round(seconds * FPS) + for i in range(1, frames + 1): + t = i / frames + e = 2 * t * t if t < .5 else 1 - (-2 * t + 2) ** 2 / 2 + self.set_cursor(x0 + (x - x0) * e, y0 + (y - y0) * e) + self.snap() + + def set_cursor(self, x, y, scale=1, opacity=1): + self.cursor = (x, y) + self.page.evaluate( + "([x, y, s, o]) => { const c = document.getElementById('__cursor');" + " c.style.left = x + 'px'; c.style.top = y + 'px'; c.style.transform = `scale(${s})`; c.style.opacity = o; }", + [x, y, scale, opacity]) + + def choose(self, decision, branch): + btn = f'#{decision} .decision-branch-button[data-branch="{branch}"]' + self.glide_to(f"#{decision}", at=0.12) + self.hold(0.7) + box = self.page.locator(btn).bounding_box() + x, y = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 + self.move_cursor(x, y, 0.9) + self.hold(0.25) + self.set_cursor(x, y, scale=0.7) + self.page.evaluate("s => document.querySelector(s).click()", btn) + self.hold(0.2) + self.set_cursor(x, y) + self.hold(0.5) + self.set_cursor(x, y, opacity=0) + self.cursor = None def card(step_id): return f'.step-card[data-step-id="{step_id}"]' -def choose(page, decision, branch): - btn = f'#{decision} .decision-branch-button[data-branch="{branch}"]' - glide_to(page, f"#{decision}", 1400, at=0.12) - page.wait_for_timeout(900) - box = page.locator(btn).bounding_box() - page.evaluate("([x, y]) => __moveCursor(x, y, 900)", [box["x"] + box["width"] / 2, box["y"] + box["height"] / 2]) - page.wait_for_timeout(450) - page.evaluate("__press()") - page.locator(btn).click() - page.wait_for_timeout(300) - page.evaluate("__hideCursor()") - page.wait_for_timeout(1600) # the engine's own smooth scroll to the branch - - -def record(base_url, video_dir): +def record(base_url, frames_dir): with sync_playwright() as p: browser = p.chromium.launch() - ctx = browser.new_context(viewport=SIZE, record_video_dir=str(video_dir), record_video_size=SIZE) - page = ctx.new_page() - start = time.monotonic() + page = browser.new_page(viewport=SIZE) page.goto(f"{base_url}/index.html?sheet=bereshit") page.wait_for_function("() => window.ScrollTrigger && ScrollTrigger.getAll().length > 0") page.evaluate("document.fonts.ready") - page.evaluate(CURSOR_JS) # Let the manuscript pages load before they're needed. page.evaluate("""() => Promise.all([...document.querySelectorAll('img')].map(i => i.complete ? 0 : new Promise(r => { i.onload = i.onerror = r; })))""") - page.wait_for_timeout(1200) - lead_in = time.monotonic() - start - - page.wait_for_timeout(1800) # title screen - choose(page, "root-fork", "creation") # decision 1: a passage - choose(page, "creation-fork", "cr-1963") # decision 2: a year - page.wait_for_timeout(600) - glide_to(page, card("cr-1963-codex"), 2200) # into the manuscript - page.wait_for_timeout(2200) - glide_to(page, card("cr-1963-days"), 1600) # the scribe's day breaks - page.wait_for_timeout(2000) - poster_at = time.monotonic() - start - lead_in # poster: day breaks lit on folio 1v - page.wait_for_timeout(1400) - glide_to(page, card("cr-1963-day-6"), 1800) # folio 2r - page.wait_for_timeout(2400) - glide_to(page, card("cr-1963-seventh"), 1600) # Va-yekhullu: chapter 2 - page.wait_for_timeout(3800) - - video = page.video.path() - ctx.close() + page.wait_for_timeout(1500) + cdp = page.context.new_cdp_session(page) + page.evaluate(SETUP_JS, SLOWDOWN) + + r = Recorder(page, cdp, frames_dir) + r.hold(1.6) # title screen + r.choose("root-fork", "creation") # decision 1: a passage + r.glide_to("#creation-fork", at=0.12) # past "The Days of Creation" + r.choose("creation-fork", "cr-1963") # decision 2: a year + steps = page.evaluate( + "() => [...document.querySelectorAll('.step-card[data-section-id=\"cr-1963\"]')].map(c => c.dataset.stepId)") + for sid in steps: # the verse, words lit + r.glide_to(card(sid)) + r.hold(2.0) + r.glide_to(card("cr-1963-codex")) # into the manuscript + r.hold(2.0) + r.glide_to(card("cr-1963-days")) # the scribe's day breaks + r.hold(1.8) + poster_frame = r.n # poster: day breaks lit on folio 1v + r.hold(1.6) + r.glide_to(card("cr-1963-day-6")) # folio 2r + r.hold(2.2) + r.glide_to(card("cr-1963-seventh")) # Va-yekhullu: chapter 2 + r.hold(3.8) browser.close() - return Path(video), lead_in, poster_at + return poster_frame, r.video_time def main(): @@ -139,22 +210,18 @@ def main(): time.sleep(0.8) try: with tempfile.TemporaryDirectory() as tmp: - raw, lead_in, poster_at = record(base, Path(tmp)) + frames = Path(tmp) + poster_frame, dur = record(base, frames) OUT.mkdir(parents=True, exist_ok=True) - trim = ["-ss", f"{lead_in:.2f}", "-i", str(raw), "-an"] - subprocess.run(["ffmpeg", "-y", *trim, "-vf", "fps=30,scale=1280:-2", "-c:v", "libx264", - "-pix_fmt", "yuv420p", "-crf", "28", "-preset", "slow", "-movflags", "+faststart", + src = ["-framerate", str(FPS), "-i", str(frames / "%05d.jpg")] + subprocess.run(["ffmpeg", "-y", *src, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "26", + "-preset", "slow", "-movflags", "+faststart", str(OUT / "bereshit-walkthrough.mp4")], check=True, capture_output=True) - subprocess.run(["ffmpeg", "-y", *trim, "-vf", "fps=30", "-c:v", "libvpx-vp9", "-b:v", "0", - "-crf", "40", str(OUT / "bereshit-walkthrough.webm")], check=True, capture_output=True) + subprocess.run(["ffmpeg", "-y", *src, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "38", + "-row-mt", "1", str(OUT / "bereshit-walkthrough.webm")], check=True, capture_output=True) + shutil.copy(frames / f"{poster_frame:05d}.jpg", OUT / "bereshit-walkthrough.jpg") finally: server.terminate() - # Poster: the highlighted manuscript, the frame that sells it. - mp4 = OUT / "bereshit-walkthrough.mp4" - dur = float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(mp4)], - capture_output=True, text=True, check=True).stdout) - subprocess.run(["ffmpeg", "-y", "-ss", f"{poster_at:.2f}", "-i", str(mp4), "-frames:v", "1", "-q:v", "4", - str(OUT / "bereshit-walkthrough.jpg")], check=True, capture_output=True) for f in sorted(OUT.iterdir()): print(f"{f.relative_to(ROOT)} {f.stat().st_size / 1e6:.2f} MB") print(f"duration {dur:.1f}s")