';
slice=slice.split('<\\/script>').join('<\/script>'); // same un-escape buildReaderHTML does
const c=readerChrome();
for(const k in c) slice=slice.split('{{'+k+'}}').join(c[k]);
try{
const doc=new DOMParser().parseFromString(slice,'text/html');
const pop=doc.querySelector('#infoPop');
return pop?pop.innerHTML:'';
}catch(e){ return ''; }
}
function openAbout(){
const d=document.getElementById('aboutdlg'); if(!d) return;
const body=document.getElementById('aboutbody');
const html=aboutHTML();
if(body) body.innerHTML=html||'The reader template is not embedded yet — run scripts/embed-reader.py.
';
const h=document.getElementById('aboutdlgh');
const name=(META&&(META.book||META.code))||BOOK||'';
if(h) h.textContent='About this edition'+(name?(' — '+name):'');
if(!d.open) d.showModal();
}
/* ---------- stacked (multi-source) rendering ---------- */
/* STK_OVR / STK_RESOLVED are in-memory caches keyed by vk = "chapter:verse" (multi-chapter safe:
verse numbers repeat across chapters). They are the projection of the STKO:/STKR: records held
in SAVED (localStorage + server sync); stkRehydrate() rebuilds them from SAVED on every load. */
const STK_OVR={}, STK_RESOLVED={};
function stkSources(role){return (META&&META.sources&&META.sources[role])||[]}
function stkEditableRoles(){return ['en','ceb','tl','mrw'].filter(r=>stkSources(r).some(s=>s.editable))}
function stkVk(v){return vch(v)+':'+v.n} // composite in-memory key (ch:n)
function stkKeyO(ch,n,role,si){return 'STKO:'+ch+':'+n+':'+role+':'+si} // per-source override record
function stkKeyR(ch,n,role){return 'STKR:'+ch+':'+n+':'+role} // per-role resolve record
function stkTok(vk,role,src,col,fb){const o=(((STK_OVR[vk]||{})[role]||{})[src]||{});return (col in o)?o[col]:fb}
/* Rebuild the in-memory caches from the STKO:/STKR: records in SAVED (call on every load). */
function stkRehydrate(){
for(const k in STK_OVR)delete STK_OVR[k];
for(const k in STK_RESOLVED)delete STK_RESOLVED[k];
Object.keys(SAVED).forEach(key=>{
const r=SAVED[key];if(!r||!r.stacked)return;
const vk=r.chapter+':'+r.verse;
if(key.slice(0,5)==='STKO:'&&r.overrides){
const byRole=(STK_OVR[vk]=STK_OVR[vk]||{});const bySrc=(byRole[r.role]=byRole[r.role]||{});
const m=(bySrc[r.src]=bySrc[r.src]||{});Object.keys(r.overrides).forEach(c=>{m[c]=r.overrides[c]});
}else if(key.slice(0,5)==='STKR:'&&r.resolved){
const m=((STK_RESOLVED[vk]=STK_RESOLVED[vk]||{})[r.role]=STK_RESOLVED[vk][r.role]||{});
Object.keys(r.resolved).forEach(c=>{if(r.resolved[c])m[c]=true});
}
});
}
/* Persist one edited stacked token: cache → SAVED (STKO record) → localStorage → server sync. */
function stkPersistOverride(ch,n,role,si,src,col,text){
const vk=ch+':'+n;
const byRole=(STK_OVR[vk]=STK_OVR[vk]||{});const bySrc=(byRole[role]=byRole[role]||{});
(bySrc[src]=bySrc[src]||{})[col]=text;
const key=stkKeyO(ch,n,role,si);
const rec=SAVED[key]||{stacked:true,chapter:ch,verse:n,role:role,src:src,overrides:{}};
const from=rec.overrides[col];
rec.src=src;rec.overrides[col]=text;
// M12: multi-source mode has no chip model, but a change still has an author — stamp the record
// and keep the same attributed history shape the single-source path uses.
rec.by=WHO.name||'';rec.lvl=WHO.level;rec.at=nowISO();
rec.log=(rec.log||[]).concat([{i:(rec.log||[]).length+1,at:rec.at,by:rec.by,lvl:WHO.level,ses:SESSION_ID,
op:'stack_edit',cols:[col],d:{col:col,src:src,from:from,to:text},
txt:describeEvent('stack_edit',{col:col,src:src,to:text})}]).slice(-LOG_CAP);
SAVED[key]=rec;
DIRTY=true;persist();if(window.syncKey)window.syncKey(key);
}
/* Persist one reconcile resolution (a reviewed conflict column): cache → SAVED (STKR) → store. */
function stkPersistResolve(ch,n,role,col){
const vk=ch+':'+n;
((STK_RESOLVED[vk]=STK_RESOLVED[vk]||{})[role]=STK_RESOLVED[vk][role]||{})[col]=true;
const key=stkKeyR(ch,n,role);
const rec=SAVED[key]||{stacked:true,chapter:ch,verse:n,role:role,resolved:{}};
rec.resolved[col]=true;
rec.by=WHO.name||'';rec.lvl=WHO.level;rec.at=nowISO();
rec.log=(rec.log||[]).concat([{i:(rec.log||[]).length+1,at:rec.at,by:rec.by,lvl:WHO.level,ses:SESSION_ID,
op:'stack_resolve',cols:[col],d:{col:col,role:role},txt:describeEvent('stack_resolve',{col:col,role:role})}]).slice(-LOG_CAP);
SAVED[key]=rec;
DIRTY=true;persist();if(window.syncKey)window.syncKey(key);
}
function stkChip(w,ci,mode,focus,flaggedCols,vk){
const rtl=isHebrew(w.g)?' rtl':'';
const flagged=!!(flaggedCols&&flaggedCols.has(ci));
let h=''+esc((w.pre||'')+w.g+(w.post||''))+'
'+esc(w.strong||'')+'
'+(flagged?'
⚑
':'')+'
';
if(mode==='reader'){
['en','ceb','tl','mrw'].filter(r=>stkSources(r).length).forEach(role=>{
const srcs=w.stacks&&w.stacks[role]; if(!srcs||!srcs.length)return;
h+='
'+esc((LANGS[role]&&LANGS[role].n)||role)+'
';
srcs.forEach(s=>{
const cls=s.editable?'ed':'ref';
const base=s.disp||s.tok||'—';
const val=(s.editable&&vk)?stkTok(vk,role,s.src,ci,base):base; // reader reflects saved edits
h+='
'+esc(s.src)+' '+esc(val)+'
';
});
});
}else{ // edit, focused on one role
if(focus!=='en' && w.stacks && w.stacks.en){
const refs=w.stacks.en.map(s=>esc(s.src+' '+(s.tok||'—'))).join(' · ');
h+='
refs '+refs+'
';
}
const cv=VERSES[S.vi],ch=vch(cv),n=cv.n;
((w.stacks&&w.stacks[focus])||[]).forEach((s,si)=>{
const val=esc(stkTok(vk,focus,s.src,ci,s.tok||'—'));
if(s.editable){
h+='
'+esc(s.src)+' '
+''+val+'
';
}else{
h+='
'+esc(s.src)+' '+val+'
';
}
});
}
return h+'
';
}
function renderStackedReader(){
const host=document.getElementById('reader');host.innerHTML='';
const multi=isMulti();let curCh=null;
VERSES.forEach(v=>{
if(multi&&vch(v)!==curCh){curCh=vch(v);const hh=document.createElement('h2');hh.className='chaphead';hh.textContent='Chapter '+curCh;host.appendChild(hh);}
const vk=stkVk(v);
const sec=document.createElement('section');sec.className='verse';
sec.innerHTML=''+(multi?vch(v)+':':'')+v.n+'
'+v.words.map((w,ci)=>stkChip(w,ci,'reader',null,null,vk)).join('')+'
';
host.appendChild(sec);
});
}
function renderStackedEditor(){
const host=document.getElementById('spine');const v=VERSES[S.vi];const focus=S.lang;const vk=stkVk(v);
const conflicts=((v.reconcile||{})[focus])||[];
const flaggedCols=new Set(conflicts.filter(c=>!(((STK_RESOLVED[vk]||{})[focus]||{})[c.col])).map(c=>c.col));
host.innerHTML=''+v.words.map((w,ci)=>stkChip(w,ci,'edit',focus,flaggedCols,vk)).join('')+'
'+stkReview(v,focus,conflicts);
stkUpdateFlagCount();
}
function stkReview(v,focus,conflicts){
if(!conflicts.length)return '';
const vk=stkVk(v),ch=vch(v);
const items=conflicts.map(c=>{
const done=(((STK_RESOLVED[vk]||{})[focus]||{})[c.col]);const w=v.words[c.col]||{};
return '⚑ '+esc(w.g||('col '+c.col))+' ('+esc(w.strong||'')+') — editions differ in meaning: '+esc(c.a_sense)+' vs '+esc(c.b_sense)+'
'
+'
🤖 '+esc(c.detail||'')+(c.confidence!=null?' · confidence '+(+c.confidence).toFixed(2):'')+'
'
+'
Keep both (valid) '
+'Mark reviewed
';
}).join('');
return '
⚑ Reconcile — '+esc((LANGS[focus]&&LANGS[focus].n)||focus)+' · verse '+v.n+' '+items+'';
}
function stkUpdateFlagCount(){
let n=0;VERSES.forEach(v=>{const vk=stkVk(v);const c=(v.reconcile||{});Object.keys(c).forEach(role=>{c[role].forEach(x=>{if(!(((STK_RESOLVED[vk]||{})[role]||{})[x.col]))n++})})});
let el=document.getElementById('stkflag');
if(!el){const lbl=document.querySelector('.ectl');if(lbl){el=document.createElement('span');el.id='stkflag';el.className='stk-flagcount';lbl.appendChild(el);}}
if(el)el.textContent=n?('⚑ '+n+' to reconcile'):'';
}
function stkStartEdit(el){
el.setAttribute('contenteditable','true');el.focus();
const r=document.createRange();r.selectNodeContents(el);const sel=getSelection();sel.removeAllRanges();sel.addRange(r);
el.onblur=()=>{el.removeAttribute('contenteditable');const nv=el.textContent.trim();
const ch=+el.dataset.ch,n=+el.dataset.n,role=el.dataset.role,src=el.dataset.src,si=+el.dataset.srci,col=+el.dataset.col;
stkPersistOverride(ch,n,role,si,src,col,nv);announce('saved '+src+' · '+nv);};
}
(function(){const spine=document.getElementById('spine');
spine.addEventListener('dblclick',e=>{const el=e.target.closest('[data-stkedit]');if(el)stkStartEdit(el)});
spine.addEventListener('keydown',e=>{const el=e.target.closest('[data-stkedit]');if(!el)return;
if(el.getAttribute('contenteditable')!=='true'&&(e.key==='Enter'||e.key==='F2')){e.preventDefault();stkStartEdit(el)}
else if(el.getAttribute('contenteditable')==='true'&&e.key==='Enter'){e.preventDefault();el.blur()}});
spine.addEventListener('click',e=>{const b=e.target.closest('[data-stkresolve]');if(!b)return;
const ch=+b.dataset.ch,n=+b.dataset.n,col=+b.dataset.col,focus=S.lang;
stkPersistResolve(ch,n,focus,col);render()});
})();
/* ---------- export ---------- */
function overridesFor(vi,lang){
const v=VERSES[vi];const k=saveKey(vi,lang);const saved=SAVED[k];
const tokens=saved&&saved.tokens?saved.tokens:draftTokens(vi,lang).tokens;
const ops=saved&&saved.ops?saved.ops:[];
const byCol={};tokens.forEach(t=>{(byCol[t.col]=byCol[t.col]||[]).push(t)});
const ovr=[];
v.words.forEach((w,ci)=>{
const ts=(byCol[ci]||[]).sort((a,b)=>a.ro-b.ro);
const real=ts.filter(t=>!t.sup).map(t=>t.text);const sup=ts.filter(t=>t.sup).map(t=>t.text);
// Which of the supplied words are FUNCTION words (grammar the target language requires)
// rather than the translator supplying meaning. Additive: `display` still brackets both
// kinds identically, so the pipeline's reconstruction gate is untouched by this.
const fnw=ts.filter(t=>t.sup&&t.fn).map(t=>t.text);
const token=real.length?real.join(' '):'—';
// one bracket per contiguous supplied run, the shape align.build_display emits and
// corrections._token_from_display parses back — see bracketGroups
const display=runText(ts)||'—';
const origDisp=dispOf(w,lang)||'—';
const flag=ts.some(t=>t.flag); // M11: reviewer flag (a raised concern) on this column
// H7: a column this language drops renders nothing (display==='—') and had nothing
// originally (origDisp==='—'); it always satisfied cur!==og and emitted a spurious
// {token:'—'} no-op that [].every()===true then falsely stamped reviewed. Skip it.
if(origDisp==='—'&&display==='—'&&!flag)return;
const cur=ts.map(t=>t.text+(t.sup?(t.fn?'^':'*'):'')).join('|');
const og=parseDisplay(origDisp).map(p=>p.text+(p.sup?'*':'')).join('|');
const changed=cur!==og;
if(!changed&&!flag)return; // untouched AND unflagged → not an override (H7 preserved)
// H8: emit per-column readOrder (the `ro` the editor already tracks) + the reason `op`,
// so the pipeline can rebuild the realignment — align.build_display sorts columns by order.
const readOrder=ts.map(t=>t.ro);
const colIds=new Set(ts.map(t=>t.id));
let op=null; // most recent STRUCTURAL op that touched this column
for(let i=ops.length-1;i>=0&&!op;i--){const o=ops[i];
// annotations (a flag, a ✓, a gloss/back-translation edit) don't set the column's op.
// 'reviewed' must match toggleRev's logOp name exactly — see the note there.
if(o.type==='flag'||o.type==='reviewed'||o.type==='gloss'||o.type==='back_translation')continue;
const ids=[].concat(o.ids||[],o.id!=null?[o.id]:[],o.into!=null?[o.into]:[]);
if(ids.some(id=>colIds.has(id)))op=o.type;}
if(!op)op=changed?(real.length>1?'merge':(sup.length?'mark_supplied':(real.length?'realign':'unalign'))):'flag';
// H7: require a non-empty token list — [].every() is true and would falsely mark reviewed.
const reviewed=ts.length>0&&ts.every(t=>t.reviewed);
const o={col:ci,strong:w.strong,token,display,supplied_words:sup,readOrder,op,phrase:real.length>1,reviewed,flag};
if(fnw.length)o.functional_words=fnw; // additive — absent unless a reviewer said so
// Which words ride their own card beside this column, on which side and as which kind — one
// entry per card, in reading order, exactly the shape supplied.annotate groups across
// languages and the reader draws. `display` cannot carry this: "[a] servant" is byte-identical
// whether or not the reviewer peeled the article out, so without this the pipeline can only
// fall back to its own >=2-word guess. Absent when the reviewer pinned nothing.
const st=supTilesFor(ts);if(st)o.supplied_tiles=st;
ovr.push(o);
});
const un=(byCol[-1]||[]);if(un.length)ovr.push({col:null,op:'unalign',words:un.map(t=>t.text),readOrder:un.map(t=>t.ro),flag:un.some(t=>t.flag)});
// M11: THE ported reconstruction invariant — can this display rebuild the published reading line?
const st=reconstructStatus(vi,lang,tokens);
const added=tokens.filter(t=>t.added).map(t=>({text:t.text,col:t.col===-1?null:t.col,readOrder:t.ro,supplied:!!t.sup,flag:!!t.flag}));
const entry={verse:v.n,chapter:vch(v),lang,source_diff:(st.ok?'clean':'diverged'),reconstructs:st.ok,
// `flagged_remaining` is the pipeline's name for the UNREVIEWED backlog (see corrections.py's
// doc payload); the reviewer's own ⚑ count has always ridden alongside it as reviewer_flags.
flagged_remaining:unreviewedCount(tokens),reviewed:tokens.filter(t=>t.reviewed).length,
reviewer_flags:tokens.filter(t=>t.flag).length,note:(saved&&saved.note)||'',
op_log:ops,overrides:ovr};
if(added.length)entry.added_words=added; // M11: words the reviewer inserted (add-word), as an EXTRA field
// M12: the review layer travels with the entry — sign-off ladder, note threads, and the
// attributed change history. Purely ADDITIVE: `corrections.incorporate_corrections` reads only
// `overrides`, so an older pipeline ignores all of this, while `feedback.py` lives on it.
const so=(saved&&saved.signoff)||{};
if(Object.keys(so).length){
entry.signoff=so;
entry.review_level=Math.max.apply(null,Object.keys(so).map(Number));
}
const threads=(saved&&Array.isArray(saved.notes))?saved.notes:[];
if(threads.length)entry.notes=threads.map(th=>({
id:th.id,col:th.col,word:th.word,
strong:(th.col!=null&&v.words[th.col])?v.words[th.col].strong:null,
open:th.entries.filter(e=>!e.done).length,
entries:th.entries.map(e=>({body:e.body,by:e.by||'',lvl:e.lvl,at:e.at,done:!!e.done,
done_by:e.doneBy||undefined,done_at:e.doneAt||undefined}))
}));
const log=(saved&&Array.isArray(saved.log))?saved.log:[];
if(log.length){
entry.history=log;
if(saved.logdropped)entry.history_trimmed=saved.logdropped;
// who worked on this entry, and how much — the roll-up the review dashboard and the
// training distiller both want without re-walking every event
const byWho={};
log.forEach(e=>{const k=(e.by||'unsigned')+'\u0000'+(e.lvl||0); // escape, not a raw byte: one makes git and grep treat this file as binary
const r=byWho[k]||(byWho[k]={by:e.by||'',lvl:e.lvl||0,events:0,first:e.at,last:e.at});
r.events++;if(e.atr.last)r.last=e.at;});
entry.reviewers=Object.keys(byWho).map(k=>byWho[k]).sort((a,b)=>b.events-a.events);
}
if(lang==='mrw'){ // M11: Maranao gloss / back-translation — expected divergence
if(saved&&saved.bt!=null&&saved.bt!==(v.mrw_bt||'')){entry.mrw_bt=saved.bt;entry.mrw_bt_original=v.mrw_bt||'';entry.expected_divergence=true}
const mg=saved&&saved.mg?saved.mg:{};
const mgo=Object.keys(mg).filter(c=>mg[c]!==((v.words[c]&&v.words[c].mg)||'')).map(c=>({col:+c,strong:v.words[c]?v.words[c].strong:null,mg:mg[c]}));
if(mgo.length){entry.mg_overrides=mgo;entry.expected_divergence=true}
}
return entry;
}
function buildBundle(){
const entries=[];
VERSES.forEach((v,vi)=>Object.keys(LANGS).forEach(lang=>{
const k=saveKey(vi,lang);if(!SAVED[k])return; // only touched verse+lang pairs
const e=overridesFor(vi,lang);
if(e.op_log.length||e.overrides.length||e.reviewed||e.note||e.mrw_bt!=null||e.mg_overrides||e.added_words
||e.notes||e.history||e.signoff)entries.push(e);
}));
// Each ENTRY carries its own `chapter` (verse numbers repeat across chapters). The bundle-level
// `chapter` stays for single-chapter books; for multi-chapter it is null — read the per-entry one.
const bundle={book:BOOK,chapter:isMulti()?null:CHAP,generated_by:'interlinear-editor',schema:'stage5-overrides@1',
app_version:APP_VERSION,exported_at:nowISO(),entries};
// M12: the review ladder this bundle was produced under, plus the roll-up of who contributed —
// enough for `pipeline/feedback.py` to weight a correction by the level that made it.
bundle.review={
levels:REVIEW_LEVELS.map(l=>({n:l.n,name:l.name})),
exported_by:{name:WHO.name||'',lvl:WHO.level,hosted:!!WHO.hosted},
contributors:(function(){
const m={};entries.forEach(e=>(e.reviewers||[]).forEach(r=>{
const k=(r.by||'unsigned')+' '+r.lvl;const x=m[k]||(m[k]={by:r.by||'',lvl:r.lvl,events:0,entries:0});
x.events+=r.events;x.entries++;}));
return Object.keys(m).map(k=>m[k]).sort((a,b)=>b.events-a.events);
})(),
signed:entries.filter(e=>e.signoff).length,
open_notes:entries.reduce((n,e)=>n+((e.notes||[]).reduce((m,t)=>m+t.open,0)),0)
};
if(META&&META.stacked)stkBundleSections(bundle);
return bundle;
}
/* Stacked books don't use the single-source token model, so their reviewer work lives in the
STKO:/STKR: records rather than in `entries`. Emit it as two additive bundle sections keyed by
(chapter, verse) so the pipeline can apply per-source edits and mark resolved conflicts. */
function stkBundleSections(bundle){
const so=[],rr=[];
Object.keys(SAVED).forEach(key=>{
const r=SAVED[key];if(!r||!r.stacked)return;
if(key.slice(0,5)==='STKO:'&&r.overrides){
const ov=Object.keys(r.overrides).map(c=>({col:+c,token:r.overrides[c]})).sort((a,b)=>a.col-b.col);
if(ov.length)so.push({verse:r.verse,chapter:r.chapter,lang:r.role,source:r.src,overrides:ov,
by:r.by||'',lvl:r.lvl||null,at:r.at||null,history:r.log||undefined}); // M12: attribution
}else if(key.slice(0,5)==='STKR:'&&r.resolved){
Object.keys(r.resolved).forEach(c=>{if(r.resolved[c])
rr.push({verse:r.verse,chapter:r.chapter,lang:r.role,col:+c,resolution:'reviewed',
by:r.by||'',lvl:r.lvl||null,at:r.at||null})});
}
});
const bycv=(a,b)=>a.chapter-b.chapter||a.verse-b.verse||(a.langb.lang?1:0);
so.sort((a,b)=>bycv(a,b)||(a.sourceb.source?1:0));
rr.sort((a,b)=>bycv(a,b)||a.col-b.col);
bundle.stacked=true;
if(so.length)bundle.stack_overrides=so;
if(rr.length)bundle.reconcile_resolutions=rr;
}
function exportBundle(){
const bundle=buildBundle();
/* DIRTY is NOT cleared here. It used to be, on this line, before the invariant below has had a
chance to block the export: a blocked export downloaded nothing and still disarmed the
leave-warning, so the one guard that would have said "you have unexported work" was switched
off by the attempt that failed. It is cleared in download() instead — the moment a bundle
actually leaves the browser. */
const dl=document.getElementById('dldl');const warn=document.getElementById('dlgwarn');
document.getElementById('dlgpre').textContent=JSON.stringify(bundle,null,2);
const download=()=>{
const blob=new Blob([JSON.stringify(bundle,null,2)],{type:'application/json'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=BOOK+(isMulti()?'':'-ch'+CHAP)+'-overrides.json';a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),2000);
DIRTY=false; // M10(b): the bundle is out — only now does the unload warning have nothing to say
};
// M11: ENFORCE the reconstruction invariant on export. Any edited PUBLISHED-language entry whose
// display cannot rebuild its reading line is blocked — a loud red banner lists the failing verses
// and the download is disabled, rather than silently shipping a bundle the pipeline gate rejects.
const bad=bundle.entries.filter(e=>LANGS[e.lang]&&LANGS[e.lang].published&&e.reconstructs===false);
// …and the second thing worth stopping an export for: a card the reviewer declared and never put
// a word on. It cannot break the pipeline the way a broken reconstruction does — no chip is
// written for it at all — but exporting a bundle with placeholders still in it is almost never
// what was meant, so it is named here beside the invariant and blocks the same button.
const empties=emptyCardsAll();
const msgs=[],why=[];
if(bad.length){
const list=bad.map(e=>'v'+e.verse+' ('+LANGS[e.lang].n+')').join(', ');
msgs.push('⚠ Reconstruction invariant BROKEN. The display cannot rebuild the published reading line for: '+esc(list)+' . '+
'The pipeline gate (align.build_display) will reject this — fix these verses before exporting.');
why.push('the reconstruction invariant is BROKEN for '+bad.length+' published-language entr'+(bad.length>1?'ies':'y')+' ('+list+')');
}
if(empties.length){
const list=empties.slice(0,6).map(e=>'v'+e.verse+' '+emptyCardLabel(e.vi,e.card)).join('; ');
msgs.push('⚠ '+empties.length+' empty card'+(empties.length>1?'s':'')+'. A supplemental or function-word card with no word in any '+
'language is a placeholder — nothing is published for it. '+esc(list)+(empties.length>6?' …':''));
why.push(empties.length+' card'+(empties.length>1?'s have':' has')+' no word in any language');
}
if(msgs.length){
warn.style.display='';
warn.innerHTML=msgs.join(' ')+'Export anyway ';
dl.disabled=true;dl.onclick=null;
document.getElementById('dlgforce').onclick=()=>{if(confirm('Export anyway?\n\nThis bundle is going out even though '+why.join(', and ')+'.')){dl.disabled=false;download()}};
}else{
warn.style.display='none';dl.disabled=false;dl.onclick=download;
}
dlg.showModal();
}
/* ---------- view switching + controls ---------- */
function setView(view){
const ed=view==='editor';
document.getElementById('editorView').classList.toggle('on',ed);
document.getElementById('readerView').classList.toggle('on',!ed);
document.getElementById('foot').classList.toggle('hide',!ed);
const nb=document.getElementById('navbar'); if(nb)nb.style.display=ed?'':'none'; // reader carries its own controls
document.querySelectorAll('.ectl').forEach(el=>el.style.display=ed?'':'none');
document.getElementById('seltb').classList.toggle('on',ed&&S.sel.size>0);
[...document.querySelectorAll('#viewseg button')].forEach(b=>b.setAttribute('aria-pressed',b.dataset.view===view));
if(!ed)showReader();
}
document.getElementById('viewseg').addEventListener('click',ev=>{const b=ev.target.closest('button');if(b)setView(b.dataset.view)});
const vsel=document.getElementById('vsel');
vsel.onchange=()=>{S.vi=+vsel.value;build()};
const lseg=document.getElementById('lseg');
Object.keys(LANGS).forEach(k=>{const b=document.createElement('button');b.textContent=LANGS[k].n;b.dataset.lang=k;b.setAttribute('aria-pressed',k===S.lang);
b.onclick=()=>activateLang(k);lseg.appendChild(b)});
// activate a language for editing (from the Editing control OR a click on a reference track)
function activateLang(lang){
if(lang===S.lang)return;
S.lang=lang;
[...lseg.children].forEach(b=>b.setAttribute('aria-pressed',b.dataset.lang===lang));
build();
}
// "Show" multi-select — which language tracks appear (the active one is always shown + locked on)
const showseg=document.getElementById('showseg');
Object.keys(LANGS).forEach(k=>{const b=document.createElement('button');b.textContent=TRI[k];b.dataset.show=k;
b.title='Show the '+LANGS[k].n+' row';b.setAttribute('aria-pressed',String(!!SHOWN[k]));
b.onclick=()=>{if(k===S.lang)return;SHOWN[k]=!SHOWN[k];saveShow();render()};showseg.appendChild(b)});
function syncShowSeg(){
const seg=document.getElementById('showseg');if(!seg)return;
[...seg.children].forEach(b=>{const k=b.dataset.show;const act=(k===S.lang);
b.setAttribute('aria-pressed',String(!!SHOWN[k]||act));b.disabled=act;});
}
document.getElementById('undo').onclick=undo;document.getElementById('redo').onclick=redo;
document.getElementById('nextflag').onclick=nextFlagged;
/* M11: per-entry reviewer note + Maranao back-translation edit + add-unaligned-word wiring. */
document.getElementById('note').onchange=e=>{S.note=e.target.value;saveCurrent();fillVerseSelect()};
document.getElementById('mrwbt').onchange=e=>{S.bt=e.target.value;logOp('back_translation',{to:S.bt});saveCurrent()};
document.getElementById('trayadd').onclick=()=>addWord(-1);
document.getElementById('dim').onchange=e=>document.body.classList.toggle('focusflag',e.target.checked);
document.getElementById('help').onclick=()=>hdlg.showModal();
{const ab=document.getElementById('hdrinfo');if(ab)ab.onclick=openAbout;}
document.getElementById('dbgbtn').onclick=()=>setDebug(!DBG);
/* M12: the review layer's wiring — one toolbar button, four dialogs. */
document.getElementById('revbtn').onclick=ev=>{ev.stopPropagation();openReviewMenu(ev)};
document.getElementById('ndlgadd').onclick=submitNote;
document.getElementById('ndlgin').addEventListener('keydown',ev=>{
ev.stopPropagation();
if(ev.key==='Enter'&&(ev.ctrlKey||ev.metaKey)){ev.preventDefault();submitNote()}});
document.getElementById('gdlgscope').onchange=renderHistory;
document.getElementById('gdlgwho').onchange=renderHistory;
document.getElementById('gdlgcopy').onclick=()=>{
const t=historyText();
const done=()=>{const b=document.getElementById('gdlgcopy');b.textContent='Copied ✓';setTimeout(()=>b.textContent='Copy',1400)};
if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(t).then(done,()=>alert(t));
else alert(t);
};
document.getElementById('idsave').onclick=saveIdentity;
document.getElementById('ldlgok').onclick=confirmLinkDialog;
// closing by Escape or the backdrop is a cancel, not a silent commit of whatever was ticked
document.getElementById('ldlg').addEventListener('close',()=>{LINKPEND=null});
try{if(localStorage.getItem(ATTR_KEY)==='1')document.body.classList.add('showattr')}catch(e){}
// version pill: the number comes from this file's own , so it can only change when this file
// actually reached the server — a stale number means the upload did not take. The editor carries the
// pill in its header, and that is the only place this document shows the version.
(function(){const el=document.getElementById('appver');
if(el){el.textContent='v'+APP_VERSION;el.title=APP_TITLE+' — app version '+APP_VERSION;}
setDebug(DBG);})();
/* M12: a Reset discards the ALIGNMENT edits, never the review record. The notes, the attributed
change history and the sign-off ladder are the audit trail — they survive, and the reset itself
is recorded in the history (notes re-attach to the fresh tokens by their card+word anchor). */
document.getElementById('reset').onclick=()=>{
if(!confirm('Reset this verse to the AI draft? Your alignment edits for this verse + language will be discarded, including any cards you added.\n\nNotes, change history and sign-offs are kept.'))return;
const k=saveKey(S.vi,S.lang);const prev=SAVED[k]||{};
histPush('reset',{});
const keep={notes:S.notes,log:S.log,logdropped:S.logdropped||0,signoff:S.signoff};
if(prev.bt!=null)keep.bt=prev.bt;if(prev.mg)keep.mg=prev.mg; // the Maranao layer is not an alignment edit
const empty=!keep.notes.length&&!keep.log.length&&!Object.keys(keep.signoff||{}).length&&keep.bt==null&&!keep.mg;
if(empty)delete SAVED[k];else SAVED[k]=keep;
persist();if(window.syncKey)window.syncKey(k);
// F10: drop this key's undo stack too. undoStash early-returns when the key is unchanged, so the
// pre-Reset snapshots would otherwise survive — and a Ctrl+Z would restore tokens whose ids
// reanchorNotes() has already re-pointed, re-attaching note threads to the wrong word.
undoForget(k);
build();announce('verse reset to AI draft')};
/* Discard the ALIGNMENT edits behind a list of keys, keeping everything Reset keeps.
Deliberately the same keep-set as the Reset button rather than a second opinion about what an
"unsaved edit" is: notes, change history, sign-offs and the Maranao gloss layer are not
alignment work, and losing a reviewer's note threads to a data refresh would be a far worse bug
than the stale draft this is here to clear. */
function clearDrafts(list){
let n=0;
(list||[]).forEach(d=>{
const prev=SAVED[d.key];if(!prev||!Array.isArray(prev.tokens))return;
const keep={notes:prev.notes||[],log:prev.log||[],logdropped:prev.logdropped||0,
signoff:prev.signoff||{}};
if(prev.bt!=null)keep.bt=prev.bt;if(prev.mg)keep.mg=prev.mg;
const empty=!keep.notes.length&&!keep.log.length&&!Object.keys(keep.signoff).length
&&keep.bt==null&&!keep.mg;
if(empty)delete SAVED[d.key];else SAVED[d.key]=keep;
if(typeof undoForget==='function')undoForget(d.key);
n++;
});
if(n){persist();if(typeof window!=='undefined'&&window.syncKey)
(list||[]).forEach(d=>{try{window.syncKey(d.key)}catch(e){}})}
return n;
}
/* Offer to clear drafts that no longer describe the book just loaded. Called from loadData, which
is the one funnel every book arrives through — /api/chips, the /api/book fallback, and
⤒ Load JSON alike.
The trigger is "some saved draft no longer matches", not "the database changed". They are the
same thing whenever it matters and differ only where the difference is in the reviewer's favour:
a data update that touches nothing anyone has edited asks nothing, because there is nothing to
decide. Cancelling is safe — the drafts stay and every one of them is badged. */
/* Asked once per situation, not once per page load. A reviewer who has said "keep my edits" has
answered; asking again every time they open the book turns a useful warning into a nag, and a
nag gets clicked through without reading. The answer is remembered against WHICH drafts were
stale, so a newly-stale draft is a new question and does ask. The badges never go away either
way — this only silences the modal. */
function staleToken(stale){
return hash32((stale||[]).map(d=>d.key+':'+d.state).sort().join('|'));
}
function staleAsked(){try{return localStorage.getItem(STORE_KEY+':asked')||''}catch(e){return ''}}
function markStaleAsked(tok){try{localStorage.setItem(STORE_KEY+':asked',tok)}catch(e){}}
function offerStaleCleanup(){
const stale=staleDrafts();
if(!stale.length)return 0;
const tok=staleToken(stale);
if(staleAsked()===tok)return 0; // same drafts, already answered — the ✎! marks still show
markStaleAsked(tok);
const dl=document.getElementById('stdlg');
if(!dl||!dl.showModal)return 0; // no dialog to ask with: leave the drafts, badges still show
const n=stale.length;
// The question, then the evidence. "Which verses?" is the first thing anyone asks, and a list is
// shorter than the sentence that would summarise it.
document.getElementById('stdlgbody').innerHTML=
''+n+' verse'+(n===1?'':'s')+' below '+(n===1?'is':'are')+
' drawn from edits you saved before the book data changed . '+(n===1?'It shows':'They show')+
' the older alignment, not what is in the database now.
'+
''+stale.map(d=>{
const v=(VERSES||[])[d.vi]||{};
return ''+esc(verseLabel(v)||('verse '+(d.vi+1)))+' '+
''+esc(((LANGS[d.lang]||{}).n)||d.lang)+' '+
''+(d.state==='stale'?'the data behind it changed'
:'saved before this check existed — cannot be verified')+
' ';}).join('')+' '+
'Use the current data discards those edits and redraws from the '+
'database. Keep my edits leaves them exactly as they are; they stay marked ✎! in the '+
'verse list and you can clear any of them later from the badge. Either way your notes, change '+
'history and sign-offs are kept.
';
let cleared=0;
const done=()=>{if(dl.open)dl.close()};
document.getElementById('stdlgkeep').onclick=()=>{done();announce('kept '+n+' out-of-date draft'+(n===1?'':'s'))};
document.getElementById('stdlgdrop').onclick=()=>{
cleared=clearDrafts(stale);done();
if(haveBook())build();
announce(cleared+' out-of-date draft'+(cleared===1?'':'s')+' cleared');
};
// Escape closes a , and the safe answer to a destructive question is the one you get by
// dismissing it: keep the edits. Focus goes to Keep for the same reason.
dl.showModal();
try{document.getElementById('stdlgkeep').focus()}catch(e){}
return cleared; // 0 here; the drop button clears asynchronously
}
document.getElementById('seltb').addEventListener('click',ev=>{const a=ev.target.dataset.a;if(!a)return;const ids=[...S.sel];
if(a==='merge')mergeSelection();else if(a==='sup')toggleSup(ids);else if(a==='rev')toggleRev(ids);
else if(a==='link')startLink(ids);
else if(a==='unalign'){DRAG=ids;moveTo(-1)}else if(a==='del')del(ids);else if(a==='clear'){S.sel=new Set();render()}});
document.getElementById('exp').onclick=exportBundle;
window.dlg=document.getElementById('dlg');window.hdlg=document.getElementById('hdlg');
['ndlg','gdlg','idlg','sdlg','ldlg'].forEach(id=>{window[id]=document.getElementById(id)}); // M12 dialogs + link
/* ---------- load a real pipeline interlinear.json (C2) ---------- */
/* Re-chrome the header for the loaded book. A Hebrew backbone (meta.backbone_lang==='hbo'
or meta.rtl) must NOT be labelled Greek; per-word RTL is already handled by isHebrew(). */
function applyMeta(){
const isHeb=!!(META&&(META.backbone_lang==='hbo'||META.rtl));
const short=(META&&META.backbone_short)||(isHeb?'Heb':'Grk');
const label=(META&&META.backbone_label)||(isHeb?'Hebrew':'Greek');
const name=(META&&META.book)||BOOK;
const bt=document.getElementById('brandTitle');
if(bt)bt.innerHTML=''+esc(name)+' · '+esc(label)+' backbone';
const bk=document.getElementById('brandKick');
if(bk)bk.textContent='Pentaglot Interlinear · '+label+' → English → Cebuano → Tagalog → Maranao';
const ob=document.getElementById('ordBackbone');
if(ob)ob.textContent=short;
document.title=name+' — Interlinear · Reader & Editor';
}
function loadData(j){
if(!j||!Array.isArray(j.verses)||!j.verses.length){
alert('Not a pipeline interlinear.json — expected a {verses:[...]} object.');return;}
META=j.meta||null;
BOOK=(META&&(META.code||META.book))||j.book||'BOOK';
CHAP=j.chapter||1;
VERSES=j.verses;
LEXICON=j.lex||{};
STORE_KEY='ulb-editor:'+BOOK+':'+CHAP;
try{SAVED=JSON.parse(localStorage.getItem(STORE_KEY)||'{}')||{}}catch(e){SAVED={}}
DIRTY=false;
S={vi:0,lang:S.lang||'ceb',tokens:[],cards:[],nid:1,hist:[],fut:[],orig:'',sel:new Set(),ops:[],focus:null,note:'',bt:'',mg:{},
notes:[],log:[],logseq:0,logdropped:0,signoff:{}};
// M12: a project running a different review ladder ships it in the book JSON (meta.review_levels).
REVIEW_LEVELS=normLevels(META&&META.review_levels)||normLevels(HOST&&HOST.reviewLevels)||DEFAULT_REVIEW_LEVELS;
if(!levelOf(WHO.level))WHO.level=REVIEW_LEVELS[0].n;
undoReset(); // F10: another book's undo snapshots can never apply here
ORDER='grk';
applyMeta();
build();setView('editor');
// After the board is drawn, not before: the dialog is a real rather than a blocking
// confirm(), so it opens OVER the verses it is asking about — and the ✎! marks it refers to are
// already on screen behind it. Its own handler rebuilds if anything is cleared.
try{offerStaleCleanup()}catch(e){}
announce('loaded '+BOOK+' — '+VERSES.length+' verse(s), '+((META&&META.backbone_label)||'Greek')+' backbone');
}
(function(){
const lj=document.getElementById('loadjson');
document.getElementById('loadbtn').onclick=()=>lj.click();
lj.addEventListener('change',ev=>{
const f=ev.target.files&&ev.target.files[0];if(!f)return;
const rd=new FileReader();
rd.onload=()=>{let j;try{j=JSON.parse(rd.result)}catch(e){alert('Could not parse JSON: '+e.message);return}loadData(j)};
rd.onerror=()=>alert('Could not read the file.');
rd.readAsText(f);ev.target.value=''; // allow re-loading the same filename
});
})();
/* M10(b): warn before leaving with unexported override edits (autosave can silently fail). */
window.addEventListener('beforeunload',ev=>{
let has=false;try{const b=buildBundle();has=b.entries.length>0||!!b.stack_overrides||!!b.reconcile_resolutions}catch(e){has=DIRTY}
if(has&&(DIRTY||!PERSIST_OK)){ev.preventDefault();ev.returnValue='';return ''}
});
/* Sticky chrome is measured, not guessed. The header wraps to a second row below ~1100px (67px at
1440, 115px at 1024) and the foot grows with the note field and the legend wrap, so every literal
offset was wrong at some width. Publishing the live heights on :root lets the CSS keep its
literal fallbacks for the first paint and self-correct from the observer's first callback. */
(function(){const root=document.documentElement,hdr=document.querySelector('header.hdr'),
nav=document.getElementById('navbar'),foot=document.getElementById('foot');
const h=el=>el?Math.round(el.getBoundingClientRect().height):0;
function sync(){root.style.setProperty('--hdr-h',h(hdr)+'px');
root.style.setProperty('--nav-h',h(nav)+'px');
root.style.setProperty('--foot-h',h(foot)+'px');
bottom();}
/* Where the sticky chrome ends in the VIEWPORT — not the sum of its heights. The banner above
the header scrolls away, so the header only reaches y=0 after ~158px of scrolling and the
figure changes as you scroll. The desk pane hangs off this, so it is kept current per frame
rather than per resize. */
function bottom(){const b=Math.max(nav?nav.getBoundingClientRect().bottom:0,
hdr?hdr.getBoundingClientRect().bottom:0,0);
root.style.setProperty('--chrome-b',Math.round(b)+'px');}
let queued=false;
addEventListener('scroll',()=>{if(queued)return;queued=true;
requestAnimationFrame(()=>{queued=false;bottom()})},{passive:true});
sync();
// ResizeObserver catches the reflows no event reports: a control row wrapping, the foot's reading
// line growing, a font swapping in. resize/orientationchange cover the rest.
if(window.ResizeObserver){const ro=new ResizeObserver(sync);[hdr,nav,foot].forEach(el=>{if(el)ro.observe(el)});}
addEventListener('resize',sync);addEventListener('orientationchange',sync);
})();
/* ═══ The word behind a card ═══════════════════════════════════════════════
The board prints what fits on a card: the Greek, the Strong's number, the morphology code.
Everything else the app already knows — the dictionary headword, the BestLex sense, the
morphology read out in words, every language at once — had nowhere to go on a desk. It goes
here, opened from either end of the same link: a word in the reading line, or the head of the
card it sits on.
Both ends light while it is open, so the dialog also answers "where is this word in the
sentence" and "which card is it" without being asked. */
function wordDialogFor(col, tid){
const v=VERSES[S.vi]; const w=v&&v.words&&v.words[col];
const dlg=document.getElementById('wdlg'); if(!dlg) return;
const head=document.getElementById('wdlgh'), body=document.getElementById('wdlgbody');
// LEXICON, not LEX: `const LEX = __LEX__` is declared inside the reader TEMPLATE, which lives
// here as inert text in #readerTpl and is only a real binding once the iframe parses it. In the
// editor's own scope that name has never existed, so `typeof LEX!=='undefined'` was false every
// time and this dialog silently dropped the headword and the sense — the two things the comment
// above says it exists to surface. The editor's copy of the same payload is LEXICON.
const lex=(w&&w.strong&&typeof LEXICON!=='undefined'&&LEXICON[w.strong])||null;
const mw=(w&&w.morph&&typeof morphWords==='function')?morphWords(w.morph):'';
head.textContent = w ? w.g : 'Unaligned word';
const row=(k,val,cls)=>''+k+' '+
(val?esc(val):'— ')+'
';
const langRows=Object.keys(LANGS).map(k=>row(LANGS[k].n.slice(0,3),wordIn(w,k),'v-'+k)).join('');
body.innerHTML = (w?(
''+esc(w.g)+'
'+
(lex&&lex.l?'
'+esc(lex.l)+'
':'')+
(mw?'
'+esc(mw)+'
':'')+
'
'+esc(w.strong||'')+(w.morph?' · '+esc(w.morph):'')+'
'+
(lex&&lex.d?'
'+esc(lex.d)+'
':'')+wordSrc(lex,w)+'
'
):'This word is not attached to a card.
')
+ ''+langRows+(w&&w.mg?row('Mrw→En',w.mg,'wbt'):'')+'
';
// light both ends, and put them out again when it closes
document.querySelectorAll('.rl .rw.on').forEach(e=>e.classList.remove('on'));
document.querySelectorAll('.spine .col.wlit').forEach(e=>e.classList.remove('wlit'));
if(tid!=null){const e=document.querySelector('.rl .rw[data-tid="'+CSS.escape(String(tid))+'"]');
if(e)e.classList.add('on');}
// the alignment group carries data-col; its one non-translator-added column is the card
const grp=document.querySelector('.spine .grp[data-col="'+col+'"]');
const colEl=grp?grp.querySelector('.col:not(.ta)'):null;
if(colEl)colEl.classList.add('wlit');
if(!dlg.open)dlg.showModal();
}
/* Where the three claims above came from: the headword's lexicon, the gloss's, and the English.
The board has no SRC row to read off the way the reader's panel does, so this composes the same
line from the same fields, by srcRow()'s rule — ls, then ds when it differs, then the English,
which is per WORD rather than per Strong's because a bridged book can align two words sharing a
lemma from different places. A machine-made alignment keeps its gold so it can never read as a
translator's own. Silent when the lexicon says nothing: an empty "SRC —" is worse than no row. */
function wordSrc(lex,w){
if(!w) return '';
const bits=[]; const e=lex||{};
if(e.ls) bits.push(esc(e.ls));
if(e.ds && e.ds!==e.ls) bits.push(esc(e.ds));
if(w.esrc) bits.push(''+esc(w.esrc)+' ');
else if(w.en) bits.push('ULT');
return bits.length?'src '+bits.join(' · ')+'
':'';
}
/* the display form of `w` in language k — the same field the card row shows */
function wordIn(w,k){ if(!w)return ''; const d={en:'de',ceb:'dc',tl:'dt',mrw:'dm'}[k];
const raw=(d&&w[d]!==undefined)?w[d]:w[k]; return (raw===undefined||raw===null)?'':String(raw); }
(function(){const dlg=document.getElementById('wdlg'); if(!dlg)return;
const clear=()=>{document.querySelectorAll('.rl .rw.on').forEach(e=>e.classList.remove('on'));
document.querySelectorAll('.spine .col.wlit').forEach(e=>e.classList.remove('wlit'));};
dlg.addEventListener('close',clear);
// from the sentence: a word in the reading line names the card it came from
document.addEventListener('click',e=>{const rw=e.target.closest('.rl .rw'); if(!rw)return;
const t=(S.tokens||[]).find(x=>String(x.id)===rw.dataset.tid); if(!t)return;
wordDialogFor(t.col,t.id);});
document.addEventListener('keydown',e=>{if(e.key!=='Enter'&&e.key!==' ')return;
const rw=e.target.closest&&e.target.closest('.rl .rw'); if(!rw)return; e.preventDefault(); rw.click();});
// from the board: the card head, but never while a link is armed — there the click means
// "attach the armed word to this card", and that gesture keeps its meaning.
document.addEventListener('click',e=>{
// Anywhere lens mode does not own the tap. Gating this on body.pane left it dead between
// 721 and 899px, where neither mode is on — a tablet could see the word and not open it.
if(document.body.classList.contains('lens'))return;
if(document.body.classList.contains('cansel-link')||document.body.classList.contains('linking'))return;
const g=e.target.closest('.spine .card .g'); if(!g)return;
const col=g.closest('.col'); if(!col||col.classList.contains('ta'))return;
const grp=col.closest('.grp[data-col]'); if(!grp)return;
e.stopPropagation(); wordDialogFor(+grp.dataset.col,null);});
})();
/* The pane is a desk affordance. Gate it on a class the way lens mode is gated, so the media
query is never the only thing deciding — and so the two can never overlap. */
(function(){const MQ=matchMedia('(min-width:900px)');
const set=()=>document.body.classList.toggle('pane',MQ.matches);
set(); MQ.addEventListener?MQ.addEventListener('change',set):MQ.addListener(set);})();
/* Wide screens have the room to keep the drop-rule reference open; a phone does not — there it
would push the first Greek word off the fold. Set once, so a manual toggle is never overridden. */
/* The instruction block used to force itself open on every desk load — 145-220px of the first
screen, more than the header and the toolbar together at 1024, re-asserted after you closed
it. It is a , so it was always collapsible; what it was not was OBEDIENT. Now it
opens closed and remembers what you last did with it, per browser. A reviewer who wants the
drop rules keeps them; one who has read them once never sees them again. */
(function(){
const eh=document.getElementById('edhint'); if(!eh) return;
const K='iln-hint-open';
let want=null; try{want=localStorage.getItem(K)}catch(e){}
eh.open = want==='1';
eh.addEventListener('toggle',()=>{try{localStorage.setItem(K,eh.open?'1':'0')}catch(e){}});
})();
/* ═══════════════════════════════════════════════════════════════════════════════════════════
LENS MODE — the phone layout (option B; see docs/mobile-redesign-options.html).
Three parts, and only the first two change what is on screen at rest:
1. the RAIL — the backbone pinned, every other track a chip, and the height the current
set costs printed under it. It writes SHOWN, the same state the desk Show control
writes, so nothing downstream can tell which control was used.
2. the DEFAULT — a phone starts on one track, the one being edited. Seeded only when the
reviewer has never made a Show choice, so an explicit preference is never stomped.
3. the CARD PANEL — Strong's, the morphology code read out in words, the dictionary
headword and sense, and every language including the ones the rail is hiding, with
the confidence on each. It is where the detail that used to cost every card a row now
lives, and it is opened by tapping a card.
MORPH_WORDS below is duplicated verbatim in template/interlinear.template.html — the reader
is a separate document, so it cannot share code with this one. tests/test_morph_words.py
fails if the two copies drift.
═════════════════════════════════════════════════════════════════════════════════════════ */
/* MORPH_WORDS_BEGIN */
/* The older BSB codes (N-GMS, V-AOP-3S, PPro-D2P). The spine has moved to UGNT and UHB, whose
codes the two blocks below read; this one stays for any book still carrying the old form. */
const MORPH_POS={N:'Noun',V:'Verb',Adj:'Adjective',Adv:'Adverb',Art:'Article',Prep:'Preposition',
Conj:'Conjunction',PPro:'Personal pronoun',RelPro:'Relative pronoun',DPro:'Demonstrative pronoun',
IPro:'Interrogative pronoun',RefPro:'Reflexive pronoun',RecPro:'Reciprocal pronoun',
Prtcl:'Particle',I:'Interjection',Heb:'Hebrew word'};
const MORPH_CASE={N:'nominative',G:'genitive',D:'dative',A:'accusative',V:'vocative'};
const MORPH_GENDER={M:'masculine',F:'feminine',N:'neuter'};
const MORPH_NUMBER={S:'singular',P:'plural'};
const MORPH_TENSE={P:'present',I:'imperfect',F:'future',A:'aorist',R:'perfect',L:'pluperfect'};
const MORPH_MOOD={I:'indicative',S:'subjunctive',O:'optative',M:'imperative',N:'infinitive',P:'participle'};
const MORPH_VOICE={A:'active',M:'middle',P:'passive'};
const MORPH_PERSON={1:'first person',2:'second person',3:'third person'};
const MORPH_DEGREE={C:'comparative',S:'superlative'};
function bsbWords(code){
if(!code)return '';
const parts=String(code).split('-'),pos=parts[0],out=[MORPH_POS[pos]||pos];
if(pos==='V'&&parts.length>=2){
const tmv=parts[1];
if(!(tmv.length>=3&&MORPH_TENSE[tmv[0]]&&MORPH_MOOD[tmv[1]]))return code;
out.push(MORPH_TENSE[tmv[0]],MORPH_MOOD[tmv[1]]);
const v=tmv.slice(2);out.push(v==='MP'?'middle or passive':(MORPH_VOICE[v]||v));
const agr=parts[2]||'';
if(/^[123][SP]$/.test(agr))out.push(MORPH_PERSON[agr[0]],MORPH_NUMBER[agr[1]]);
else if(agr.length===3&&MORPH_CASE[agr[0]])out.push(MORPH_CASE[agr[0]],MORPH_GENDER[agr[1]],MORPH_NUMBER[agr[2]]);
else if(agr)return code;
return out.filter(Boolean).join(' · ');
}
const suf=parts[1]||'';
if(!suf)return out[0];
// a third-person pronoun carries a gender its first- and second-person forms have no use
// for, so PPro-GM3P is four characters where PPro-G1P is three
if(/Pro$/.test(pos)&&/^[NGDAV][MFN][123][SP]$/.test(suf))
out.push(MORPH_CASE[suf[0]],MORPH_GENDER[suf[1]],MORPH_PERSON[suf[2]],MORPH_NUMBER[suf[3]]);
else if(/Pro$/.test(pos)&&/^[NGDAV][123][SP]$/.test(suf))
out.push(MORPH_CASE[suf[0]],MORPH_PERSON[suf[1]],MORPH_NUMBER[suf[2]]);
else if(/^[NGDAV][MFN][SP]$/.test(suf))
out.push(MORPH_CASE[suf[0]],MORPH_GENDER[suf[1]],MORPH_NUMBER[suf[2]]);
else if(MORPH_DEGREE[suf]) // Adv-C, Adv-S and their adjectival twins
out.push(MORPH_DEGREE[suf]);
else return code;
return out.filter(Boolean).join(' · ');
}
/* ── Morphology, read out in words ──────────────────────────────────────────────────────────
Three encodings reach this function, and it dispatches on the prefix:
He,C:Vqw3ms:Sp3fs OSHB (Hebrew/Aramaic), colon-separated MORPHEMES inside one word
Gr,V,IAA3,,,,, CNTR/BHP (Greek), a role plus comma-separated attribute groups
V-PIA-3S the older hyphenated BSB form, kept for books not yet migrated
Anything it cannot read is returned unchanged, so a card shows the raw code rather than a
confident wrong answer — and `mw !== code` is how the caller knows to print "read from …".
Hebrew names the stem and the conjugation technically, because that is what a grammar and a
lexicon are indexed by, with the plain reading in brackets after it: piel (intensive). Every
morpheme is read out — a prefixed conjunction or an object suffix is part of the word on the
card, and a readout that silently covers only the middle of it would be describing something
the reader is not looking at. */
/* ---- CNTR / BHP Greek (greekcntr.org). Role, then Mood Tense Voice Person Case Gender Number,
with the role deciding which of those may apply. ---- */
const GK_ROLE={
N:'noun', NS:'substantive adjective', NP:'proper noun',
AA:'adjective', AR:'adjective',
EA:'article', EP:'possessive determiner', EQ:'quantifier', ED:'demonstrative determiner',
EN:'number determiner', EO:'ordinal determiner', EF:'determiner', ET:'determiner',
RP:'personal pronoun', RD:'demonstrative pronoun', RR:'relative pronoun',
RI:'interrogative pronoun', RE:'reflexive pronoun', RT:'correlative pronoun',
RC:'reciprocal pronoun',
V:'verb', P:'preposition', PI:'improper preposition',
D:'adverb', DO:'correlative adverb',
CC:'coordinating conjunction', CS:'subordinating conjunction', CO:'correlative conjunction',
T:'particle', IE:'interjection', I:'interjection'
};
const GK_MOOD={I:'indicative',M:'imperative',S:'subjunctive',O:'optative',N:'infinitive',P:'participle'};
const GK_TENSE={P:'present',I:'imperfect',F:'future',A:'aorist',E:'perfect',L:'pluperfect'};
const GK_VOICE={A:'active',M:'middle',P:'passive'};
const GK_CASE={N:'nominative',G:'genitive',D:'dative',A:'accusative',V:'vocative'};
const GK_GEN={M:'masculine',F:'feminine',N:'neuter'};
const GK_NUM={S:'singular',P:'plural'};
const GK_PERSON={1:'first person',2:'second person',3:'third person'};
const GK_DEG={C:'comparative',S:'superlative',I:'indeclinable'};
/* One attribute group — the trailing chunks of a CNTR code, e.g. NMS, 1D, 3DMP, AMSC, S.
Returns null when the chunk is not an attribute group, so an unknown field can never be
silently read as grammar. */
function gkGroup(g){
const out=[]; let i=0;
if(/^[123]/.test(g)){out.push(GK_PERSON[g[0]]); i=1;}
if(ipair[0]+' ('+pair[1]+')';
/* person / gender / number, in whatever combination the morpheme carries */
function hbPGN(s){
const out=[];
for(const ch of s){
if(HB_PERSON[ch])out.push(HB_PERSON[ch]);
else if(HB_GEN[ch])out.push(HB_GEN[ch]);
else if(HB_NUM[ch])out.push(HB_NUM[ch]);
else return null;
}
return out;
}
function hbMorpheme(m){
const k=m[0], r=m.slice(1);
if(k==='C')return r?null:['and']; // prefixed conjunction
if(k==='D')return r?null:['adverb'];
if(k==='R'){ // preposition, maybe with article
if(!r)return ['preposition'];
if(r==='d')return ['preposition','with the definite article'];
return null;
}
if(k==='T')return HB_PART[r]?[HB_PART[r]]:null;
if(k==='A'){ // Aamsa
if(!HB_ADJ[r[0]])return null;
const pgn=hbPGN(r.slice(1,-1)), st=HB_STATE[r[r.length-1]];
if(!pgn||!st)return null;
return [HB_ADJ[r[0]],...pgn,st];
}
if(k==='N'){ // Ncmsa, Np
if(!HB_NOUN[r[0]])return null;
if(r.length===1)return [HB_NOUN[r[0]]];
const pgn=hbPGN(r.slice(1,-1)), st=HB_STATE[r[r.length-1]];
if(!pgn||!st)return null;
return [HB_NOUN[r[0]],...pgn,st];
}
if(k==='P'){ // Pp3ms, Pdxcp
if(!HB_PRO[r[0]])return null;
const pgn=hbPGN(r.slice(1));
if(!pgn)return null;
return [HB_PRO[r[0]],...pgn];
}
if(k==='S'){ // Sp3ms, Sn, Sh, Sd
if(!HB_SUF[r[0]])return null;
if(r.length===1)return [HB_SUF[r[0]]];
const pgn=hbPGN(r.slice(1));
if(!pgn)return null;
return [HB_SUF[r[0]],...pgn];
}
if(k==='V'){ // Vqp3ms, Vqrmsa, Vqc
const stem=HB_STEM[r[0]], conj=HB_CONJ[r[1]];
if(!stem||!conj)return null;
const out=['verb',gloss(stem),gloss(conj)];
const rest=r.slice(2);
if(!rest)return out; // infinitives carry no agreement
if(r[1]==='r'||r[1]==='s'){ // participles inflect like nouns
const pgn=hbPGN(rest.slice(0,-1)), st=HB_STATE[rest[rest.length-1]];
if(!pgn||!st)return null;
return [...out,...pgn,st];
}
const pgn=hbPGN(rest);
if(!pgn)return null;
return [...out,...pgn];
}
return null;
}
function hbWords(body){
const parts=[];
for(const m of body.split(':')){
if(!m)continue;
const w=hbMorpheme(m);
if(!w)return null; // one unknown morpheme: say nothing
parts.push(w.join(' · '));
}
return parts.length?parts.join(' + '):null;
}
/* The one entry point. Anything none of the three can read is returned unchanged, so a card
shows the raw code rather than a confident wrong answer — and `mw !== code` is how the caller
knows to print "read from the morphology code …" underneath. */
function morphWords(code){
if(!code)return '';
const s=String(code).trim();
let out=null;
if(/^(He|Ar),/.test(s))out=hbWords(s.slice(3));
else if(/^Gr,/.test(s))out=gkWords(s);
else return bsbWords(s);
return out||code;
}
/* MORPH_WORDS_END */
const BT_KEY='ulb-editor-lens-bt';
const LENS=(()=>{
const MQ=matchMedia('(max-width:720px)');
const TRI={en:'ENG',ceb:'CEB',tl:'TAG',mrw:'MRW'};
const CONF={high:'high',med:'medium',medium:'medium',low:'low'};
const wc=s=>{const t=String(s||'').trim();return t?t.split(/\s+/).length:0};
/* A phone shows one track by default — but only for a reviewer who has never said otherwise.
SHOWN is one preference across both layouts, so seeding it on top of a real choice would
silently undo that choice the next time they opened a desk browser. */
let seeded=false;
function seed(){
if(seeded)return;seeded=true;
let bt=null;try{bt=localStorage.getItem(BT_KEY)}catch(e){}
document.body.classList.toggle('nobt',bt!=='1'); // the gloss track is off by default here
let stored=null;try{stored=localStorage.getItem(SHOW_KEY)}catch(e){}
if(stored)return; // an explicit choice — leave it alone
['en','ceb','tl','mrw'].forEach(l=>{SHOWN[l]=(l===S.lang)});
}
/* The height of the verse currently on the board, which is what a track costs. Measured off
the spine rather than predicted, so the number cannot drift from the layout. */
function verseHeight(){
const sp=document.getElementById('spine');
if(!sp)return null;
let h=sp.getBoundingClientRect().height;
sp.querySelectorAll('.lensdet').forEach(d=>{h-=d.getBoundingClientRect().height});
return Math.max(0,Math.round(h));
}
function rail(){
const main=document.getElementById('editorView');
if(!main)return null;
let r=document.getElementById('lensrail');
if(!r){
r=document.createElement('div');r.id='lensrail';r.className='lensrail';
main.insertBefore(r,main.firstChild);
}
return r;
}
function renderRail(){
if(!document.body.classList.contains('lens')){
const r=document.getElementById('lensrail');if(r)r.remove();
return;
}
buildBar();buildActs();pairUndo();
const r=rail();if(!r)return;
const bb=(META&&META.backbone_short)||'Grk';
let h=''+
''+esc(bb.toUpperCase())+' ';
['en','ceb','tl','mrw'].forEach(l=>{
const active=(l===S.lang); // the language being edited is never off
const on=!!SHOWN[l]||active;
h+=''+
TRI[l]+' '+(active?'✎':(on?'×':'+'))+' ';
});
{const on=!document.body.classList.contains('nobt');
h+='M→E '+
(on?'×':'+')+' ';}
const nTracks=['en','ceb','tl','mrw'].filter(l=>SHOWN[l]||l===S.lang).length+1+
(document.body.classList.contains('nobt')?0:1);
h+='
'+nTracks+
' tracks verse — '+
''+
(document.body.classList.contains('tools')?'hide tools':'tools')+'
';
r.innerHTML=h;
r.querySelectorAll('.tk[data-l]').forEach(b=>{
if(b.disabled)return;
b.onclick=()=>{const l=b.dataset.l;SHOWN[l]=!SHOWN[l];saveShow();dbg('lens',(SHOWN[l]?'show ':'hide ')+l);build()};
});
{const bt=r.querySelector('.tk[data-bt]');
if(bt)bt.onclick=()=>{
const off=document.body.classList.toggle('nobt');
try{localStorage.setItem(BT_KEY,off?'0':'1')}catch(e){}
dbg('lens',(off?'hide':'show')+' the Mrw→En gloss track');renderRail();
};}
r.querySelector('#lenstools').onclick=()=>{
document.body.classList.toggle('tools');renderRail();
};
const c=document.getElementById('lenscost');
if(c){const h2=verseHeight();c.textContent=h2==null?'—':h2.toLocaleString()+'px'}
}
/* ══ the bar ══════════════════════════════════════════════════════════════
Nothing here is a second copy of a control. Every one of them is the control the desk
layout already had, MOVED — so its handlers, its state and the code that syncs it are
untouched, and leaving lens mode puts it back exactly where it was. `home` remembers the
parent and the next sibling, which is the only way a restore can be exact. */
const homes=new WeakMap();
function relocate(el,into){
if(!el||!into||el.parentNode===into)return;
if(!homes.has(el))homes.set(el,{parent:el.parentNode,next:el.nextSibling});
into.appendChild(el);
}
function restore(el){
const h=el&&homes.get(el);
if(!h||!h.parent)return;
h.parent.insertBefore(el,h.next&&h.next.parentNode===h.parent?h.next:null);
homes.delete(el);
}
let sheetOpen=null;
function sheet(id,title){
let s=document.getElementById(id);
if(!s){
s=document.createElement('div');s.id=id;s.className='lenssheet';
s.setAttribute('role','dialog');s.setAttribute('aria-label',title);
s.innerHTML='
'+esc(title)+'
';
document.body.appendChild(s);
s.querySelector('.lsh-grab').onclick=()=>closeSheet();
}
return s;
}
function scrim(){
let sc=document.getElementById('lensscrim');
if(!sc){sc=document.createElement('div');sc.id='lensscrim';sc.className='lensscrim';
document.body.appendChild(sc);sc.onclick=()=>closeSheet();}
return sc;
}
function openSheet(id){
closeSheet();
const s=document.getElementById(id);if(!s)return;
s.classList.add('open');scrim().classList.add('on');sheetOpen=id;
}
function closeSheet(){
document.querySelectorAll('.lenssheet.open').forEach(s=>s.classList.remove('open'));
const sc=document.getElementById('lensscrim');if(sc)sc.classList.remove('on');
sheetOpen=null;
}
function refLabel(){
const v=VERSES&&VERSES[S.vi];
return {book:(META&&META.book)||BOOK||'—', ref:v?verseLabel(v):''};
}
function buildBar(){
const hdr=document.querySelector('header.hdr');
if(!hdr)return;
let bar=document.getElementById('lensbar');
if(!bar){
bar=document.createElement('div');bar.id='lensbar';bar.className='lensbar';
bar.innerHTML=''+
'▾ ';
hdr.insertBefore(bar,hdr.firstChild);
const more=document.createElement('button');
more.type='button';more.className='lb-ic';more.id='lensacct';more.textContent='⋯';
more.setAttribute('aria-label','Account, sign-out and the rest of the header');
more.setAttribute('aria-haspopup','dialog');
bar.appendChild(more);
document.getElementById('lensref').onclick=()=>openSheet('lenssheet-nav');
more.onclick=()=>openSheet('lenssheet-acct');
}
/* The ⓘ rides along. At phone width the whole .brand block is hidden, and the Reader's own
banner — which used to carry this affordance — is hidden too, so without this the About
panel is unreachable on a phone: no sources, and none of the CC BY-SA attributions the
provenance list exists to give. It sits with the other icon buttons and goes home with
them on the way out of lens mode. */
relocate(document.getElementById('hdrinfo'),bar);
// the view toggle keeps its place in the bar, between the reference and the "⋯"
const seg=document.getElementById('viewseg');
if(seg&&seg.parentNode!==bar)bar.insertBefore(seg,document.getElementById('lensacct'))||relocate(seg,bar);
if(seg&&!homes.has(seg))homes.set(seg,{parent:document.querySelector('header.hdr'),next:null});
const nav=sheet('lenssheet-nav','Go to').querySelector('.lsh-body');
// both pickers live here: the host's Book/Chapter/Verse, and the editor's own verse list,
// which is the one that carries each verse's unreviewed count
relocate(document.getElementById('__hostnav'),nav);
// …and the editor's own verse list only when there is no host picker: with one, the host
// folds this label away on purpose (.__hosthide), and a hidden control in a sheet is noise
const vlab=document.getElementById('vsel')&&document.getElementById('vsel').closest('label');
if(vlab&&!document.getElementById('__versepick'))relocate(vlab,nav);
const acct=sheet('lenssheet-acct','Account').querySelector('.lsh-body');
relocate(document.getElementById('__hoststatus'),acct);
if(!document.getElementById('__hoststatus'))
acct.textContent='This copy of the editor is not signed in to a server.';
const r=refLabel();
const bk=bar.querySelector('.bk'),cv=bar.querySelector('.cv');
if(bk)bk.textContent=r.book;
if(cv)cv.textContent=r.ref;
const ref=document.getElementById('lensref');
if(ref)ref.setAttribute('aria-label','Currently at '+r.book+' '+r.ref+' — choose another');
}
/* Save, flag and next: the three things a reviewer reaches for on every card. They ride in
the foot, which is already fixed to the bottom and already measured, rather than adding a
second fixed bar for the screen to pay for. */
function buildActs(){
const foot=document.getElementById('foot');
if(!foot)return;
let acts=document.getElementById('lensacts');
if(!acts){
acts=document.createElement('div');acts.id='lensacts';acts.className='lensacts';
foot.insertBefore(acts,foot.firstChild);
const flag=document.createElement('button');
flag.type='button';flag.id='lensflag';flag.innerHTML='⚑ Flag';
flag.title='Flag the selected word as a concern (f)';
flag.onclick=()=>{
const ids=S.sel.size?[...S.sel]:(S.focus!=null?[S.focus]:[]);
if(ids.length)toggleFlag(ids);
};
acts.appendChild(flag);
}
const flag=document.getElementById('lensflag');
relocate(document.getElementById('__savebtn'),acts);
relocate(document.getElementById('nextflag'),acts);
// appendChild moves a node already here, so re-asserting the order every render is what
// keeps it Save · Flag · Next rather than whatever the last relocate happened to leave
[document.getElementById('__savebtn'),flag,document.getElementById('nextflag')]
.forEach(el=>{if(el)acts.appendChild(el)});
// the save button is the host's; a standalone editor has none, so say so rather than
// leaving a gap where a reviewer would look for it
if(!document.getElementById('__savebtn')&&!document.getElementById('lensnosave')){
const n=document.createElement('button');n.type='button';n.id='lensnosave';n.disabled=true;
n.textContent='Autosaved';n.title='This copy saves to this browser, not to a server';
acts.insertBefore(n,acts.firstChild);
}
syncActs();
}
/* ⚑ Flag acts on the selection, so it is only live when there is one. */
function syncActs(){
const flag=document.getElementById('lensflag');
if(flag)flag.disabled=!(S.sel&&(S.sel.size||S.focus!=null));
}
/* Undo and redo are one control in two halves; a wrapping toolbar was putting them on
different lines, which reads as a layout fault rather than a pair. One flex item cannot be
split, so they travel together. */
function pairUndo(){
const nav=document.getElementById('navbar');
const u=document.getElementById('undo'),r=document.getElementById('redo');
if(!nav||!u||!r)return;
let box=document.getElementById('lensundo');
if(!box){box=document.createElement('span');box.id='lensundo';box.className='lensundo';}
if(box.parentNode!==nav)nav.appendChild(box);
relocate(u,box);relocate(r,box);
}
function tearDownChrome(){
closeSheet();
['viewseg','__hostnav','__hoststatus','__savebtn','nextflag','undo','redo','hdrinfo'].forEach(id=>{
const el=document.getElementById(id);if(el)restore(el);
});
const vsel=document.getElementById('vsel');
if(vsel&&vsel.closest('label'))restore(vsel.closest('label'));
['lensbar','lensacts','lensundo','lenssheet-nav','lenssheet-acct','lensscrim','lensnosave']
.forEach(id=>{const el=document.getElementById(id);if(el)el.remove();});
}
/* ---- the card, opened ---------------------------------------------------
Everything a card knows, including the tracks the rail is hiding. Reads the LIVE tokens,
not the draft display, so it shows what is on the card now. */
function cardData(col){
const ci=+col.dataset.ci,w=(VERSES[S.vi].words||[])[ci];
if(!w)return null;
const core=col.dataset.kind==='core';
const live=liveCols(S.vi);
const out={ci,w,core,side:col.dataset.side,kind:col.dataset.kind,langs:{},conf:{}};
['en','ceb','tl','mrw'].forEach(l=>{
const runs=supRuns(live[l][ci]||[]);
let toks;
if(core)toks=runs.core;
else{const kr=kindRuns(runs[out.side]);toks=kr[out.kind]||[]}
out.langs[l]=runText(toks);
// one card can hold words of differing confidence; report the weakest, which is the one
// a reviewer would want to be told about
const order={low:0,med:1,high:2};
let worst=null;toks.forEach(t=>{if(worst==null||order[t.conf]'+esc((w.pre||'')+w.g+(w.post||''))+'
'+
''+esc(w.strong||'—')+' '+esc(w.morph||'')+'
';
}else{
const fn=d.kind==='fn';
h+=''+
(fn?'Function word':'+ Supplemental')+'
'+
''+(d.side==='pre'?'before ':'after ')+esc(w.g)+' no '+
esc(((META&&META.backbone_label)||'Greek'))+'
';
}
h+='✕ ';
if(d.core&&w.morph){
const mw=morphWords(w.morph);
h+=''+esc(mw)+(mw!==w.morph
? 'read from the morphology code '+esc(w.morph)+' '
: '')+'
';
}
if(d.core&&(lex.l||lex.d)){
h+='Dictionary headword
'+
esc(lex.l||'—')+'
'+(lex.d?'
'+esc(lex.d)+'
':'')+'
';
}
h+='All tracks
';
['en','ceb','tl','mrw'].forEach(l=>{
const v=d.langs[l],n=wc(v),on=(SHOWN[l]||l===S.lang),cf=d.conf[l];
h+='
'+TRI[l]+' '+
''+(v?esc(v):'— ')+
(n>2?''+n+'w ':'')+' '+
(cf?''+esc(CONF[cf]||cf)+' ':'')+'
';
});
if(d.bt)h+='
M→E '+esc(d.bt)+'
';
h+='
';
h+='';
if(!d.core)h+='⇄ Make '+(d.kind==='fn'?'supplemental':'a function word')+' ';
h+='Edit '+esc(LANGS[S.lang].n)+' ';
h+='
';
return h;
}
function close(){
document.querySelectorAll('.lensdet').forEach(p=>p.remove());
document.querySelectorAll('.col.lenson').forEach(c=>c.classList.remove('lenson'));
renderRail();
}
function open(col){
const wasOpen=col.classList.contains('lenson');
close();
if(wasOpen)return;
const d=cardData(col);if(!d)return;
col.classList.add('lenson');
const panel=document.createElement('div');panel.className='lensdet';
panel.innerHTML=panelHTML(d);
// the panel is a full-width row after the group the card sits in, so it reads the same
// whether the card is alone or one of four in a wrapped group
const grp=col.closest('.grp')||col;
grp.parentNode.insertBefore(panel,grp.nextSibling);
panel.querySelector('.dclose').onclick=close;
panel.querySelectorAll('[data-act]').forEach(b=>{
b.onclick=()=>{
if(b.dataset.act==='flip'){close();flipCardKind(d.ci,d.side,d.kind);return}
close();
const slot=col.querySelector('.track.active .slot');
if(slot)slot.scrollIntoView({block:'center'});
const chip=col.querySelector('.track.active .chip');
if(chip)chip.focus();else addWord(d.ci);
};
});
panel.scrollIntoView({block:'nearest'});
dbg('lens','opened the '+(d.core?w2s(d.w):'supplied')+' card');
renderRail();
}
function w2s(w){return (w&&w.g)||'card'}
/* A tap on a card head opens it — unless the linking flow wanted that tap, in which case the
card's own handler has already stopped this event from getting here. */
function wire(){
/* the collapsed foot: one tap opens the reference block, the legend and the note fields */
const foot=document.getElementById('foot');
if(foot&&!foot.dataset.lensWired){
foot.dataset.lensWired='1';
foot.addEventListener('click',ev=>{
if(!document.body.classList.contains('lens'))return;
if(ev.target.closest('input,textarea,button,a,select,details'))return;
document.body.classList.toggle('footopen');
});
}
const sp=document.getElementById('spine');
if(!sp||sp.dataset.lensWired)return;
sp.dataset.lensWired='1';
sp.addEventListener('click',ev=>{
if(!document.body.classList.contains('lens'))return;
if(ev.target.closest('.chip,.addw,.cadd,.ckind,.cdel,.slot,.lensdet'))return;
const card=ev.target.closest('.card');if(!card)return;
const col=card.closest('.col');if(!col||!col.dataset.ci)return;
open(col);
});
}
/* `booting` is the one call made before build() has ever run: it may seed the track set but
must not ask for a rebuild, because the boot sequence is about to do one anyway. */
function apply(booting){
const on=MQ.matches;
const was=document.body.classList.contains('lens');
document.body.classList.toggle('lens',on);
if(on&&!was)seed();
if(booting)return;
if(!on&&was)tearDownChrome();
if(on!==was){close();build()}
else renderRail();
}
// wrapped, not passed: addEventListener hands the listener a MediaQueryListEvent, which as a
// first argument would read as `booting` and make every crossing a no-op
addEventListener('keydown',ev=>{
if(ev.key==='Escape'&&sheetOpen){ev.stopPropagation();closeSheet()}
},true);
const onChange=()=>apply(false);
if(MQ.addEventListener)MQ.addEventListener('change',onChange);
else if(MQ.addListener)MQ.addListener(onChange);
return {apply,renderRail,wire,close,verseHeight,morphWords,openSheet,closeSheet,syncActs};
})();
/* Keep the rail honest: it reports the track set and the height of the verse on the board, and
both change on every rebuild. syncShowSeg already runs at the end of every build(). */
(function(){
const orig=syncShowSeg;
syncShowSeg=function(){orig.apply(this,arguments);LENS.wire();LENS.renderRail();};
// selection changes without a rebuild, and the bar's ⚑ Flag acts on the selection
const sel=renderSel;
renderSel=function(){sel.apply(this,arguments);LENS.syncActs();};
})();
/* ---------- boot ---------- */
LENS.apply(true); // sets body.lens and may seed the track set; build() follows
build();
setView('editor');
/* M12: a standalone file has no signed-in identity, so ask for a name ONCE — the work is still
recorded either way (as "unsigned"), and naming yourself later adopts this session's entries. */
(function(){
if(WHO.name)return;
let asked=false;try{asked=sessionStorage.getItem('ulb-editor:asked')==='1'}catch(e){}
if(asked)return;
try{sessionStorage.setItem('ulb-editor:asked','1')}catch(e){}
setTimeout(openIdentity,400);
})();