Daily learning in Claude Code: Chitas (Chumash with Rashi, Tehillim, Tanya), Rambam, Hayom Yom and Daf Yomi (with Rashi) on one line above the prompt, and the…

Your daily learning inside Claude Code: today's Chitas, Rambam, Hayom Yom and Daf Yomi, and a Library of Mishnayos, Shas and Mishneh Torah to learn through at your own pace, in Hebrew that reads right. · Website
In a Claude Code session:
/plugin marketplace add sharshi/daily-learning-plugin
/plugin install dl@daily-learning
Then /dl opens the sidebar. Needs Claude Code 2.1.286+ and python3.

✡ 25 Tishrei 5787 · Parashat Bereshit 📅 Today's learning 📚 Library ▶ Ketubot 5 ×
One line above the prompt: today's date, 📅 Today's learning, 📚 Library, and ▶ where you left each Library collection. Each opens the sidebar there; × hides the line until tomorrow.
On a longer-running task, the same line offers to fill the wait:
⏳ Claude's working. Learn while you wait: ▶ Ketubot 5 📅 Chumash + Rashi later
▶ picks up where you left the Library; 📅 opens the next part of today you haven't read yet (or Today's learning, once you've opened them all). Either one opens the sidebar right there. later puts the launcher back.
In the sidebar, 📅 Today is a menu of the day's sections; each opens into its full text, and next › walks through the day in order:
📚 Library (l) is for learning through whole works:
Pick a collection, a seder or sefer, a masechta or hilchos, then a perek or daf. Continue picks up where you left each one, in any session; next › runs straight on into the next masechta. Texts load from Sefaria only when you open them.
All of it is on the sidebar's ⚙ Settings page (s), and in /config under dl.
/dl | Open the sidebar |
/dl-toggle | Hide or show the line above the prompt |
/dl:text | Print today's text in the chat, to ask Claude about it |
t l s | Today, Library, Settings |
1–7 | A section from today's menu |
j k | Next, previous (perek, amud, daf) |
Texts and calendar from Sefaria, the Hebrew date from hebcal, with chabad.org as a fallback.
claude --plugin-dir . # run from a clone; edits apply on /reload-plugins
claude plugin test . # tests
claude plugin validate . # what the mod does, and anything the engine would refuse
hooks/register.tsx is the mod (line, sidebar, fetching); hooks/data.ts, library.ts, hebrew.ts and svg.ts are pure helpers it draws with; scripts/dl.py fetches the day's text. An installed copy is a snapshot: bump version in .claude-plugin/plugin.json, then claude plugin update dl@daily-learning.
Code: MIT (LICENSE). Fonts: SIL Open Font License 1.1, Shlomo © SIL International and Shlomo Orbach, Frank Ruhl Libre © The Frank Ruhl Libre Project Authors (fonts/).
hooks/register.tsx 892 lines1// dl (Daily Learning): today's learning in a band above the prompt, and the full text in a
2// sidebar (/dl). Both are cached per Gregorian day, in memory and in
3// the mod's $.store. Data: ./data.ts; Hebrew layout: ./hebrew.ts.
4
5import { atom, read, update } from "claude-code";
6import type { EngineInterface as Host, Register, RenderChildren } from "claude-code";
7
8import type { Day, Status, TabId } from "../types";
9import { briefFrom, calendarUrl, DAY_V, hebcalUrl, hebrewDate, linesFor, todayKey, toDay } from "./data";
10import type { English } from "./data";
11import { COLL_ORDER, COLLS, findBook, libPart, parseShape, rashiRef, step, textRef, unitLabel, unitName } from "./library";
12import type { Coll, LibBook } from "./library";
13import type { Brief } from "./data";
14import { cleanHe, cleanText, termVisual, visual, wrap } from "./hebrew";
15import { svgPages } from "./svg";
16import type { EmbeddedFont, FontKey, Line } from "./svg";
17
18const PANE = "dl";
19const TABS: [TabId, string][] = [
20 ["chumash", "Chumash + Rashi"], ["tehillim", "Tehillim"],
21 ["tanya", "Tanya"], ["rambam1", "Rambam ×1"], ["rambam3", "Rambam ×3"], ["hayom", "Hayom Yom"],
22 ["daf", "Daf Yomi"],
23];
24
25
26// ---- pane state ---------------------------------------------------------
27const day = atom({ plugin: "dl", key: "day" } as const, null);
28const status = atom({ plugin: "dl", key: "status" } as const, null);
29const tab = atom({ plugin: "dl", key: "tab" } as const, null);
30const pageAtom = atom({ plugin: "dl", key: "page" } as const, "read");
31const libPos = atom({ plugin: "dl", key: "libPos" } as const, {});
32const libShapes = atom({ plugin: "dl", key: "libShapes" } as const, {});
33const libText = atom({ plugin: "dl", key: "libText" } as const, null);
34const libStatus = atom({ plugin: "dl", key: "libStatus" } as const, null);
35const libLast = atom({ plugin: "dl", key: "libLast" } as const, {});
36const prefsOver = atom({ plugin: "dl", key: "prefsOver" } as const, {});
37const todayOpened = atom({ plugin: "dl", key: "todayOpened" } as const, null);
38const perek = atom({ plugin: "dl", key: "perek" } as const, null);
39
40// ---- band state ---------------------------------------------------------
41let state: { key: string | null; data: Brief | null; error: string | null; loading: boolean } =
42 { key: null, data: null, error: null, loading: false };
43// The day the band was dismissed for (YYYY-MM-DD), kept in the store so new
44// sessions that day stay quiet; a new day brings the band back.
45let hiddenFor: string | null = null;
46
47async function setHidden($: Host, key: string | null) {
48 hiddenFor = key;
49 if (key) await $.store.set("hidden", key);
50 else await $.store.delete("hidden");
51 $.ui.invalidate("ui.render");
52}
53
54async function getJSON($: Host, url: string) {
55 const res = await $.http.fetch(url, {
56 headers: { "User-Agent": "daily-learning (+https://github.com/sharshi/daily-learning-plugin)", Accept: "application/json" },
57 });
58 if (!res.ok) throw new Error(`${res.status} ${url}`);
59 return JSON.parse(res.text);
60}
61
62// Fetch what the band needs for Gregorian day `d` and shape it.
63async function buildBrief($: Host, d: Date): Promise<Brief> {
64 const [heb, cal] = await Promise.all([getJSON($, hebcalUrl(d)), getJSON($, calendarUrl(d))]);
65 let monthHas30 = true;
66 if (+heb.hd === 29) {
67 const next = new Date(d); next.setDate(d.getDate() + 1);
68 try { monthHas30 = +(await getJSON($, hebcalUrl(next))).hd === 30; } catch {}
69 }
70 return briefFrom(d, hebrewDate(heb), cal, monthHas30);
71}
72
73// The band's brief for `key`: from memory, the store, or the network. The band
74// redraws when it lands.
75async function ensure($: Host, key: string) {
76 if (state.key === key && (state.data || state.loading)) return;
77 state = { key, data: null, error: null, loading: true };
78 const storeKey = `brief-${key}`;
79 try {
80 const cached = (await $.store.get(storeKey)) as Brief | undefined;
81 if (cached) {
82 state.data = cached;
83 return;
84 }
85 const data = await buildBrief($, new Date(`${key}T12:00:00`));
86 if (state.key !== key) return; // day rolled over mid-fetch
87 state.data = data;
88 // Keep only today's brief in the store.
89 for (const k of await $.store.keys()) if (k.startsWith("brief-") && k !== storeKey) await $.store.delete(k);
90 await $.store.set(storeKey, data);
91 } catch (e: any) {
92 if (state.key === key) state.error = e?.message || String(e);
93 } finally {
94 if (state.key === key) state.loading = false;
95 $.ui.invalidate("ui.render");
96 }
97}
98
99// The fetch in flight, by day. A module variable, not $.state: a reload drops
100// it with the fetch it tracked, so a "loading" left by an old module never
101// blocks the next load.
102let dayFetch: { key: string; done: Promise<void> } | null = null;
103
104function ensureDay($: Host) {
105 const key = todayKey();
106 if (dayFetch?.key !== key) dayFetch = { key, done: loadDay($, key) };
107 return dayFetch.done;
108}
109
110async function loadDay($: Host, key: string) {
111 const d0 = await read($, day);
112 if (d0?.key === key && d0.v === DAY_V) {
113 await update($, status, () => ({ key, phase: "ready" }) as Status);
114 return;
115 }
116 await update($, status, () => ({ key, phase: "loading" }) as Status);
117 const storeKey = `day-v${DAY_V}-${key}`;
118 try {
119 let d = (await $.store.get(storeKey)) as Day | undefined;
120 // Older builds kept a Day under day-<date>; the prune below removes it.
121 if (!d) {
122 const script = `${$.plugin.root}/scripts/dl.py`;
123 const r = await $.process.run(["python3", script, "--json", "--full"], { timeoutMs: 300_000 });
124 if (r.exitCode !== 0) throw new Error(r.stderr.trim().split("\n").pop() || `exit ${r.exitCode}`);
125 d = toDay(key, JSON.parse(r.stdout));
126 for (const k of await $.store.keys()) if (k.startsWith("day-") && k !== storeKey) await $.store.delete(k);
127 await $.store.set(storeKey, d);
128 }
129 await update($, day, () => d!);
130 await update($, status, () => ({ key, phase: "ready" }) as Status);
131 } catch (e: any) {
132 if (dayFetch?.key === key) dayFetch = null; // let the next open retry
133 await update($, status, () => ({ key, phase: "error", error: e?.message || String(e) }) as Status);
134 }
135}
136
137// A section Sefaria had no text for (Hayom Yom today, often) is left out of
138// the sidebar and the band instead of pointing at a page that does not load.
139const hasText = (d: Day, id: TabId) => (d.parts[id] ?? []).some((p) => p.he.length || p.en.length);
140const shownTabs = (d: Day | null) => TABS.filter(([id]) => !d || hasText(d, id));
141
142// Open the sidebar on a section of the day, or on the day's menu.
143async function openPane($: Host, t?: TabId) {
144 await update($, tab, () => t ?? null);
145 await update($, pageAtom, () => "read");
146 void ensureDay($);
147 await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
148}
149
150// Terminals with a bidi of their own, which get termVisual's order (see
151// hebrew.ts); every other terminal gets visual's.
152const RLM = "\u200F";
153const BIDI_TERMINALS = new Set(["Apple_Terminal"]);
154
155// The chosen font as WOFF, base64, for the Desktop app's SVG pages; null for
156// "System", until read, or if it can't be (the Desktop app then gets text).
157let embedded: EmbeddedFont | null = null;
158
159async function loadFont($: Host, choice: unknown) {
160 const f = FONTS[String(choice)];
161 if (!f) return;
162 try {
163 const r = await $.fs.read(`${$.plugin.root}/fonts/${f.file}.woff`, { as: "bytes" });
164 embedded = typeof r === "string" ? null : { key: f.key, base64: r.base64 };
165 $.ui.invalidate("ui.render");
166 } catch {}
167}
168
169// ---- library ----------------------------------------------------------------
170// Everything loads when first needed: a collection's structure when it is
171// opened (kept for good: it doesn't change), a unit's text when it is read
172// (the store keeps the last LIB_KEEP). One fetch at a time per thing.
173const LIB_KEEP = 12;
174const libFetching = new Map<string, Promise<void>>();
175const once = (what: string, run: () => Promise<void>) => {
176 if (!libFetching.has(what)) libFetching.set(what, run().finally(() => libFetching.delete(what)));
177 return libFetching.get(what)!;
178};
179// Text colours, as theme keys so they follow the person's light or dark theme:
180// the body in the theme's own text colour (not the terminal's default, which
181// a dark terminal under a light theme draws pale on the pane), amber titles
182// (yellow in dark, a darker amber in light), indigo Rashi, blue headings.
183const BODY = "text", TITLE = "warning", RASHI = "permission", ACCENT = "ide";
184
185const sefaria = (path: string) => `https://www.sefaria.org/api/${path}`;
186const enc = (s: string) => s.split("/").map(encodeURIComponent).join("/");
187
188function ensureShape($: Host, coll: Coll) {
189 return once(`shape:${coll}`, async () => {
190 if ((await read($, libShapes))[coll]) return;
191 await update($, libStatus, () => ({ what: COLLS[coll].name, phase: "loading" as const }));
192 try {
193 const key = `lib-shape-${coll}`;
194 let shape = (await $.store.get(key)) as ReturnType<typeof parseShape> | undefined;
195 if (!shape?.sections?.length) {
196 shape = parseShape(coll, await getJSON($, sefaria(`shape/${enc(COLLS[coll].shape)}`)));
197 await $.store.set(key, shape);
198 }
199 await update($, libShapes, (v) => ({ ...v, [coll]: shape }));
200 await update($, libStatus, () => null);
201 } catch (e: any) {
202 await update($, libStatus, () => ({ what: COLLS[coll].name, phase: "error" as const, error: e?.message || String(e) }));
203 }
204 });
205}
206
207function ensureLibText($: Host, coll: Coll, b: LibBook, unit: number) {
208 const ref = textRef(b, unit);
209 return once(`text:${ref}`, async () => {
210 if ((await read($, libText))?.ref === ref) return;
211 await update($, libStatus, () => ({ what: ref, phase: "loading" as const }));
212 try {
213 const key = `lib-text-${ref}`;
214 let part = (await $.store.get(key)) as ReturnType<typeof libPart> | undefined;
215 if (!part) {
216 const q = "?version=hebrew&version=english&return_format=text_only";
217 const [text, rashi] = await Promise.all([
218 getJSON($, sefaria(`v3/texts/${encodeURIComponent(ref)}${q}`)),
219 coll === "bavli" ? getJSON($, sefaria(`v3/texts/${encodeURIComponent(rashiRef(b, unit))}?version=hebrew&return_format=text_only`)).catch(() => undefined) : undefined,
220 ]);
221 part = libPart(coll, b, unit, text, rashi);
222 // Keep the newest LIB_KEEP texts.
223 const recent = (((await $.store.get("lib-recent")) as string[] | undefined) ?? []).filter((k) => k !== key);
224 recent.unshift(key);
225 for (const old of recent.splice(LIB_KEEP)) await $.store.delete(old);
226 await $.store.set("lib-recent", recent);
227 await $.store.set(key, part);
228 }
229 await update($, libText, () => ({ ref, part: part! }));
230 await update($, libStatus, () => null);
231 } catch (e: any) {
232 await update($, libStatus, () => ({ what: ref, phase: "error" as const, error: e?.message || String(e) }));
233 }
234 });
235}
236
237// Open a unit: move the menu there, remember it as where this collection was
238// last read, and load its text.
239async function openUnit($: Host, coll: Coll, b: LibBook, unit: number) {
240 await update($, libPos, () => ({ coll, section: b.section, book: b.title, unit }));
241 const at = await $.clock.now();
242 const last = await update($, libLast, (v) => ({ ...v, [coll]: { book: b.title, unit, at } }));
243 await $.store.set("lib-last", last);
244 void ensureLibText($, coll, b, unit);
245}
246
247// Where a collection was last read, as "Ketubot 5" (the book's short name).
248const lastLabel = (l: { book: string; unit: number }) => `${l.book.replace(/^(Mishnah|Mishneh Torah,) /, "")} ${l.unit}`;
249
250// Continue a collection where it was left: load its structure if need be,
251// open the unit, and show it in the Library.
252async function continueLib($: Host, c: Coll) {
253 const l = (await read($, libLast))[c];
254 if (!l) return;
255 await ensureShape($, c);
256 const sh = (await read($, libShapes))[c];
257 const bk = sh ? findBook(sh, l.book) : undefined;
258 if (bk) await openUnit($, c, bk, l.unit);
259}
260
261// ---- while Claude works -----------------------------------------------------
262// A turn that runs long turns the line above the prompt into a suggestion to
263// learn meanwhile: where the Library was left, or the next section of today
264// not yet opened. Gone when the turn ends; at most once every 30 minutes;
265// never on a day the line was hidden; never takes the keyboard.
266const WAIT_MS: Record<string, number> = { "After 45 seconds": 45_000, "After 2 minutes": 120_000 };
267const SUGGEST_GAP_MS = 30 * 60_000;
268let waitTimer: { cancel: () => void } | null = null;
269let suggesting = false; // the suggestion is on the line now
270let suggestedTurn = false; // it was shown during this turn
271
272async function startWait($: Host, setting: unknown) {
273 waitTimer?.cancel();
274 waitTimer = null;
275 const ms = WAIT_MS[String(setting ?? "After 45 seconds")];
276 if (!ms) return;
277 waitTimer = $.clock.after(ms, () => void suggest($));
278}
279
280async function suggest($: Host) {
281 waitTimer = null;
282 if (hiddenFor === todayKey()) return;
283 const now = await $.clock.now();
284 const last = (await $.store.get("suggest-last")) as number | undefined;
285 if (last != null && now - last < SUGGEST_GAP_MS) return;
286 await $.store.set("suggest-last", now);
287 suggesting = suggestedTurn = true;
288 $.ui.invalidate("ui.render");
289}
290
291function endWait($: Host) {
292 waitTimer?.cancel();
293 waitTimer = null;
294 if (suggesting) { suggesting = false; $.ui.invalidate("ui.render"); }
295 if (suggestedTurn) { suggestedTurn = false; $.ui.toast("Daily Learning: Claude is done."); }
296}
297
298// Note a section of today as opened (the suggestion offers the next one not).
299async function markOpened($: Host, id: TabId) {
300 const key = todayKey();
301 const v = await update($, todayOpened, (o) => {
302 const ids = o?.key === key ? o.ids : [];
303 return { key, ids: ids.includes(id) ? ids : [...ids, id] };
304 });
305 await $.store.set("today-opened", v);
306}
307
308// ---- settings -------------------------------------------------------------
309// The sidebar's settings are the plugin's own (userConfig): they persist, show
310// in /config, and the settings page changes them with $.config.set, which
311// reloads the mod with the new values.
312const ENGLISH: Record<string, English> = { "Off": "off", "Staggered": "staggered", "Side by side": "side" };
313const SIZES: Record<string, number> = { "Small": 0.88, "Medium": 1, "Large": 1.18 };
314
315type Prefs = { font: string; english: English; nikkud: boolean; rashi: boolean; size: string };
316function prefsOf(o: Record<string, unknown>): Prefs {
317 return {
318 font: String(o.hebrew_font ?? "Frank Ruhl"),
319 english: ENGLISH[String(o.english)] ?? "off",
320 nikkud: o.nikkud !== false,
321 rashi: o.rashi !== false,
322 size: SIZES[String(o.text_size)] ? String(o.text_size) : "Medium",
323 };
324}
325
326// A setting's /config row key. Builds name plugin rows differently
327// (`dl.nikkud`, `dl@daily-learning.nikkud`, ...), so it's looked up in the
328// list by the owning plugin and the field rather than guessed.
329async function rowKey($: Host, field: string) {
330 const rows = await $.config.list();
331 const ours = (r: (typeof rows)[number]) => /^dl(@|$)/.test(String(r.provider?.plugin ?? "")) || /^dl[@.]/.test(r.key);
332 const tail = new RegExp(`(^|[^A-Za-z0-9_])${field}$`);
333 return (rows.find((r) => r.key === `dl.${field}`)
334 ?? rows.find((r) => ours(r) && tail.test(r.key))
335 ?? rows.find((r) => tail.test(r.key) && /(^|[^A-Za-z0-9_])dl([^A-Za-z0-9_]|$)/.test(r.key)))?.key;
336}
337
338const PREF_FIELDS = ["hebrew_font", "english", "nikkud", "rashi", "text_size", "wait_suggest"];
339const optionsKey = (o: Record<string, unknown>) => JSON.stringify(PREF_FIELDS.map((f) => o[f] ?? null));
340
341// Change one setting from the sidebar. It takes effect at once and is kept in
342// the plugin's store, over the plugin's options; it is also written to
343// /config where this build lists the plugin's rows there (the Desktop app's
344// may not), which reloads the mod with it. A later change in /config wins.
345async function setPref($: Host, field: string, value: string | boolean) {
346 const over = await update($, prefsOver, (v) => ({ ...v, [field]: value }));
347 await $.store.set("prefs-over", over);
348 if (field === "hebrew_font") { void applyFont($, value); void loadFont($, value); }
349 try {
350 const key = await rowKey($, field);
351 if (key) await $.config.set({ key, value });
352 } catch {}
353}
354
355// ---- ui -----------------------------------------------------------------
356// The "Hebrew font" setting's choices: each font's key (for its letter widths),
357// its file under fonts/, and the family name inside it (Shlomo's is inherited
358// from SIL's Ezra).
359const FONTS: Record<string, { key: FontKey; file: string; family: string }> = {
360 "Shlomo": { key: "shlomo", file: "Shlomo", family: "Ezra SIL SR" },
361 "Frank Ruhl": { key: "frank", file: "FrankRuhlLibre-Regular", family: "Frank Ruhl Libre" },
362};
363
364// Install the chosen font and map Ghostty's Hebrew to it, or take the mapping
365// back out for "System". Says so only when something changed.
366async function applyFont($: Host, choice: unknown) {
367 const script = `${$.plugin.root}/scripts/install-font.sh`;
368 const f = FONTS[String(choice)];
369 const argv = f ? ["/bin/sh", script, f.file, f.family] : ["/bin/sh", script, "--remove"];
370 try {
371 const r = await $.process.run(argv, { timeoutMs: 30_000 });
372 const changed = r.stdout.split("\n").filter((l) => l.startsWith("changed:"));
373 if (r.exitCode !== 0)
374 $.ui.toast(`Daily Learning: Hebrew font setting failed: ${r.stderr.trim().split("\n").pop() || `install-font.sh exited ${r.exitCode}`}`);
375 else if (changed.length)
376 $.ui.toast(`Daily Learning: ${changed.map((l) => l.slice(9)).join("; ")}. Reload Ghostty's config (cmd+shift+,) to see it.`);
377 } catch (err: any) {
378 $.ui.toast(`Daily Learning: Hebrew font setting failed: ${err?.message || err}`);
379 }
380}
381
382export const register: Register = (on, options) => {
383 const opts = options as Record<string, unknown>;
384 on("session.start", async ($, e, next) => {
385 // Sidebar choices stand until /config changes one of the plugin's options.
386 let over = ((await $.store.get("prefs-over")) as Record<string, string | boolean> | undefined) ?? {};
387 if ((await $.store.get("prefs-seen")) !== optionsKey(opts)) {
388 over = {};
389 await $.store.set("prefs-over", over);
390 await $.store.set("prefs-seen", optionsKey(opts));
391 }
392 await update($, prefsOver, () => over);
393 const font = over.hebrew_font ?? options.hebrew_font;
394 void applyFont($, font);
395 void loadFont($, font);
396 const last = (await $.store.get("lib-last")) as Partial<Record<Coll, { book: string; unit: number; at?: number }>> | undefined;
397 if (last) await update($, libLast, () => last);
398 const opened = (await $.store.get("today-opened")) as { key: string; ids: TabId[] } | undefined;
399 if (opened?.key === todayKey()) await update($, todayOpened, () => opened);
400 hiddenFor = ((await $.store.get("hidden")) as string | undefined) ?? null;
401 await $.command.register({ name: "dl-toggle", description: "Show or hide the daily learning line above the prompt" });
402 await $.command.register({ name: "dl", description: "Open today's learning in a sidebar" });
403 void ensure($, todayKey());
404 void ensureDay($); // warm the sidebar text: from the store when cached
405 return next(e);
406 });
407
408 // A turn's start and end drive the suggestion to learn meanwhile.
409 on("prompt.submit", async ($, e, next) => {
410 const over = await read($, prefsOver);
411 void startWait($, over.wait_suggest ?? options.wait_suggest);
412 return next(e);
413 });
414 on("turn.complete", async ($, e, next) => {
415 endWait($);
416 return next(e);
417 });
418
419 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
420 const { Box, Text, Button } = $.ui.resolve(e);
421 const key = todayKey();
422 if (hiddenFor === key) return next(e);
423 const last = await read($, libLast);
424 void ensure($, key); // no-op unless the date rolled over
425
426 if (state.loading && !state.data)
427 return <Box paddingX={1}><Text dimColor>✡ dl · loading…</Text></Box>;
428 if (state.error && !state.data)
429 return <Box paddingX={1}><Text color="red">✡ dl · {state.error}</Text></Box>;
430 if (!state.data) return next(e);
431
432 // While a long turn runs: the suggestion, in place of the launcher.
433 if (suggesting) {
434 const [dd, opened] = await Promise.all([read($, day), read($, todayOpened)]);
435 const recent = COLL_ORDER.filter((c) => last?.[c]).sort((a, b) => (last![b]!.at ?? 0) - (last![a]!.at ?? 0))[0];
436 const today = dd?.key === key ? shownTabs(dd) : [];
437 const done = opened?.key === key ? opened.ids : [];
438 const nextToday = today.find(([id]) => !done.includes(id));
439 const go = async (run: () => Promise<unknown>) => {
440 suggesting = false;
441 await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
442 await run();
443 $.ui.invalidate("ui.render");
444 };
445 return (
446 <Box paddingX={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
447 <Text color={TITLE}>⏳ Claude's working. Learn while you wait:</Text>
448 {recent ? <Button key="wait-lib" plain label={`▶ ${lastLabel(last![recent]!)}`}
449 onPress={() => go(async () => { await continueLib($, recent); await update($, pageAtom, () => "library"); })} /> : null}
450 {nextToday
451 ? <Button key="wait-today" plain label={`📅 ${nextToday[1]}`}
452 onPress={() => go(async () => { void ensureDay($); await update($, tab, () => nextToday[0]); await update($, pageAtom, () => "read"); await markOpened($, nextToday[0]); })} />
453 : <Button key="wait-today" plain label="📅 Today's learning"
454 onPress={() => go(async () => { void ensureDay($); await update($, tab, () => null); await update($, pageAtom, () => "read"); })} />}
455 <Button key="wait-later" plain label="later" onPress={() => { suggesting = false; $.ui.invalidate("ui.render"); }} />
456 </Box>
457 );
458 }
459
460 // One line, a launcher: the date, then Today's learning, the Library, and
461 // a continue button for each Library collection being read; × hides the
462 // line for the rest of the day.
463 const { heb } = state.data;
464 const date = `✡ ${heb.hd} ${heb.hm} ${heb.hy}` + (heb.events.length ? ` · ${heb.events.join(", ")}` : "");
465 const show = async (to: "read" | "library") => {
466 if (to === "read") void ensureDay($);
467 await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
468 if (to === "read") await update($, tab, () => null);
469 else await update($, libPos, () => ({}));
470 await update($, pageAtom, () => to);
471 };
472 return (
473 <Box paddingX={1} flexDirection="row" flexWrap="wrap" columnGap={3}>
474 <Text key="date" color={ACCENT}>{date}</Text>
475 <Button key="band-today" plain label="📅 Today's learning" onPress={() => show("read")} />
476 <Button key="band-library" plain label="📚 Library" onPress={() => show("library")} />
477 {COLL_ORDER.filter((c) => last?.[c]).map((c) => (
478 <Button key={`band-lib-${c}`} plain label={`▶ ${lastLabel(last![c]!)}`} onPress={async () => {
479 await $.ui.open({ id: PANE, title: "Daily Learning", focus: true });
480 await continueLib($, c);
481 await update($, pageAtom, () => "library");
482 }} />
483 ))}
484 <Button key="dismiss" plain role="dismiss" label="×" onPress={() => setHidden($, key)} />
485 </Box>
486 );
487 });
488
489 // /dl-toggle hides the line for today, or brings it back.
490 on("command.run", { command: "dl-toggle" }, async ($) => {
491 const key = todayKey();
492 const hide = hiddenFor !== key;
493 await setHidden($, hide ? key : null);
494 return { text: hide ? "Hidden for today (/dl-toggle shows it again)." : "Shown." };
495 });
496
497 // /dl opens the sidebar and fetches the text in the background.
498 on("command.run", { command: "dl" }, async ($) => {
499 await openPane($);
500 return { text: "Opened the sidebar. Keys: t Today · l Library · s Settings · number keys pick from today's menu · j/k next/previous · ↑↓ scroll · Esc back to prompt." };
501 });
502
503 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
504 const { Box, Text, Button } = $.ui.resolve(e);
505 const Svg = e.surface === "desktop" ? $.ui.resolve(e).Svg : null;
506 const [d, st, t, pk, pg, lp, shapes, lt, ls, ll, po] = await Promise.all([
507 read($, day), read($, status), read($, tab), read($, perek), read($, pageAtom),
508 read($, libPos), read($, libShapes), read($, libText), read($, libStatus), read($, libLast), read($, prefsOver),
509 ]);
510 const prefs = prefsOf({ ...opts, ...po });
511 const nk = prefs.nikkud;
512 // Load on sight: a render never writes state, so start it on a timer.
513 if ((d?.key !== todayKey() || d.v !== DAY_V) && st?.phase !== "error") $.clock.after(0, () => void ensureDay($));
514 const width = Math.max(20, e.props.bodyColumns - 1);
515 // Where the Hebrew is drawn decides its order: reversed here for a terminal
516 // with no bidi, word order only for one that reverses each word, and reading
517 // order for the Desktop app, which lays it out itself.
518 const term = e.surface === "terminal" ? await $.env.get("TERM_PROGRAM") : undefined;
519 const isBidiTerminal = e.surface === "terminal" && BIDI_TERMINALS.has(term ?? "");
520 const isFlipped = e.surface === "terminal" && !isBidiTerminal;
521 // How a line of Hebrew words is ordered for this surface. The Desktop app
522 // lays Hebrew out itself but in a left-to-right line, so each line is
523 // wrapped in right-to-left marks to keep edge punctuation on the Hebrew side.
524 const order = isFlipped ? visual : isBidiTerminal ? termVisual : (ws: string[]) => RLM + ws.join(" ") + RLM;
525
526 // A Hebrew paragraph, wrapped here and right-aligned line by line, each line
527 // ordered for the surface. `label` (a halacha's or a verse's "א.") leads the
528 // paragraph in bold, at the right end of its first line.
529 const he = (s: string, k: string, color?: string, label?: string, w = width) => {
530 const txt = cleanHe(s, nk);
531 if (!txt) return null;
532 const lines = wrap(label ? `${label} ${txt}` : txt, w);
533 return (
534 <Box key={k} flexDirection="column" alignItems="flex-end">
535 {lines.map((ws, li) =>
536 label && li === 0
537 ? <Box flexDirection="row">
538 <Text color={color ?? BODY}>{order(ws.slice(1))} </Text>
539 <Text bold color={color ?? BODY}>{order(ws.slice(0, 1))}</Text>
540 </Box>
541 : <Text wrap="truncate" color={color ?? BODY}>{order(ws)}</Text>)}
542 </Box>
543 );
544 };
545 const en = (s: string, k: string, color?: string, label?: string) => {
546 const txt = cleanText(s);
547 if (!txt) return null;
548 return (
549 <Box key={k} flexDirection="row">
550 {label ? <Text bold dimColor={!color} color={color ?? BODY}>{label} </Text> : null}
551 <Text dimColor={!color} color={color ?? BODY}>{txt}</Text>
552 </Box>
553 );
554 };
555 // A bold chapter heading, right-aligned like the Hebrew under it.
556 const heading = (s: string, k: string) => (
557 <Box key={k} flexDirection="column" alignItems="flex-end">
558 <Text bold color={ACCENT}>{order(s.split(" "))}</Text>
559 </Box>
560 );
561
562 // A line of the body drawn as terminal text; a pair puts the English in
563 // the left half and the Hebrew in the right.
564 const half = Math.max(10, Math.floor((width - 2) / 2));
565 const draw = (l: Line, k: string) =>
566 l.kind === "gap" ? <Text key={k}> </Text>
567 : l.kind === "heading" ? heading(l.text, k)
568 : l.kind === "he" ? he(l.text, k, l.rashi ? RASHI : undefined, l.label)
569 : l.kind === "en" ? en(l.text, k, l.rashi ? RASHI : undefined, l.label)
570 : (
571 <Box key={k} flexDirection="row" justifyContent="space-between">
572 <Box width={half} flexDirection="column">{en(l.en, `${k}e`, l.rashi ? RASHI : undefined, l.enLabel)}</Box>
573 <Box width={half} flexDirection="column">{he(l.he, `${k}h`, l.rashi ? RASHI : undefined, l.label, half)}</Box>
574 </Box>
575 );
576
577 // The body's lines in the Desktop app: one SVG with the Hebrew font in it,
578 // or text when the font isn't at hand or the page is too big for an SVG.
579 const page = (lines: Line[], k: string) => {
580 if (e.surface === "desktop" && embedded) {
581 // About 7.8 CSS pixels a column, so the page draws near its real size
582 // and its text sits with the app's own.
583 const svgs = svgPages(lines.map((l) =>
584 l.kind === "he" ? { ...l, text: cleanHe(l.text, nk) }
585 : l.kind === "heading" ? { ...l, text: cleanHe(l.text, nk) }
586 : l.kind === "en" ? { ...l, text: cleanText(l.text) }
587 : l.kind === "pair" ? { ...l, he: cleanHe(l.he, nk), en: cleanText(l.en) } : l),
588 embedded, { width: (width + 1) * 7.8, scale: SIZES[prefs.size]! });
589 if (svgs && Svg) return svgs.map((src, i) => <Svg key={`${k}svg${i}`} source={src} alt="The day's text in Hebrew" />);
590 }
591 return lines.map((l, i) => draw(l, `${k}l${i}`));
592 };
593
594 // The settings page: each setting a row of choices; a press writes it.
595 const settings = () => {
596 const row = (field: string, title: string, choices: [string, string | boolean][], current: string | boolean, hint: string) => (
597 <Box key={`set-${field}`} flexDirection="column" marginBottom={1}>
598 <Text bold color={BODY}>{title}</Text>
599 <Box flexDirection="row" flexWrap="wrap">
600 {choices.map(([label, value]) => (
601 <Button key={`set-${field}-${label.replace(/\W+/g, "-").toLowerCase()}`} label={label}
602 variant={value === current ? "primary" : "secondary"} dimColor={value !== current}
603 onPress={() => value === current ? undefined : setPref($, field, value)} />
604 ))}
605 </Box>
606 <Text dimColor>{hint}</Text>
607 </Box>
608 );
609 const onOff: [string, boolean][] = [["On", true], ["Off", false]];
610 return [
611 row("hebrew_font", "Hebrew font", [["Frank Ruhl", "Frank Ruhl"], ["Shlomo", "Shlomo"], ["System", "System"]], prefs.font,
612 "Drawn in the Desktop app; installed and mapped in Ghostty. System leaves fonts alone."),
613 row("english", "English", [["Off", "Off"], ["Staggered", "Staggered"], ["Side by side", "Side by side"]],
614 Object.keys(ENGLISH).find((k) => ENGLISH[k] === prefs.english)!, "Staggered: under each paragraph. Side by side: English left, Hebrew right."),
615 row("nikkud", "Nikkud", onOff, prefs.nikkud, "The Hebrew with or without vowels."),
616 row("rashi", "Rashi", onOff, prefs.rashi, "Under each verse of Chumash and each passage of the Daf."),
617 row("wait_suggest", "While Claude works", [["After 45 seconds", "After 45 seconds"], ["After 2 minutes", "After 2 minutes"], ["Off", "Off"]],
618 String((po.wait_suggest ?? opts.wait_suggest) || "After 45 seconds"), "When a turn runs long, suggest learning on the line above the prompt (at most every 30 minutes)."),
619 row("text_size", "Text size", [["Small", "Small"], ["Medium", "Medium"], ["Large", "Large"]], prefs.size,
620 e.surface === "terminal" ? "For the Desktop app; a terminal uses its own size." : "The sidebar's text in the Desktop app."),
621 <Button key="settings-done" dimColor label="‹ Back to the text" onPress={() => update($, pageAtom, () => "read")} />,
622 ];
623 };
624
625 // The Library: collection › section › book › unit, one level at a time,
626 // each loaded when first opened. Breadcrumbs go back up; Continue goes to
627 // where a collection was last read; next and previous cross into the
628 // next book.
629 const library = () => {
630 // Hebrew in a label goes through the surface's ordering, like the text.
631 const heb = (x: string) => order(x.split(" "));
632 const pos = lp ?? {};
633 const go = (p: typeof pos) => update($, libPos, () => p);
634 const coll = pos.coll;
635 const shape = coll ? shapes?.[coll] : undefined;
636 if (coll && !shape) $.clock.after(0, () => void ensureShape($, coll));
637 const book = shape ? findBook(shape, pos.book) : undefined;
638 if (coll && book && pos.unit != null && lt?.ref !== textRef(book, pos.unit) && ls?.phase !== "error")
639 $.clock.after(0, () => void ensureLibText($, coll, book, pos.unit!));
640
641 const crumbs = [
642 <Button key="crumb-library" plain dimColor label="📚 Library" onPress={() => go({})} />,
643 ...(coll ? [<Text key="c1" dimColor> › </Text>, <Button key="crumb-coll" plain dimColor label={COLLS[coll].name} onPress={() => go({ coll })} />] : []),
644 ...(coll && pos.section && (shape?.sections.length ?? 0) > 1
645 ? [<Text key="c2" dimColor> › </Text>, <Button key="crumb-section" plain dimColor label={pos.section} onPress={() => go({ coll, section: pos.section })} />] : []),
646 ...(book ? [<Text key="c3" dimColor> › </Text>, <Button key="crumb-book" plain dimColor label={book.name} onPress={() => go({ coll, section: pos.section, book: book.title })} />] : []),
647 ];
648 const head = <Box key="crumbs" flexDirection="row" flexWrap="wrap" marginBottom={1}>{crumbs}</Box>;
649 const loading = ls && ls.phase === "loading" ? <Text key="loading" dimColor>Loading {ls.what}…</Text> : null;
650 const failed = ls && ls.phase === "error"
651 ? <Box key="failed" flexDirection="column"><Text color="red">Could not load {ls.what}: {ls.error}</Text>
652 <Button key="retry" variant="primary" label="Try again" onPress={async () => { await update($, libStatus, () => null); }} /></Box>
653 : null;
654 // "Continue: Berakhot 5" for a collection read before.
655 const cont = (c: Coll) => {
656 const l = ll?.[c];
657 if (!l) return null;
658 const s2 = shapes?.[c], b2 = s2 ? findBook(s2, l.book) : undefined;
659 const label = b2 ? `Continue ${unitLabel(b2, l.unit)}` : `Continue ${lastLabel(l)}`;
660 return <Button key={`continue-${c}`} variant="primary" label={label} onPress={() => continueLib($, c)} />;
661 };
662
663 // Collections.
664 if (!coll) return [
665 head,
666 ...COLL_ORDER.map((c) => (
667 <Box key={`coll-${c}`} flexDirection="column" marginBottom={1}>
668 <Box flexDirection="row">
669 <Button key={`open-${c}`} dimColor label={`${COLLS[c].name} · ${heb(COLLS[c].he)}`} onPress={() => go({ coll: c })} />
670 {cont(c)}
671 </Box>
672 <Text dimColor>{COLLS[c].blurb}</Text>
673 </Box>
674 )),
675 ];
676 if (!shape) return [head, failed ?? loading ?? <Text key="wait" dimColor>Loading {COLLS[coll].name}…</Text>];
677
678 // Sections (sedarim, sefarim); skipped when there is only one.
679 const section = pos.section ?? (shape.sections.length === 1 ? shape.sections[0]!.name : undefined);
680 if (!section) return [
681 head,
682 <Box key="cont" flexDirection="row">{cont(coll)}</Box>,
683 ...shape.sections.map((sec) => (
684 <Button key={`section-${sec.name.replace(/\W+/g, "-")}`} dimColor label={`${sec.name} · ${heb(sec.he)} (${sec.books.length})`}
685 onPress={() => go({ coll, section: sec.name })} />
686 )),
687 ];
688
689 // Books (masechtos, hilchos).
690 if (!book) {
691 const sec = shape.sections.find((x) => x.name === section);
692 return [
693 head,
694 ...(sec?.books ?? []).map((b) => (
695 <Button key={`book-${b.title.replace(/\W+/g, "-")}`}
696 label={`${b.name} · ${heb(b.he)} (${b.last - b.first + 1} ${COLLS[coll].unit === "daf" ? "dapim" : "perakim"})`}
697 variant={ll?.[coll]?.book === b.title ? "primary" : "secondary"} dimColor={ll?.[coll]?.book !== b.title}
698 onPress={() => go({ coll, section, book: b.title })} />
699 )),
700 ];
701 }
702
703 // Units (perakim, dapim): a grid, the last one read marked.
704 if (pos.unit == null) {
705 const lastHere = ll?.[coll]?.book === book.title ? ll[coll]!.unit : undefined;
706 return [
707 head,
708 <Text key="pick" dimColor>{book.name} · {heb(book.he)}: pick a {COLLS[coll].unit}</Text>,
709 <Box key="grid" flexDirection="row" flexWrap="wrap">
710 {Array.from({ length: book.last - book.first + 1 }, (_, i) => book.first + i).map((u) => (
711 <Button key={`unit-${u}`} label={heb(unitName(coll, u).split(" ")[1]!)}
712 variant={u === lastHere ? "primary" : "secondary"} dimColor={u !== lastHere}
713 onPress={() => openUnit($, coll, book, u)} />
714 ))}
715 </Box>,
716 ];
717 }
718
719 // Reading: the unit's text, with previous and next across books.
720 const unit = pos.unit;
721 const prev = step(shape, book.title, unit, -1), next = step(shape, book.title, unit, 1);
722 const nav = (where: "top" | "end") => (
723 <Box key={`libnav-${where}`} flexDirection="row" justifyContent="space-between">
724 {prev ? <Button key={`lib-prev-${where}`} plain dimColor hotkey={where === "end" ? "k" : undefined}
725 label={`‹ ${unitLabel(prev.book, prev.unit)}`} onPress={() => openUnit($, coll, prev.book, prev.unit)} /> : <Text> </Text>}
726 <Text dimColor>{unitLabel(book, unit)}</Text>
727 {next ? <Button key={`lib-next-${where}`} plain hotkey={where === "end" ? "j" : undefined} variant="primary"
728 label={`${unitLabel(next.book, next.unit)} ›`} onPress={() => openUnit($, coll, next.book, next.unit)} /> : <Text dimColor>end ✓</Text>}
729 </Box>
730 );
731 const part = lt?.ref === textRef(book, unit) ? lt.part : null;
732 const lines = part ? (part.sections ?? []).flatMap((_, i) => linesFor(part, i, { english: prefs.english, rashi: prefs.rashi })) : [];
733 return [
734 head,
735 nav("top"),
736 ...(part ? [<Text key="ltitle" color={TITLE} bold>{part.title}</Text>, <Text key="lsp"> </Text>]
737 : [failed ?? loading ?? <Text key="lwait" dimColor>Loading {unitLabel(book, unit)}…</Text>]),
738 ...(part ? page(lines, "lib") : []),
739 nav("end"),
740 ];
741 };
742
743 const openTab = async (id: TabId | null) => {
744 await update($, tab, () => id);
745 await update($, pageAtom, () => "read");
746 if (id) await markOpened($, id);
747 };
748
749 // Today: a menu of the day's sections, each with what it is today.
750 const todayMenu = (shown: [TabId, string][]) => [
751 <Text key="today-title" bold color={BODY}>Today's learning</Text>,
752 <Text key="today-sp"> </Text>,
753 ...shown.map(([id, label], i) => (
754 <Box key={`today-${id}`} flexDirection="column" marginBottom={1}>
755 <Button key={`tab-${id}`} dimColor label={label} hotkey={String(i + 1)} onPress={() => openTab(id)} />
756 <Text dimColor wrap="truncate"> {d!.parts[id]?.[0]?.title ?? ""}</Text>
757 </Box>
758 )),
759 ];
760
761 const body = () => {
762 if (pg === "settings") return settings();
763 if (pg === "library") return library();
764 if (!d || d.key !== todayKey()) {
765 if (st?.phase === "error") return <Text color="red">Could not load: {st.error}</Text>;
766 return <Text dimColor>Loading today's text… (the first fetch takes a little while)</Text>;
767 }
768 const shown = shownTabs(d);
769 // No section chosen (or one hidden today, or kept from an older build): the menu.
770 const ti = shown.findIndex(([id]) => id === t);
771 if (ti < 0) return todayMenu(shown);
772 const tabId = shown[ti]![0];
773 const parts = d.parts[tabId] ?? [];
774 // Back to the menu, and on to the day's previous or next section.
775 const crumbs = (
776 <Box key="today-crumbs" flexDirection="row" marginBottom={1}>
777 <Button key="crumb-today" plain dimColor label="📅 Today" onPress={() => openTab(null)} />
778 <Text dimColor> › </Text><Text color={BODY}>{shown[ti]![1]}</Text>
779 </Box>
780 );
781 const prevTab = shown[ti - 1], nextTab = shown[ti + 1];
782 const dayNav = (
783 <Box key="day-nav" flexDirection="row" justifyContent="space-between" marginTop={1}>
784 {prevTab ? <Button key="day-prev" plain dimColor label={`‹ ${prevTab[1]}`} onPress={() => openTab(prevTab[0])} /> : <Text> </Text>}
785 {nextTab ? <Button key="day-next" variant="primary" label={`${nextTab[1]} ›`} onPress={() => openTab(nextTab[0])} />
786 : <Button key="day-done" variant="primary" label="Today's menu ›" onPress={() => openTab(null)} />}
787 </Box>
788 );
789 if (!parts.length) return [crumbs, <Text key="none" dimColor>Nothing listed for this today.</Text>, dayNav];
790 return [crumbs, ...parts.map((p, pi) => {
791 const sections = p.sections ?? [];
792 const n = sections.length;
793 const at = Math.min(pk?.key === d.key ? pk.at[tabId] ?? 0 : 0, Math.max(n - 1, 0));
794 const go = (i: number) => update($, perek, (v) =>
795 ({ key: d.key, at: { ...(v?.key === d.key ? v.at : {}), [tabId]: i } }));
796 // Previous and next section (perek, amud), above the text and below it.
797 const nav = (where: "top" | "end") => n > 1 ? (
798 <Box key={`${pi}nav-${where}`} flexDirection="row" justifyContent="space-between">
799 {at > 0
800 ? <Button key={`prev-${where}`} plain dimColor hotkey={where === "end" ? "k" : undefined}
801 label={`‹ ${p.unit} ${sections[at - 1]!.short}`} onPress={() => go(at - 1)} />
802 : <Text> </Text>}
803 <Text dimColor>{p.unit} {at + 1} of {n}</Text>
804 {at < n - 1
805 ? <Button key={`next-${where}`} hotkey={where === "end" ? "j" : undefined}
806 variant="primary" label={`next ${p.unit} ${sections[at + 1]!.short} ›`} onPress={() => go(at + 1)} />
807 : <Text dimColor>done ✓</Text>}
808 </Box>
809 ) : null;
810 const empty = !p.he.length && !p.en.length;
811 return (
812 <Box key={`p${pi}`} flexDirection="column" marginBottom={1}>
813 <Text color={TITLE} bold>{p.title}</Text>
814 {empty && <Text dimColor>No text from Sefaria for this one; read it on chabad.org:</Text>}
815 {empty && (p.chabad || p.link) && <Text dimColor>{p.chabad || p.link}</Text>}
816 <Text> </Text>
817 {nav("top")}
818 {page(linesFor(p, at, { english: prefs.english, rashi: prefs.rashi }), `p${pi}`)}
819 {nav("end")}
820 </Box>
821 );
822 }), dayNav];
823 };
824
825 const hebDate = d?.hebrew ? cleanHe(d.hebrew, nk) : "";
826 // Missing text hides its tab, so its "... text: 404" line says nothing useful.
827 const issues = (d?.errors ?? []).filter((x) => !/ text: /.test(x));
828 // The header: date, tabs, toggles. Rows are packed here (a Button draws as
829 // "[ label ]", a plain one as "k: label") so the header's height is known.
830 const pack = <T,>(items: T[], w: (x: T) => number) => {
831 const rows: T[][] = [];
832 let row: T[] = [], n = 0;
833 for (const x of items) {
834 if (row.length && n + w(x) > width + 1) { rows.push(row); row = []; n = 0; }
835 row.push(x); n += w(x);
836 }
837 if (row.length) rows.push(row);
838 return rows;
839 };
840 // Where to go: today's learning, the Library, the settings. The current one is marked.
841 const navItems: [string, string, "read" | "library" | "settings", string][] = [
842 ["today", "📅 Today", "read", "t"], ["library", "📚 Library", "library", "l"], ["settings", "⚙ Settings", "settings", "s"],
843 ];
844 const navRows = pack(navItems, ([, label]) => label.length + 4);
845 const header = [
846 <Box key="date" flexDirection="row" justifyContent="space-between">
847 <Text color={ACCENT} bold wrap="truncate">✡ {d?.title ?? "Daily Learning"}</Text>
848 {hebDate ? <Text color={ACCENT} wrap="truncate">{order(hebDate.split(" "))}</Text> : null}
849 </Box>,
850 ...navRows.map((row, r) => (
851 <Box key={`nav${r}`} flexDirection="row">
852 {row.map(([key, label, to, hotkey]) => (
853 <Button key={key} label={label} hotkey={hotkey}
854 variant={pg === to ? "primary" : "secondary"} dimColor={pg !== to}
855 onPress={() => to === "read" && pg === "read" ? openTab(null) : update($, pageAtom, () => to)} />
856 ))}
857 </Box>
858 )),
859 e.surface === "terminal" ? <Text key="rule" dimColor>{"─".repeat(width)}</Text> : <Text key="rule"> </Text>,
860 ];
861
862 // In the terminal the header stays put: it is drawn over the body at the
863 // window's current top (the pane redraws as it scrolls), on a layer of
864 // spaces that blanks the text scrolling beneath it, and a spacer of the
865 // same height keeps the body's first rows clear of it.
866 if (e.surface === "terminal") {
867 const top = e.props.scroll?.offset ?? 0;
868 const tall = header.length; // not `h`: that name is JSX's element factory
869 return (
870 <Box flexDirection="column">
871 {Array.from({ length: tall }, (_, i) => <Text key={`gap${i}`}> </Text>)}
872 {body()}
873 {issues.length ? <Text dimColor>Issues: {issues.join("; ")}</Text> : null}
874 <Box key="blank" position="absolute" top={top} left={0} flexDirection="column">
875 {Array.from({ length: tall }, (_, i) => <Text key={`blank${i}`}>{" ".repeat(width + 1)}</Text>)}
876 </Box>
877 <Box key="header" position="absolute" top={top} left={0} flexDirection="column" width={width + 1}>
878 {header}
879 </Box>
880 </Box>
881 );
882 }
883 return (
884 <Box flexDirection="column">
885 {header}
886 {body()}
887 {issues.length ? <Text dimColor>Issues: {issues.join("; ")}</Text> : null}
888 </Box>
889 );
890 });
891};
892hooks/data.ts 226 lines1// The day's learning, shaped for drawing. Pure: the fetching happens in
2// register.tsx, the one file the engine lets call $.
3// - the band: hebcal.com (Hebrew date) and the Sefaria calendar;
4// - the sidebar: scripts/dl.py --json --full, shaped into a Day.
5
6import type { Day, Part, Section, TabId } from "../types";
7import { gematria } from "./hebrew";
8import type { Line } from "./svg";
9
10const ALIYOT = ["Rishon", "Sheini", "Shlishi", "Revi'i", "Chamishi", "Shishi", "Shvi'i"];
11const TEHILLIM: Record<number, string> = {
12 1: "1-9", 2: "10-17", 3: "18-22", 4: "23-28", 5: "29-34", 6: "35-38", 7: "39-43",
13 8: "44-48", 9: "49-54", 10: "55-59", 11: "60-65", 12: "66-68", 13: "69-71",
14 14: "72-76", 15: "77-78", 16: "79-82", 17: "83-87", 18: "88-89", 19: "90-96",
15 20: "97-103", 21: "104-105", 22: "106-107", 23: "108-112", 24: "113-118",
16 25: "119:1-96", 26: "119:97-176", 27: "120-134", 28: "135-139", 29: "140-144",
17 30: "145-150",
18};
19const HEB_MONTH: Record<string, string> = { "Sh'vat": "Shevat", Iyyar: "Iyar", Tamuz: "Tammuz" };
20
21// Shape version of a cached Day: bump it when Day changes, and old caches refetch.
22export const DAY_V = 8;
23
24// What the band shows: the Hebrew date and one [label, ref] row per section.
25export type Brief = {
26 heb: { hd: number; hm: string; hy: number; hebrew: string; events: string[] };
27 sections: [string, string][];
28};
29
30// The local Gregorian date as YYYY-MM-DD: what caches are keyed by.
31export function todayKey(d = new Date()) {
32 const p = (n: number) => String(n).padStart(2, "0");
33 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
34}
35
36// ---- band ---------------------------------------------------------------
37export const hebcalUrl = (d: Date) =>
38 `https://www.hebcal.com/converter?cfg=json&g2h=1&gy=${d.getFullYear()}&gm=${d.getMonth() + 1}&gd=${d.getDate()}`;
39export const calendarUrl = (d: Date) =>
40 `https://www.sefaria.org/api/calendars?year=${d.getFullYear()}&month=${d.getMonth() + 1}&day=${d.getDate()}&diaspora=1`;
41
42// hebcal's converter reply as the band's Hebrew date.
43export function hebrewDate(j: any): Brief["heb"] {
44 return { hd: +j.hd, hm: j.hm, hy: +j.hy, hebrew: j.hebrew || "", events: j.events || [] };
45}
46
47// The band's rows for Gregorian day `d`, from its Hebrew date and Sefaria
48// calendar. `monthHas30` says whether this Hebrew month has a 30th day: on a
49// 29th without one, Tehillim finishes the book (140-150).
50export function briefFrom(d: Date, heb: Brief["heb"], cal: any, monthHas30 = true): Brief {
51 const items = Object.fromEntries((cal.calendar_items || []).map((i: any) => [i.title.en, i]));
52 const out: Brief = { heb, sections: [] };
53
54 const par = items["Parashat Hashavua"];
55 if (par) {
56 const wd = d.getDay(); // Sun=0 … Sat=6
57 const aliyot = par.extraDetails?.aliyot || [];
58 const ref = aliyot[wd] || par.ref;
59 out.sections.push(["Chumash", `${par.displayValue.en} · ${ALIYOT[wd]} (${ref})`]);
60 }
61
62 const teh = heb.hd === 29 && !monthHas30 ? "140-150" : TEHILLIM[heb.hd];
63 out.sections.push(["Tehillim", `${teh} (day ${heb.hd})`]);
64
65 const tanya = items["Tanya Yomi"];
66 if (tanya) out.sections.push(["Tanya", tanya.displayValue.en]);
67
68 const r3 = items["Daily Rambam (3 Chapters)"], r1 = items["Daily Rambam"];
69 if (r3) out.sections.push(["Rambam ×3", r3.displayValue.en]);
70 if (r1) out.sections.push(["Rambam ×1", r1.displayValue.en]);
71
72 out.sections.push(["Hayom Yom", `${HEB_MONTH[heb.hm] || heb.hm} ${heb.hd}`]);
73
74 const daf = items["Daf Yomi"];
75 if (daf) out.sections.push(["Daf Yomi", daf.displayValue.en]);
76 return out;
77}
78
79// ---- sidebar ------------------------------------------------------------
80// Sefaria nests chapters as arrays of arrays; flatten to paragraphs.
81function flat(v: unknown): string[] {
82 if (v == null) return [];
83 if (Array.isArray(v)) return v.flatMap(flat);
84 return [String(v)];
85}
86
87function part(title: string, s: any, text: any): Part {
88 return { title, link: s?.link, chabad: s?.chabad, he: flat(text?.he), en: flat(text?.en) };
89}
90
91// Each verse of a Chumash reading numbered as Rambam's halachot are ("כא.",
92// "21."), with a heading where each chapter starts (פרק ד) as Rambam's perakim
93// have. From the ref's start ("Genesis 2:20-3:21") and the text's chapters,
94// which a range spanning several needs; none when the counts don't add up.
95export function verseLabels(ref: string, text: any): NonNullable<Part["labels"]> | undefined {
96 const m = /(\d+):(\d+)(-\d+:\d+)?(?:-\d+)?$/.exec(ref ?? "");
97 const verses = flat(text?.he).length;
98 if (!m || !verses) return undefined;
99 const chapters: number[] = Array.isArray(text?.he_chapters) && text.he_chapters.length
100 ? text.he_chapters.map((c: unknown[]) => c.length)
101 : m[3] ? [] : [verses];
102 if (chapters.reduce((a, b) => a + b, 0) !== verses) return undefined;
103 const out: NonNullable<Part["labels"]> = [];
104 chapters.forEach((n, ci) => {
105 const c = Number(m[1]) + ci;
106 for (let k = 0; k < n; k++) {
107 const v = (ci === 0 ? Number(m[2]) : 1) + k;
108 out.push({ he: `${gematria(v)}.`, en: `${v}.`, ...(k === 0 ? { heading: `פרק ${gematria(c)}` } : {}) });
109 }
110 });
111 return out;
112}
113
114// A Rambam reading, one perek at a time, its halachot numbered; the first
115// perek's number comes from the ref ("Mishneh Torah, Damages to Property 12-14" → 12).
116function rambamPart(title: string, r: any, chabad: string): Part {
117 const p = part(title, { ...r, chabad }, r.text);
118 const he: string[][] = r.text?.he_chapters ?? [];
119 const en: string[][] = r.text?.en_chapters ?? [];
120 const first = Number(/(\d+)(?:-\d+)?$/.exec(r.ref ?? "")?.[1] ?? 1);
121 if (he.length) {
122 p.sections = he.map((h, i): Section => ({
123 name: `פרק ${gematria(first + i)}`, short: gematria(first + i), he: h, en: en[i] ?? [],
124 }));
125 p.unit = "perek";
126 p.numbered = true;
127 }
128 return p;
129}
130
131// The daf, one amud at a time ("18a" → דף יח., "18b" → דף יח:), each passage
132// with its Rashi.
133function dafPart(d: any): Part {
134 const amudim: any[] = d.amudim ?? [];
135 const p: Part = { title: `Daf Yomi · ${d.display}`, link: d.link, he: [], en: [] };
136 if (!amudim.length) return p;
137 p.sections = amudim.map((a): Section => {
138 const m = /^(\d+)([ab])$/.exec(a.amud ?? "");
139 const short = m ? `${gematria(Number(m[1]))}${m[2] === "a" ? "." : ":"}` : String(a.amud ?? "");
140 return { name: `דף ${short}`, short, he: a.he ?? [], en: a.en ?? [], rashi: a.rashi_he ?? [] };
141 });
142 p.he = amudim.flatMap((a) => a.he ?? []);
143 p.en = amudim.flatMap((a) => a.en ?? []);
144 p.unit = "amud";
145 return p;
146}
147
148// dl.py's --json output as the Day the sidebar draws, one list of parts per tab.
149export function toDay(key: string, j: any): Day {
150 const s = j.sections || {};
151 const ch = s.chumash, rb = s.rambam || {};
152 const labels = ch && verseLabels(ch.ref, ch.text);
153 const parts: Record<TabId, Part[]> = {
154 chumash: ch ? [{
155 ...part(`${ch.parsha} · ${ch.aliyah} (${ch.ref}) with Rashi`, ch, ch.text),
156 ...(labels ? { labels } : {}),
157 ...(ch.rashi ? { rashi: { he: ch.rashi.he_verses || [], en: ch.rashi.en_verses || [] } } : {}),
158 }] : [],
159 tehillim: s.tehillim ? [part(`Tehillim ${s.tehillim.ref} (day ${s.tehillim.day_of_month})`, s.tehillim, s.tehillim.text)] : [],
160 // "Tanya, Part IV; Iggeret HaKodesh 25:1-5" → "Iggeret HaKodesh 25:1-5"
161 tanya: s.tanya ? [part(`Tanya · ${s.tanya.display} (${String(s.tanya.ref ?? "").split("; ").pop()})`, s.tanya, s.tanya.text)] : [],
162 rambam1: rb.one_perek ? [rambamPart(`Rambam ×1 · ${rb.one_perek.display}`, rb.one_perek, rb.chabad)] : [],
163 rambam3: rb.three_perakim ? [rambamPart(`Rambam ×3 · ${rb.three_perakim.display}`, rb.three_perakim, rb.chabad)] : [],
164 hayom: s.hayom_yom ? [part(s.hayom_yom.ref, s.hayom_yom, s.hayom_yom.text)] : [],
165 daf: s.daf_yomi ? [dafPart(s.daf_yomi)] : [],
166 };
167 const hd = j.hebrew || {};
168 return {
169 v: DAY_V,
170 key,
171 title: `${j.weekday} ${j.gregorian} · ${hd.hd} ${hd.hm} ${hd.hy}`,
172 hebrew: hd.hebrew || "",
173 parts,
174 errors: j.errors || [],
175 };
176}
177
178// How the body shows English and Rashi (the sidebar's settings).
179export type English = "off" | "staggered" | "side";
180export type LineOptions = { english: English; rashi: boolean };
181
182// What a part draws, line by line: for a sectioned part, section `at` under
183// its heading; otherwise verse by verse. Each paragraph is followed by its
184// English (staggered: under it; side: beside it) and its Rashi.
185export function linesFor(p: Part, at: number, opt: LineOptions): Line[] {
186 const out: Line[] = [];
187 const showEn = opt.english !== "off";
188 const para = (he: string[], en: string[], rashi: string[][] | undefined, rashiEn: string[][] | undefined,
189 numbered: boolean, labels?: Part["labels"]) => {
190 const paired = he.length === en.length;
191 // Rashi lines up with the paragraphs when it has no more entries than they
192 // do: Sefaria leaves the list short when the last ones have no Rashi.
193 const r = opt.rashi && rashi && rashi.length <= he.length ? rashi : null;
194 he.forEach((h, i) => {
195 if (labels?.[i]?.heading) out.push({ kind: "heading", text: labels[i]!.heading! });
196 const label = numbered ? `${gematria(i + 1)}.` : labels?.[i]?.he;
197 const enLabel = numbered ? `${i + 1}.` : labels?.[i]?.en;
198 if (opt.english === "side" && paired) {
199 out.push({ kind: "pair", he: h, en: en[i]!, ...(label ? { label, enLabel } : {}) });
200 } else {
201 out.push({ kind: "he", text: h, ...(label ? { label } : {}) });
202 if (showEn && paired) out.push({ kind: "en", text: en[i]!, ...(enLabel ? { label: enLabel } : {}) });
203 }
204 const rh = r?.[i] ?? [], re = showEn && r ? rashiEn?.[i] ?? [] : [];
205 if (opt.english === "side" && rh.length && rh.length === re.length) {
206 rh.forEach((c, j) => out.push({ kind: "pair", he: c, en: re[j]!, rashi: true }));
207 } else {
208 rh.forEach((c) => out.push({ kind: "he", text: c, rashi: true }));
209 re.forEach((c) => out.push({ kind: "en", text: c, rashi: true }));
210 }
211 out.push({ kind: "gap" });
212 });
213 if (showEn && !paired) en.forEach((x) => out.push({ kind: "en", text: x }, { kind: "gap" }));
214 // Rashi that did not line up with the paragraphs still shows, after them.
215 if (opt.rashi && rashi && !r) rashi.flat().forEach((c) => out.push({ kind: "he", text: c, rashi: true }));
216 };
217 if (p.sections?.length) {
218 const s = p.sections[Math.min(Math.max(at, 0), p.sections.length - 1)]!;
219 out.push({ kind: "heading", text: s.name });
220 para(s.he, s.en, s.rashi, undefined, !!p.numbered);
221 } else {
222 para(p.he, p.en, p.rashi?.he, p.rashi?.en, false, p.labels);
223 }
224 return out;
225}
226hooks/library.ts 117 lines1// The Library: whole works to learn through at your own pace (Mishnayos, Shas,
2// Mishneh Torah). Pure: the structure comes from Sefaria's shape API and the
3// text from its texts API, both fetched by register.tsx when first needed.
4
5import type { Coll, LibBook, LibSection, LibShape, Part, Section } from "../types";
6import { gematria } from "./hebrew";
7
8export type { Coll, LibBook, LibPos, LibSection, LibShape } from "../types";
9
10export const COLLS: Record<Coll, { name: string; he: string; shape: string; unit: "perek" | "daf"; blurb: string }> = {
11 mishnah: { name: "Mishnayos", he: "משניות", shape: "Mishnah", unit: "perek", blurb: "Six sedarim, 63 masechtos" },
12 bavli: { name: "Shas", he: "ש״ס בבלי", shape: "Bavli", unit: "daf", blurb: "Talmud Bavli, daf by daf, with Rashi" },
13 mt: { name: "Mishneh Torah", he: "משנה תורה", shape: "Halakhah/Mishneh Torah", unit: "perek", blurb: "The Rambam's 14 sefarim, perek by perek" },
14};
15export const COLL_ORDER: Coll[] = ["mishnah", "bavli", "mt"];
16
17// Section names in Hebrew: the sedarim, and the Rambam's sefarim.
18const SECTION_HE: Record<string, string> = {
19 "Seder Zeraim": "סדר זרעים", "Seder Moed": "סדר מועד", "Seder Nashim": "סדר נשים",
20 "Seder Nezikin": "סדר נזיקין", "Seder Kodashim": "סדר קדשים", "Seder Tahorot": "סדר טהרות",
21 "Introduction": "הקדמה", "Sefer Madda": "ספר המדע", "Sefer Ahavah": "ספר אהבה", "Sefer Zemanim": "ספר זמנים",
22 "Sefer Nashim": "ספר נשים", "Sefer Kedushah": "ספר קדושה", "Sefer Haflaah": "ספר הפלאה", "Sefer Zeraim": "ספר זרעים",
23 "Sefer Avodah": "ספר עבודה", "Sefer Korbanot": "ספר הקרבנות", "Sefer Taharah": "ספר טהרה", "Sefer Nezikim": "ספר נזיקים",
24 "Sefer Kinyan": "ספר קנין", "Sefer Mishpatim": "ספר משפטים", "Sefer Shoftim": "ספר שופטים",
25};
26
27// Sefaria's shape for a collection as sections of books, in Sefaria's order.
28// Bavli's `chapters` counts segments per amud from 1a, so a daf's first amud
29// is index 2(daf-1); a book's first daf is where its first text is.
30export function parseShape(coll: Coll, json: unknown): LibShape {
31 const sections: LibSection[] = [];
32 for (const x of (Array.isArray(json) ? json : []) as any[]) {
33 if (!x || typeof x.title !== "string" || x.isComplex) continue;
34 const ch = x.chapters;
35 let first = 1, last = typeof ch === "number" ? ch : Array.isArray(ch) ? ch.length : Number(x.length) || 0;
36 if (coll === "bavli" && Array.isArray(ch)) {
37 const i = ch.findIndex((n: unknown) => Number(n) > 0);
38 first = Math.floor(Math.max(i, 0) / 2) + 1;
39 last = Math.floor((ch.length - 1) / 2) + 1;
40 }
41 if (last < first) continue;
42 const name = coll === "mishnah" ? x.title.replace(/^Mishnah /, "")
43 : coll === "mt" ? x.title.replace(/^Mishneh Torah, /, "") : x.title;
44 const he = String(x.heTitle ?? x.heBook ?? "").replace(/^משנה תורה, /, "").replace(/^משנה /, "");
45 const sec = String(x.section ?? "");
46 let s = sections.find((y) => y.name === sec);
47 if (!s) sections.push(s = { name: sec, he: SECTION_HE[sec] ?? sec, books: [] });
48 s.books.push({ title: x.title, he, name, section: sec, first, last });
49 }
50 return { sections };
51}
52
53export const books = (shape: LibShape) => shape.sections.flatMap((s) => s.books);
54export const findBook = (shape: LibShape, title?: string) => books(shape).find((b) => b.title === title);
55
56// A unit's name: פרק ג, or דף ה.
57export const unitName = (coll: Coll, n: number) => `${COLLS[coll].unit === "daf" ? "דף" : "פרק"} ${gematria(n)}`;
58// The same in English, for buttons and titles: "Berakhot 5", "Sabbath 3".
59export const unitLabel = (b: LibBook, n: number) => `${b.name} ${n}`;
60
61// The next or previous unit, across books: after a book's last unit comes the
62// next book's first, and before its first, the previous book's last.
63export function step(shape: LibShape, title: string, unit: number, dir: 1 | -1): { book: LibBook; unit: number } | null {
64 const all = books(shape);
65 const i = all.findIndex((b) => b.title === title);
66 if (i < 0) return null;
67 const b = all[i]!;
68 const u = unit + dir;
69 if (u >= b.first && u <= b.last) return { book: b, unit: u };
70 const nb = all[i + dir];
71 return nb ? { book: nb, unit: dir > 0 ? nb.first : nb.last } : null;
72}
73
74// Sefaria refs for a unit, and for its Rashi (Shas only).
75export const textRef = (b: LibBook, unit: number) => `${b.title} ${unit}`;
76export const rashiRef = (b: LibBook, unit: number) => `Rashi on ${b.title} ${unit}`;
77
78// A texts API reply as he/en lists of paragraphs, by section when nested
79// (a daf's two amudim), with the ref Sefaria resolved ("Berakhot 64a").
80type Versions = { ref?: string; versions?: { language?: string; text?: unknown }[] };
81const flat = (v: unknown): string[] => v == null ? [] : Array.isArray(v) ? v.flatMap(flat) : [String(v)];
82function byLang(j: Versions) {
83 const out: { he: unknown; en: unknown } = { he: [], en: [] };
84 for (const v of j.versions ?? []) out[v.language === "he" ? "he" : "en"] = v.text ?? [];
85 return out;
86}
87const nested = (t: unknown): unknown[][] =>
88 Array.isArray(t) && t.length && t.every((x) => Array.isArray(x)) ? (t as unknown[][]) : [Array.isArray(t) ? t : []];
89
90// The Part the sidebar draws for one unit. A perek is one numbered section; a
91// daf is its amudim, each passage with its Rashi.
92export function libPart(coll: Coll, b: LibBook, unit: number, text: Versions, rashi?: Versions): Part {
93 const { he, en } = byLang(text);
94 const title = `${COLLS[coll].name} · ${unitLabel(b, unit)}`;
95 const link = `https://www.sefaria.org/${encodeURIComponent(textRef(b, unit).replace(/ /g, "_"))}`;
96 if (coll !== "bavli") {
97 const s: Section = { name: `${b.he} ${unitName(coll, unit)}`, short: gematria(unit), he: flat(he), en: flat(en) };
98 return { title, link, he: s.he, en: s.en, sections: [s], numbered: true };
99 }
100 // A daf: one section per amud.
101 // Sefaria resolves a one-amud daf to "64a"; then the text is one amud, and
102 // Rashi's lists are per passage, not per amud.
103 const single = /\d+([ab])$/.exec(text.ref ?? "")?.[1];
104 const heA = nested(he), enA = nested(en);
105 const rHe = rashi ? byLang(rashi).he : [];
106 const rA = single ? [Array.isArray(rHe) ? rHe : []] : nested(rHe);
107 const sections: Section[] = heA.map((h, i) => {
108 const side = single ?? (i === 0 ? "a" : "b");
109 const short = `${gematria(unit)}${side === "a" ? "." : ":"}`;
110 return {
111 name: `${b.he} דף ${short}`, short, he: flat(h), en: flat(enA[i]),
112 rashi: (rA[i] ?? []).map((seg) => flat(seg)),
113 };
114 }).filter((s) => s.he.length || s.en.length);
115 return { title, link, he: sections.flatMap((s) => s.he), en: sections.flatMap((s) => s.en), sections, unit: "amud" };
116}
117hooks/hebrew.ts 108 lines1// Hebrew text for a terminal: cleanup, mark order, wrapping, and the three ways
2// a line of Hebrew is ordered for the surface that draws it. Pure functions.
3
4// Combining marks: te'amim (cantillation) and nikkud (vowels, dagesh, shin dots).
5const MARK = /[\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7]/;
6const TEAMIM = /[\u0591-\u05AF\u05BD]/g;
7const NIKKUD = /[\u05B0-\u05BC\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7]/g;
8const HEB = /[\u05D0-\u05EA]/;
9const MIRROR: Record<string, string> = { "(": ")", ")": "(", "[": "]", "]": "[", "{": "}", "}": "{", "<": ">", ">": "<" };
10
11// Sefaria text to plain text: no HTML, entities, or {ס}/{פ} section markers.
12export function cleanText(s: string) {
13 return s
14 .replace(/<br\s*\/?>/gi, " ")
15 .replace(/<[^>]+>/g, "")
16 .replace(/ | | | /g, " ")
17 .replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'")
18 .replace(/&[a-z]+;/g, "")
19 .replace(/\{[\u05E1\u05E4]\}/g, "")
20 .replace(/\s+/g, " ")
21 .trim();
22}
23
24// Hebrew for display: te'amim and paseq dropped, nikkud kept or dropped, marks
25// in the order fonts expect.
26export function cleanHe(s: string, withNikkud: boolean) {
27 let t = cleanText(s).replace(TEAMIM, "").replace(/\s*\u05C0\s*/g, " ");
28 if (!withNikkud) t = t.replace(NIKKUD, "");
29 return markOrder(t);
30}
31
32// Unicode's canonical order puts a letter's vowel before its dagesh (בְּ is
33// bet, sheva, dagesh), but terminals don't reorder marks the way text shapers
34// do, and fonts place a dagesh or shin dot right only when it comes first.
35// So each letter's marks go: shin/sin dot, dagesh, rafe, then the rest.
36const MARK_RANK: Record<string, number> = { "\u05C1": 0, "\u05C2": 0, "\u05BC": 1, "\u05BF": 2 };
37const rank = (m: string) => MARK_RANK[m] ?? 3;
38export function markOrder(t: string) {
39 return t.replace(/([\u05D0-\u05EA\uFB1D-\uFB4F])([\u0591-\u05C7]+)/g, (_, base: string, marks: string) =>
40 base + [...marks].map((m, i) => [m, i] as const)
41 .sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]).map(([m]) => m).join(""));
42}
43
44// A number in Hebrew letters, as halachot and chapters are counted: יא, טו, טז, קכ.
45export function gematria(n: number) {
46 const units = ["", "א", "ב", "ג", "ד", "ה", "ו", "ז", "ח", "ט"];
47 const tens = ["", "י", "כ", "ל", "מ", "נ", "ס", "ע", "פ", "צ"];
48 const hundreds = ["", "ק", "ר", "ש", "ת"];
49 const out = hundreds[Math.min(4, Math.floor(n / 100))] ?? "";
50 const rest = n % 100;
51 if (rest === 15) return out + "טו";
52 if (rest === 16) return out + "טז";
53 return out + tens[Math.floor(rest / 10)] + units[rest % 10];
54}
55
56// Base letters with their marks, so reversing keeps each vowel on its letter.
57export function clusters(w: string) {
58 const out: string[] = [];
59 for (const ch of w) {
60 if (MARK.test(ch) && out.length) out[out.length - 1] += ch;
61 else out.push(ch);
62 }
63 return out;
64}
65
66// Words into lines of at most `width` cells; marks take no cell.
67export function wrap(s: string, width: number) {
68 const lines: string[][] = [];
69 let line: string[] = [], n = 0;
70 for (const w of s.split(" ").filter(Boolean)) {
71 const c = clusters(w).length;
72 if (line.length && n + 1 + c > width) { lines.push(line); line = []; n = 0; }
73 n += (line.length ? 1 : 0) + c;
74 line.push(w);
75 }
76 if (line.length) lines.push(line);
77 return lines;
78}
79
80// Visual order for a terminal that draws everything left to right (Ghostty):
81// words right to left, letters inside a Hebrew word reversed, brackets
82// mirrored, numbers and Latin kept as is.
83export function visual(words: string[]) {
84 return words
85 .slice()
86 .reverse()
87 .map((w) => (HEB.test(w) ? clusters(w).reverse().map((c) => MIRROR[c] ?? c).join("") : w))
88 .join(" ");
89}
90
91// Visual order for a terminal that turns each word around itself (macOS
92// Terminal, which inside Claude Code gets the screen a word at a time): words
93// right to left, letters as written, and the punctuation at each end of a word
94// moved to the other end, mirrored, since the terminal leaves it where it is
95// sent. No direction controls (LRM, RLM, overrides): Terminal draws them as boxes.
96export function termVisual(words: string[]) {
97 const across = (p: string) => [...p].reverse().map((c) => MIRROR[c] ?? c).join("");
98 return words
99 .slice()
100 .reverse()
101 .map((w) => {
102 if (!HEB.test(w)) return w;
103 const m = /^([^\u05D0-\u05EA\uFB1D-\uFB4F]*)(.*?)([^\u05D0-\u05EA\uFB1D-\uFB4F\u0591-\u05C7]*)$/su.exec(w)!;
104 return across(m[3]!) + m[2] + across(m[1]!);
105 })
106 .join(" ");
107}
108hooks/svg.ts 162 lines1// The Desktop app's Hebrew: one SVG per reading, with the chosen font embedded,
2// so the app's own text engine shapes the nikkud and lays the lines right to
3// left. The terminal draws the same Line list as text (see register.tsx).
4
5import { ADVANCE } from "./font-metrics";
6
7// A bundled font: which (for its letter widths), and its WOFF as base64.
8export type FontKey = keyof typeof ADVANCE;
9export type EmbeddedFont = { key: FontKey; base64: string };
10
11// One line of the sidebar body, before it is drawn. A "pair" is a paragraph
12// with its English beside it (English on the left, Hebrew on the right).
13export type Line =
14 | { kind: "he"; text: string; label?: string; rashi?: boolean }
15 | { kind: "en"; text: string; label?: string; rashi?: boolean }
16 | { kind: "pair"; he: string; en: string; label?: string; enLabel?: string; rashi?: boolean }
17 | { kind: "heading"; text: string }
18 | { kind: "gap" };
19
20// How the page is drawn: its width in CSS pixels (the sidebar's, so the SVG
21// draws at about its real size) and a text-size factor (1 = medium).
22export type PageOptions = { width: number; scale: number };
23
24// An Svg element's source is capped at 131072 characters.
25export const SVG_LIMIT = 131072;
26
27const PAD = 6;
28const GUTTER = 18;
29const EN_EM = 0.5; // the system sans: about half an em a character
30
31const MARK = /[\u0591-\u05C7]/;
32const xml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
33
34// A Hebrew word's width in ems, by the font's own advances; marks take none.
35function ems(w: string, adv: Record<number, number>) {
36 let n = 0;
37 for (const ch of w) if (!MARK.test(ch)) n += adv[ch.codePointAt(0)!] ?? 0.55;
38 return n;
39}
40
41// Words into lines no wider than `max`, each word measured by `measure`.
42function fit(words: string[], max: number, measure: (w: string) => number, space: number) {
43 const lines: string[][] = [];
44 let line: string[] = [], n = 0;
45 for (const w of words) {
46 const c = measure(w);
47 if (line.length && n + space + c > max) { lines.push(line); line = []; n = 0; }
48 n += (line.length ? space : 0) + c;
49 line.push(w);
50 }
51 if (line.length) lines.push(line);
52 return lines;
53}
54
55const words = (text: string, label?: string) => [...(label ? [label] : []), ...text.split(" ").filter(Boolean)];
56
57// The page as SVGs: one when it fits an Svg element, else several, split
58// between paragraphs, each with the font. Null when even one paragraph alone
59// is too big (the caller then draws it as text).
60export function svgPages(lines: Line[], font: EmbeddedFont, opt: PageOptions = { width: 560, scale: 1 }): string[] | null {
61 const adv = ADVANCE[font.key];
62 const W = Math.max(280, Math.round(opt.width));
63 // Sizes chosen to sit with the app's own ~14px text: Hebrew a little larger,
64 // since a Hebrew face looks smaller than a Latin one at the same size.
65 const s = opt.scale;
66 const HE = { size: 18 * s, line: 30 * s };
67 const EN = { size: 14 * s, line: 21 * s };
68 const HEADING = { size: 19 * s, line: 32 * s };
69 const GAP = 10 * s;
70 const at = (n: number) => Math.round(n);
71
72 const heLines = (text: string, label: string | undefined, width: number) =>
73 fit(words(text, label), width / HE.size, (w) => ems(w, adv), adv[0x20] ?? 0.3);
74 const enLines = (text: string, label: string | undefined, width: number) =>
75 fit(words(text, label), width / (EN.size * EN_EM), (w) => w.length, 1);
76 // Classes: h Hebrew, e English, t heading, r Rashi, b bold.
77 const text = (cls: string, ws: string[], first: boolean, label: string | undefined, x: number, y: number) => {
78 const head = label && first ? `<tspan class="b">${xml(ws[0]!)}</tspan> ` : "";
79 const rest = xml((label && first ? ws.slice(1) : ws).join(" "));
80 return `<text class="${cls}" x="${at(x)}" y="${at(y)}">${head}${rest}</text>`;
81 };
82
83 // Each paragraph (up to a gap) becomes a block of text drawn from y = 0.
84 type Block = { body: string[]; height: number };
85 const blocks: Block[] = [];
86 let cur: Block = { body: [], height: 0 };
87 const right = W - PAD;
88 const full = W - 2 * PAD;
89 const half = (full - GUTTER) / 2;
90 for (const l of lines) {
91 let y = cur.height;
92 if (l.kind === "gap") {
93 cur.height += GAP;
94 blocks.push(cur); cur = { body: [], height: 0 };
95 continue;
96 }
97 if (l.kind === "heading") {
98 y += HEADING.line;
99 cur.body.push(`<text class="t" x="${right}" y="${at(y - HEADING.line * 0.3)}">${xml(l.text)}</text>`);
100 } else if (l.kind === "he") {
101 heLines(l.text, l.label, full).forEach((ws, i) => {
102 y += HE.line;
103 cur.body.push(text(l.rashi ? "h r" : "h", ws, i === 0, l.label, right, y - HE.line * 0.3));
104 });
105 } else if (l.kind === "en") {
106 enLines(l.text, l.label, full).forEach((ws, i) => {
107 y += EN.line;
108 cur.body.push(text(l.rashi ? "e r" : "e", ws, i === 0, l.label, PAD, y - EN.line * 0.28));
109 });
110 } else {
111 // Side by side: both columns start on the same row; the taller sets the height.
112 const y0 = y;
113 const he = heLines(l.he, l.label, half);
114 const en = enLines(l.en, l.enLabel, half);
115 he.forEach((ws, i) => cur.body.push(text(l.rashi ? "h r" : "h", ws, i === 0, l.label, right, y0 + (i + 1) * HE.line - HE.line * 0.3)));
116 en.forEach((ws, i) => cur.body.push(text(l.rashi ? "e r" : "e", ws, i === 0, l.enLabel, PAD, y0 + (i + 1) * EN.line - EN.line * 0.28)));
117 y = y0 + Math.max(he.length * HE.line, en.length * EN.line);
118 }
119 cur.height = y;
120 }
121 if (cur.body.length || cur.height) blocks.push(cur);
122
123 const style =
124 `<style>` +
125 `@font-face{font-family:DLHebrew;src:url(data:font/woff;base64,${font.base64}) format("woff")}` +
126 `.h,.t{font-family:DLHebrew,"SF Hebrew","Arial Hebrew",serif;direction:rtl}` +
127 `.h{font-size:${at(HE.size)}px;fill:#1f1f1f}.t{font-size:${at(HEADING.size)}px;font-weight:bold;fill:#2b8aa6}` +
128 `.e{font-family:-apple-system,system-ui,sans-serif;font-size:${at(EN.size)}px;fill:#6b6b6b}` +
129 `.r{fill:#4b5bd6}.b{font-weight:bold}` +
130 `@media (prefers-color-scheme:dark){.h{fill:#e8e8e8}.e{fill:#a3a3a3}.r{fill:#b1b9f9}.t{fill:#62c4e0}}` +
131 `</style>`;
132 const svg = (parts: string[], height: number) => {
133 const h = Math.ceil(height + 2 * PAD);
134 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}">${style}` +
135 `<g transform="translate(0 ${PAD})">${parts.join("")}</g></svg>`;
136 };
137
138 // Pack the blocks into as few SVGs as fit.
139 const room = SVG_LIMIT - svg([], 0).length - 64;
140 const pages: string[] = [];
141 let parts: string[] = [], height = 0, size = 0;
142 for (const b of blocks) {
143 const g = `<g transform="translate(0 ${at(height)})">${b.body.join("")}</g>`;
144 if (g.length > room) return null;
145 if (size + g.length > room && parts.length) {
146 pages.push(svg(parts, height)); parts = []; height = 0; size = 0;
147 const g2 = `<g transform="translate(0 0)">${b.body.join("")}</g>`;
148 parts.push(g2); size += g2.length; height += b.height;
149 continue;
150 }
151 parts.push(g); size += g.length; height += b.height;
152 }
153 if (parts.length) pages.push(svg(parts, height));
154 return pages;
155}
156
157// The page as one SVG, or null when it needs more than one (tests, mostly).
158export function svgPage(lines: Line[], font: EmbeddedFont, opt?: PageOptions): string | null {
159 const pages = svgPages(lines, font, opt);
160 return pages && pages.length === 1 ? pages[0]! : null;
161}
162hooks/font-metrics.ts 262 lines1// Generated by scripts/make-woff.py from fonts/*.ttf: each character's advance
2// width, in ems, per bundled font, for wrapping Hebrew in the Desktop app.
3// Combining marks take no width; a character missing here is taken as 0.55em.
4export const ADVANCE: Record<"shlomo" | "frank", Record<number, number>> = {
5 shlomo: {
6 0x0020: 0.342,
7 0x0021: 0.433,
8 0x0022: 0.333,
9 0x0023: 0.743,
10 0x0024: 0.563,
11 0x0025: 0.861,
12 0x0026: 0.713,
13 0x0027: 0.171,
14 0x0028: 0.537,
15 0x0029: 0.537,
16 0x002A: 0.504,
17 0x002B: 0.833,
18 0x002C: 0.211,
19 0x002D: 0.321,
20 0x002E: 0.289,
21 0x002F: 0.501,
22 0x0030: 0.554,
23 0x0031: 0.554,
24 0x0032: 0.554,
25 0x0033: 0.554,
26 0x0034: 0.554,
27 0x0035: 0.554,
28 0x0036: 0.554,
29 0x0037: 0.554,
30 0x0038: 0.554,
31 0x0039: 0.554,
32 0x003A: 0.293,
33 0x003B: 0.289,
34 0x003C: 0.833,
35 0x003D: 0.833,
36 0x003E: 0.833,
37 0x003F: 0.523,
38 0x0040: 0.936,
39 0x0041: 0.664,
40 0x0042: 0.609,
41 0x0043: 0.633,
42 0x0044: 0.698,
43 0x0045: 0.585,
44 0x0046: 0.542,
45 0x0047: 0.698,
46 0x0048: 0.743,
47 0x0049: 0.33,
48 0x004A: 0.479,
49 0x004B: 0.638,
50 0x004C: 0.526,
51 0x004D: 0.871,
52 0x004E: 0.716,
53 0x004F: 0.736,
54 0x0050: 0.565,
55 0x0051: 0.742,
56 0x0052: 0.65,
57 0x0053: 0.53,
58 0x0054: 0.599,
59 0x0055: 0.704,
60 0x0056: 0.666,
61 0x0057: 0.931,
62 0x0058: 0.633,
63 0x0059: 0.625,
64 0x005A: 0.587,
65 0x005B: 0.32,
66 0x005C: 0.501,
67 0x005D: 0.32,
68 0x005E: 0.958,
69 0x005F: 0.5,
70 0x0060: 0.35,
71 0x0061: 0.512,
72 0x0062: 0.55,
73 0x0063: 0.47,
74 0x0064: 0.562,
75 0x0065: 0.495,
76 0x0066: 0.326,
77 0x0067: 0.53,
78 0x0068: 0.572,
79 0x0069: 0.303,
80 0x006A: 0.308,
81 0x006B: 0.539,
82 0x006C: 0.297,
83 0x006D: 0.855,
84 0x006E: 0.577,
85 0x006F: 0.548,
86 0x0070: 0.561,
87 0x0071: 0.542,
88 0x0072: 0.396,
89 0x0073: 0.409,
90 0x0074: 0.34,
91 0x0075: 0.569,
92 0x0076: 0.529,
93 0x0077: 0.783,
94 0x0078: 0.513,
95 0x0079: 0.53,
96 0x007A: 0.475,
97 0x007B: 0.624,
98 0x007C: 0.5,
99 0x007D: 0.624,
100 0x007E: 0.833,
101 0x05BE: 0.465,
102 0x05C3: 0.397,
103 0x05D0: 0.596,
104 0x05D1: 0.665,
105 0x05D2: 0.362,
106 0x05D3: 0.674,
107 0x05D4: 0.674,
108 0x05D5: 0.337,
109 0x05D6: 0.345,
110 0x05D7: 0.674,
111 0x05D8: 0.662,
112 0x05D9: 0.337,
113 0x05DA: 0.674,
114 0x05DB: 0.616,
115 0x05DC: 0.656,
116 0x05DD: 0.659,
117 0x05DE: 0.649,
118 0x05DF: 0.367,
119 0x05E0: 0.35,
120 0x05E1: 0.622,
121 0x05E2: 0.592,
122 0x05E3: 0.616,
123 0x05E4: 0.616,
124 0x05E5: 0.591,
125 0x05E6: 0.641,
126 0x05E7: 0.609,
127 0x05E8: 0.674,
128 0x05E9: 0.698,
129 0x05EA: 0.658,
130 0x05F3: 0.259,
131 0x05F4: 0.462,
132 },
133 frank: {
134 0x0020: 0.239,
135 0x0021: 0.285,
136 0x0022: 0.364,
137 0x0023: 0.497,
138 0x0024: 0.527,
139 0x0025: 0.859,
140 0x0026: 0.722,
141 0x0027: 0.212,
142 0x0028: 0.333,
143 0x0029: 0.333,
144 0x002A: 0.344,
145 0x002B: 0.532,
146 0x002C: 0.264,
147 0x002D: 0.343,
148 0x002E: 0.278,
149 0x002F: 0.32,
150 0x0030: 0.563,
151 0x0031: 0.47,
152 0x0032: 0.546,
153 0x0033: 0.545,
154 0x0034: 0.535,
155 0x0035: 0.529,
156 0x0036: 0.557,
157 0x0037: 0.52,
158 0x0038: 0.533,
159 0x0039: 0.557,
160 0x003A: 0.278,
161 0x003B: 0.273,
162 0x003C: 0.59,
163 0x003D: 0.536,
164 0x003E: 0.59,
165 0x003F: 0.467,
166 0x0040: 0.74,
167 0x0041: 0.646,
168 0x0042: 0.618,
169 0x0043: 0.651,
170 0x0044: 0.725,
171 0x0045: 0.587,
172 0x0046: 0.548,
173 0x0047: 0.664,
174 0x0048: 0.759,
175 0x0049: 0.345,
176 0x004A: 0.379,
177 0x004B: 0.639,
178 0x004C: 0.576,
179 0x004D: 0.838,
180 0x004E: 0.702,
181 0x004F: 0.71,
182 0x0050: 0.57,
183 0x0051: 0.71,
184 0x0052: 0.605,
185 0x0053: 0.521,
186 0x0054: 0.576,
187 0x0055: 0.734,
188 0x0056: 0.65,
189 0x0057: 0.912,
190 0x0058: 0.658,
191 0x0059: 0.619,
192 0x005A: 0.573,
193 0x005B: 0.292,
194 0x005C: 0.328,
195 0x005D: 0.292,
196 0x005E: 0.583,
197 0x005F: 0.497,
198 0x0060: 0.385,
199 0x0061: 0.49,
200 0x0062: 0.563,
201 0x0063: 0.483,
202 0x0064: 0.549,
203 0x0065: 0.506,
204 0x0066: 0.309,
205 0x0067: 0.566,
206 0x0068: 0.579,
207 0x0069: 0.296,
208 0x006A: 0.24,
209 0x006B: 0.518,
210 0x006C: 0.281,
211 0x006D: 0.856,
212 0x006E: 0.587,
213 0x006F: 0.531,
214 0x0070: 0.563,
215 0x0071: 0.537,
216 0x0072: 0.395,
217 0x0073: 0.441,
218 0x0074: 0.303,
219 0x0075: 0.583,
220 0x0076: 0.492,
221 0x0077: 0.706,
222 0x0078: 0.495,
223 0x0079: 0.483,
224 0x007A: 0.466,
225 0x007B: 0.283,
226 0x007C: 0.26,
227 0x007D: 0.283,
228 0x007E: 0.535,
229 0x05BE: 0.322,
230 0x05C3: 0.278,
231 0x05D0: 0.53,
232 0x05D1: 0.482,
233 0x05D2: 0.34,
234 0x05D3: 0.451,
235 0x05D4: 0.53,
236 0x05D5: 0.275,
237 0x05D6: 0.254,
238 0x05D7: 0.545,
239 0x05D8: 0.532,
240 0x05D9: 0.273,
241 0x05DA: 0.483,
242 0x05DB: 0.476,
243 0x05DC: 0.486,
244 0x05DD: 0.538,
245 0x05DE: 0.537,
246 0x05DF: 0.229,
247 0x05E0: 0.334,
248 0x05E1: 0.507,
249 0x05E2: 0.475,
250 0x05E3: 0.485,
251 0x05E4: 0.505,
252 0x05E5: 0.429,
253 0x05E6: 0.477,
254 0x05E7: 0.5,
255 0x05E8: 0.477,
256 0x05E9: 0.613,
257 0x05EA: 0.566,
258 0x05F3: 0.17,
259 0x05F4: 0.322,
260 },
261};
262types/index.d.ts 71 lines1// State contract for the dl (Daily Learning) mod: the values the sidebar pane draws from.
2
3// The Library's collections, and their structure as Sefaria's shape API gives
4// it: sections (sedarim, sefarim) of books (masechtos, hilchos), each with its
5// first and last unit (perek, or daf).
6export type Coll = "mishnah" | "bavli" | "mt";
7export type LibBook = { title: string; he: string; name: string; section: string; first: number; last: number };
8export type LibSection = { name: string; he: string; books: LibBook[] };
9export type LibShape = { sections: LibSection[] };
10// Where the reader is: a collection, a section, a book, a unit.
11export type LibPos = { coll?: Coll; section?: string; book?: string; unit?: number };
12
13export type TabId = "chumash" | "tehillim" | "tanya" | "rambam1" | "rambam3" | "hayom" | "daf";
14
15// One block of text in a tab: a ref, its links, and parallel he/en paragraphs.
16// `rashi` holds Rashi's comments per verse, aligned with `he` (Chumash only).
17export type Part = {
18 title: string; link?: string; chabad?: string; he: string[]; en: string[];
19 rashi?: { he: string[][]; en: string[][] };
20 // A reading read one section at a time (Rambam's perakim, the daf's amudim):
21 // `unit` names them in the next/previous buttons, `numbered` leads each
22 // paragraph with its number (a halacha's א.), and `rashi` is per paragraph.
23 sections?: Section[];
24 unit?: "perek" | "amud";
25 numbered?: boolean;
26 // Each paragraph's own number, aligned with `he` (a Chumash verse's "ב."),
27 // and the heading drawn above it where a chapter starts (פרק ג).
28 labels?: { he: string; en: string; heading?: string }[];
29};
30
31// One section: `name` heads it (פרק יב, דף יח.), `short` names it in a button (יב, יח.).
32export type Section = { name: string; short: string; he: string[]; en: string[]; rashi?: string[][] };
33
34export type Day = {
35 v: number; // shape version: a cached Day of another version is fetched again
36 key: string; // Gregorian YYYY-MM-DD
37 title: string; // "Tuesday 2026-10-06 · 25 Tishrei 5787"
38 hebrew: string; // Hebrew date in Hebrew letters
39 parts: Record<TabId, Part[]>;
40 errors: string[];
41};
42
43export type Status = { key: string; phase: "loading" | "ready" | "error"; error?: string };
44
45declare module "claude-code" {
46 interface PluginState {
47 dl: {
48 day: Day | null;
49 status: Status | null;
50 tab: TabId | null; // null: the menu of the day's sections
51 // Which page the sidebar shows: the day's text, its settings, or the Library.
52 page: "read" | "settings" | "library";
53 // The Library: where the menu is, the collections loaded so far, the unit
54 // open (its ref and text), what is loading, and where each collection
55 // was last read.
56 libPos: LibPos;
57 libShapes: Partial<Record<Coll, LibShape>>;
58 libText: { ref: string; part: Part } | null;
59 libStatus: { what: string; phase: "loading" | "error"; error?: string } | null;
60 libLast: Partial<Record<Coll, { book: string; unit: number; at?: number }>>;
61 // Today's sections opened so far, for the date they belong to.
62 todayOpened: { key: string; ids: TabId[] } | null;
63 // Settings chosen on the sidebar's settings page, over the plugin's
64 // options (userConfig) until /config changes one of those.
65 prefsOver: Record<string, string | boolean>;
66 // Which section (perek, amud) each sectioned tab shows, for which day.
67 perek: { key: string; at: Partial<Record<TabId, number>> } | null;
68 };
69 }
70}
71