Explain grammar labels in plain English on original words and in word-study cards

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 15:17:02 -04:00
1 parent d5cc93f9bd
commit fa9e825444
6 files changed
+180

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
// What an original-language word's grammar label means, in plain English.
import { grammarNotes } from '../utils/grammar.js';
export default function GrammarPanel({ label, className = '' }) {
const notes = grammarNotes(label);
if (!label) return null;
return (
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
{notes.length > 0 && (
<dl className="mt-1.5 space-y-1">
{notes.map((n) => (
<div key={n.term} className="flex gap-2"><dt className="w-32 shrink-0 font-medium">{n.term}</dt><dd className="text-[var(--r-sub)]">{n.meaning}</dd></div>
))}
</dl>
)}
</div>
);
}
+6
View File
@@ -2,10 +2,12 @@
// interlinear words; click an original word to add it to the chunk's word study. // interlinear words; click an original word to add it to the chunk's word study.
import { useState } from 'react'; import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx'; import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
import GrammarPanel from '../GrammarPanel.jsx';
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) { export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
const [openVerse, setOpenVerse] = useState(null); const [openVerse, setOpenVerse] = useState(null);
const [editingNote, setEditingNote] = useState(null); const [editingNote, setEditingNote] = useState(null);
const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index }
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev]; const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return ( return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4"> <section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
@@ -38,6 +40,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
const added = w.s && addedStrongs.has(w.s.toUpperCase()); const added = w.s && addedStrongs.has(w.s.toUpperCase());
return ( return (
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)} <button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')} title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}> className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span> <span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
@@ -45,6 +48,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
</button> </button>
); );
})} })}
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
)}
</div> </div>
)} )}
</div> </div>
+2
View File
@@ -6,6 +6,7 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
import { useIqAvailable } from '../../hooks/useIq.js'; import { useIqAvailable } from '../../hooks/useIq.js';
import { normalizeStrongs } from '../../utils/iqbible.js'; import { normalizeStrongs } from '../../utils/iqbible.js';
import IqLexicon from '../iq/IqLexicon.jsx'; import IqLexicon from '../iq/IqLexicon.jsx';
import GrammarPanel from '../GrammarPanel.jsx';
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50'; const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
@@ -50,6 +51,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')} {field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
{field('English word', 'englishGloss', 'e.g. grace')} {field('English word', 'englishGloss', 'e.g. grace')}
</div> </div>
{word.parsing && <GrammarPanel label={word.parsing} />}
{field('Short definition', 'shortDefinition')} {field('Short definition', 'shortDefinition')}
{word.definitionHtml && ( {word.definitionHtml && (
<details className="rounded-lg border border-[var(--r-line)] p-2.5"> <details className="rounded-lg border border-[var(--r-line)] p-2.5">
+1
View File
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
lexeme: w.o || entry?.lemma || '', lexeme: w.o || entry?.lemma || '',
transliteration: w.t || entry?.translit || entry?.xlit || '', transliteration: w.t || entry?.translit || entry?.xlit || '',
partOfSpeech: '', partOfSpeech: '',
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
shortDefinition: entry?.kjv_def || w.g || '', shortDefinition: entry?.kjv_def || w.g || '',
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '', definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
loading: false, loading: false,
+111
View File
@@ -0,0 +1,111 @@
// Plain-English meanings for the grammar labels on the original-language words ("Verb - Aorist Indicative Active -
// 3rd Person Singular"). The labels come with the interlinear text; this only explains them, so a reader who never
// studied Greek or Hebrew can see what "aorist" or "Hiphil" tells them. Meanings are the usual textbook ones and
// are a guide, not a rule: the context decides.
// Hebrew verbs name a "stem" (how the action is done); when one is present, Perfect, Imperfect and the rest
// mean the Hebrew thing, not the Greek one.
const HEBREW_STEMS = [
['qal', 'The basic, simple action ("he kept").'],
['niphal', 'Often passive or reflexive ("it was kept", "he kept himself").'],
['piel', 'Intensive or repeated action ("he kept carefully").'],
['pual', 'The passive of the Piel ("it was kept carefully").'],
['hithpael', 'Reflexive or repeated action ("he kept himself busy").'],
['hiphil', 'Causing the action ("he made someone keep").'],
['hophal', 'The passive of the Hiphil ("he was made to keep").'],
];
const HEBREW_FORMS = [
['perfect', 'Action seen as complete, often past ("he kept").'],
['imperfect', 'Action seen as incomplete: ongoing, repeated or still to come ("he keeps", "he will keep").'],
['imperative', 'A command ("keep!").'],
['infinitive construct', 'The verb as a noun, linked to what follows ("keeping of…").'],
['infinitive absolute', 'The verb as a noun on its own, often to stress the action ("keeping, keep").'],
['participle', 'A verb used like an adjective or noun ("keeping", "the one who keeps").'],
];
const GREEK_TENSES = [
['present', 'Ongoing or repeated action ("is doing").'],
['imperfect', 'Past action that was ongoing or repeated ("was doing").'],
['future', 'Action that will happen ("will do").'],
['aorist', 'Action seen as a whole, with no focus on how long it took. Often just "did".'],
['perfect', 'A finished action whose results still stand ("has done").'],
['pluperfect', 'A finished action whose results stood at some past time ("had done").'],
];
const MOODS = [
['indicative', 'States something as a fact or asks a question.'],
['subjunctive', 'Possibility, purpose or doubt ("may", "might", "let us").'],
['imperative', 'A command or request.'],
['optative', 'A wish or a remote possibility ("may it be").'],
['infinitive', 'The verb as a noun ("to do").'],
['participle', 'A verb used like an adjective ("-ing", "the one who…").'],
];
const VOICES = [
['middle or passive', 'The form can mean the subject acts on itself, or receives the action; context decides.'],
['middle', 'The subject acts on itself or for its own benefit.'],
['passive', 'The subject receives the action ("was done").'],
['active', 'The subject does the action.'],
];
const CASES = [
['nominative', 'The subject of the sentence, or a name for something.'],
['genitive', 'Belonging, source or description. Often "of".'],
['dative', 'The one the action is to, for or by. Often "to", "for", "in", "with".'],
['accusative', 'The direct object, the thing acted on.'],
['vocative', 'The one being spoken to.'],
];
const PARTS = [
['noun', 'A person, place or thing.'],
['verb', 'An action or state.'],
['adjective', 'Describes a noun.'],
['adverb', 'Describes a verb, adjective or another adverb.'],
['article', 'The word "the".'],
['conjunction', 'Joins words or clauses ("and", "but", "for").'],
['preposition', 'Shows how a noun relates to something else ("in", "to", "from").'],
['personal pronoun', 'Stands in for a person ("he", "you").'],
['relative pronoun', 'Links a clause back to a noun ("who", "which").'],
['demonstrative pronoun', 'Points something out ("this", "that").'],
['reflexive pronoun', 'Points back to the subject ("himself").'],
['interrogative pronoun', 'Asks a question ("who?", "what?").'],
['pronoun', 'Stands in for a noun.'],
['particle', 'A small word that adds emphasis or links ideas.'],
['interjection', 'An exclamation.'],
['numeral', 'A number.'],
];
const PREFIXES = [
['conjunctive waw', '"And" (or "but", "then") joined to the front of the next word.'],
['preposition-b', '"In", "with" or "by", joined to the front of the next word.'],
['preposition-l', '"To" or "for", joined to the front of the next word.'],
['preposition-k', '"Like" or "as", joined to the front of the next word.'],
['preposition-m', '"From", joined to the front of the next word.'],
['construct', 'Linked to the next word, usually "of".'],
];
const PERSONS = { '1st': 'I / we', first: 'I / we', '2nd': 'you', second: 'you', '3rd': 'he / she / it / they', third: 'he / she / it / they' };
const NUMBER_WORDS = { singular: 'one', plural: 'more than one', dual: 'two' };
// -> [{ term: 'Aorist', meaning: '…' }] in the order the terms appear in the label; [] when there is nothing to say.
export function grammarNotes(label) {
const text = String(label ?? '').toLowerCase();
if (!text.trim()) return [];
const isHebrewVerb = HEBREW_STEMS.some(([t]) => new RegExp(`\\b${t}\\b`).test(text));
const found = [];
const add = (table, tableName) => {
for (const [term, meaning] of table) {
const m = new RegExp(`(?:^|[^a-z-])${term.replace(/[-]/g, '[- ]?')}(?![a-z])`).exec(text);
if (m) found.push({ at: m.index, term, meaning, tableName });
}
};
add(PARTS, 'part');
if (isHebrewVerb) { add(HEBREW_STEMS, 'stem'); add(HEBREW_FORMS, 'form'); } else { add(GREEK_TENSES, 'tense'); }
add(MOODS.filter(([t]) => !isHebrewVerb || (t !== 'indicative' && t !== 'subjunctive' && t !== 'optative' && !['imperative', 'infinitive', 'participle'].includes(t))), 'mood');
if (!isHebrewVerb) add(VOICES, 'voice'); else add([['active', VOICES[3][1]], ['passive', VOICES[2][1]]], 'voice');
add(CASES, 'case');
add(PREFIXES, 'prefix');
// the same label can match a short term inside a longer one ("middle" inside "middle or passive", "pronoun" inside "personal pronoun")
const covered = (a, b) => b !== a && b.term.length > a.term.length && b.term.replace(/-/g, ' ').includes(a.term.replace(/-/g, ' ')) && a.at >= b.at - 1 && a.at < b.at + b.term.length + 1;
const kept = found.filter((a) => !found.some((b) => covered(a, b) && (b.tableName === a.tableName || b.tableName === 'prefix')));
const person = text.match(/\b(1st|2nd|3rd|first|second|third)\s+person\b/);
const number = text.match(/\b(singular|plural|dual)\b/);
const first = (t) => t.charAt(0).toUpperCase() + t.slice(1);
const notes = kept.sort((a, b) => a.at - b.at).map(({ term, meaning }) => ({ term: first(term), meaning }));
if (person) notes.push({ term: `${person[1]} person${number ? ` ${number[1]}` : ''}`.replace(/^\w/, (c) => c.toUpperCase()), meaning: `${PERSONS[person[1]]}${number ? ` (${NUMBER_WORDS[number[1]]})` : ''}.` });
return notes;
}
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from 'vitest';
import { grammarNotes } from './grammar.js';
const terms = (label) => grammarNotes(label).map((n) => n.term);
test('a Greek verb is explained part by part', () => {
const notes = grammarNotes('Verb - Aorist Indicative Active - 3rd Person Singular');
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Aorist', 'Indicative', 'Active', '3rd person singular']);
expect(notes.find((n) => n.term === 'Aorist').meaning).toMatch(/as a whole/);
expect(notes.at(-1).meaning).toBe('he / she / it / they (one).');
});
test('nouns and articles give their case', () => {
expect(terms('Noun - Nominative Masculine Singular')).toEqual(['Noun', 'Nominative']);
expect(terms('Article - Genitive Neuter Plural')).toEqual(['Article', 'Genitive']);
expect(terms('Personal / Relative Pronoun - Accusative 1st Person Singular')).toContain('Accusative');
});
test('moods, participles and voices that share words are not double counted', () => {
expect(terms('Verb - Present Participle Middle or Passive - Nominative Masculine Singular')).toEqual(['Verb', 'Present', 'Participle', 'Middle or passive', 'Nominative']);
expect(terms('Verb - Perfect Infinitive Active')).toEqual(['Verb', 'Perfect', 'Infinitive', 'Active']);
});
test('a Hebrew verb is read with Hebrew meanings, not Greek ones', () => {
const notes = grammarNotes('Verb - Qal - Perfect - third person masculine singular');
expect(notes.map((n) => n.term)).toEqual(['Verb', 'Qal', 'Perfect', 'Third person singular']);
expect(notes.find((n) => n.term === 'Perfect').meaning).toMatch(/complete/);
expect(grammarNotes('Verb - Hiphil - Imperfect - third person masculine singular').find((n) => n.term === 'Hiphil').meaning).toMatch(/Causing/);
});
test('Hebrew words with attached prefixes explain the prefix', () => {
const notes = grammarNotes('Preposition-b | Noun - feminine singular');
expect(notes.map((n) => n.term)).toEqual(expect.arrayContaining(['Noun', 'Preposition-b']));
expect(notes.find((n) => n.term === 'Preposition-b').meaning).toMatch(/joined to the front/);
});
test('nothing to explain gives an empty list', () => {
expect(grammarNotes('')).toEqual([]);
expect(grammarNotes(null)).toEqual([]);
expect(grammarNotes('???')).toEqual([]);
});