typeof globalThis[LIB_GLOBAL[name]]!=="undefined";
/* A library is woken only once the whole page has arrived: until then the stored text at the end of the file is only part of
itself, and a script made from part of it is refused by the page's own policy (it matches no hash). A booklet opened by a
link can be drawn before that (LIB_WAITED: the page draws again when it has arrived, see the end of this script). Once the
page has arrived a wake that failed (a host's policy, say) is a real failure: it is remembered and not tried per formula.
A part that is not in the file is another case: it can be missing (a host that did not serve parts/, a file saved on its own)
or the network can drop, and neither is a verdict on the part. That failure is not remembered for good: the diagram shows its source
with a notice, no further request is made while this booklet stays open (LIB_MISS), and the next open of a booklet asks again. */
let LIB_WAITED=false;
const LIB_PART={},LIB_MISS={};
/* the address of a part: parts/.js beside this page, under the page's own address (an http, https or file page only) */
function partAddress(name){
try{if(!Object.prototype.hasOwnProperty.call(LIB_GLOBAL,name)) return "";
const u=new URL("parts/"+name+".js",location.href);
return /^(https?|file):$/.test(u.protocol)?u.href:"";}catch(e){return "";}}
/* a part that is not in the file: a promise of whether it is awake, one load at a time. A part that could not be had is asked for
again on the NEXT OPEN of a booklet, not on every redraw of this one: LIB_MISS notes the open (bookletGen) it failed in. */
function loadPart(name){
if(libAwake(name)) return Promise.resolve(LIB_ON[name]=true);
if(LIB_PART[name]) return LIB_PART[name];
if(LIB_MISS[name]===bookletGen) return Promise.resolve(false);
const url=partAddress(name);
if(!url) return Promise.resolve(false);
return LIB_PART[name]=new Promise(done=>{
try{const s=document.createElement("script");
s.onload=()=>{s.remove();done(libAwake(name));};
s.onerror=()=>{s.remove();done(false);};
/* over http or https the browser checks the part against its pinned sha256 (a page from disk cannot: see above); a part with
no digest is not loaded over the network at all */
if(/^https?:/.test(url)){if(!LIB_SRI[name]) throw new Error("no digest for "+name);s.integrity=LIB_SRI[name];}
s.src=url;document.head.append(s);}catch(e){done(false);}
}).then(ok=>{delete LIB_PART[name];if(ok) LIB_ON[name]=true;else LIB_MISS[name]=bookletGen;return ok;});}
/* true when the library is awake now. When the file holds it, it is woken here. When it is a part beside the page it is not awake
yet: its load is started and this answers false (whenLib says when it arrives). */
function useLib(name){
if(name in LIB_ON) return LIB_ON[name];
if(document.readyState==="loading"){LIB_WAITED=true;return false;}
const box=document.getElementById("lib-"+name);
if(!box){loadPart(name);return false;}
let ok=false;
try{const s=document.createElement("script");
s.text=String(box.textContent).replace(/<\\\/script/g,"el(display?"div":"span",{class:"math raw"},tex);
try{
if(!useLib("temml")||typeof temml==="undefined") return raw();
const box=el(display?"div":"span",{class:"math"});
/* throwOnError:true, so a formula Temml cannot read lands in the catch below and shows as
its TeX; with false Temml paints the bad source red inside the MathML instead */
temml.render(tex,box,{displayMode:!!display,throwOnError:true,trust:false});
/* a formula drawn as flat words (inside a button, a card, a menu) names itself by its TeX: Chromium gives a control holding only
MathML no name otherwise (alttext does not do it). A formula in prose is left to the reader's own MathML support. */
const m=box.querySelector&&box.querySelector("math");
if(m&&named) m.setAttribute("aria-label",tex);
return m?box:raw();
}catch(e){return raw();}}
/* The words of a booklet line (SPEC.md section 4: a title, help text, an option, an anchor, a matrix item, a menu entry) are inline
Markdown, as the prose around them is. words(s) draws them; with `flat` a link is only its words (inside a button, and in a card,
which holds no link). wordsNode is the same for a place that wants one value: the string itself when there is nothing to draw, else
a span that remembers its plain words (a part written {lit:"…"} is not read as Markdown: the reader's own words). plainText (the parser's) is the words without their marks: a menu's text, a label for a
screen reader, a message. Data values are never read this way. */
const WORD_MARK=/[*_`~=$\[<\\!]|https?:/;
const curScope=()=>{const m=moduleForScreen(screen);return m?scopeOfModule(m):scopeOfAddr(screen);};
const wordsOpts=(flat,named)=>({...PLAIN_OPTS,math:tex=>mathNode(tex,false,named),anchor:(h,k)=>textOf(k),flat,...(flat?{}:linkHooks(curScope()))});
function words(s,flat,named){const t=String(s==null?"":s);if(!WORD_MARK.test(t)) return [t];
return renderInline(t,{opts:wordsOpts(flat,named===undefined?flat:named),refs:{}});}
/* a message that holds a name (Next: …, Up: …) as nodes: the message's words around the name drawn as a booklet line's words */
const withName=(f,name)=>{const [a,z]=f("\u0001").split("\u0001");return [a,...words(name,true),z];};
function wordsNode(...parts){const lit=x=>typeof x==="object",ks=parts.map(x=>lit(x)?[x.lit]:words(x,true));
if(ks.every(k=>k.length===1&&typeof k[0]==="string")) return ks.map(k=>k[0]).join("");
const n=el("span",{},...ks.flat());n.plain=parts.map(x=>lit(x)?x.lit:plainText(x)).join("");n.parts=parts;return n;}
/* a diagram: a mermaid fence is drawn as SVG text (no HTML labels), then the
SVG goes through sanitizeDiagramSvg, never innerHTML. A
diagram that will not parse stays on the page as its source, with a note. */
let MM_SEQ=0,MM_READY=false;
const MM_BOXES=[];
/* mermaid is given the active theme's own tokens, so a diagram is drawn in the
page's colours (its base theme, with our variables); a new theme re-draws */
function mermaidConfig(){const cs=getComputedStyle(document.documentElement),v=n=>cs.getPropertyValue("--"+n).trim();
const dark=cs.colorScheme==="dark";
return {startOnLoad:false,securityLevel:"strict",htmlLabels:false,flowchart:{htmlLabels:false},theme:"base",suppressErrorRendering:true,
themeVariables:{darkMode:dark,background:v("paper"),fontFamily:v("sans"),
primaryColor:v("paper-deep"),primaryTextColor:v("ink"),primaryBorderColor:v("accent"),
secondaryColor:v("accent-soft"),secondaryTextColor:v("ink"),secondaryBorderColor:v("accent"),
tertiaryColor:v("surface"),tertiaryTextColor:v("ink"),tertiaryBorderColor:v("rule"),
lineColor:v("muted"),textColor:v("ink"),mainBkg:v("paper-deep"),nodeBorder:v("accent"),nodeTextColor:v("ink"),
clusterBkg:v("surface"),clusterBorder:v("rule"),titleColor:v("ink"),edgeLabelBackground:v("paper"),
noteBkgColor:v("tone-amber"),noteTextColor:v("ink"),noteBorderColor:v("tone-amber-deep"),
actorBkg:v("paper-deep"),actorBorder:v("accent"),actorTextColor:v("ink"),actorLineColor:v("muted"),
signalColor:v("ink"),signalTextColor:v("ink"),labelBoxBkgColor:v("paper-deep"),labelBoxBorderColor:v("accent"),labelTextColor:v("ink"),
loopTextColor:v("ink"),activationBkgColor:v("accent-soft"),activationBorderColor:v("accent"),sequenceNumberColor:v("on-accent"),
altBackground:v("surface"),errorBkgColor:v("tone-warm"),errorTextColor:v("tone-warm-deep")}};}
/* draw (or re-draw) one diagram into its box */
/* the page's faces are carried inside it as data, and a diagram's text is measured in them: it is drawn after they are ready */
const fontsReady=()=>{try{return Promise.resolve(document.fonts&&document.fonts.ready);}catch(e){return Promise.resolve();}};
function mermaidDraw(M,box,code,fail){
const id="bkmm"+(++MM_SEQ);
return fontsReady().then(()=>M.render(id,code)).then(r=>{
const {svg:drawn}=r||{};const nodes=sanitizeDiagramSvg(drawn);
if(!nodes.length){fail();return;}
box.innerHTML="";box.classList.remove("bad");box.append(...nodes);
}).catch(()=>fail()).then(()=>{
/* mermaid's scratch container, left behind when a diagram fails to parse */
const t=document.getElementById("d"+id);if(t&&t.remove) t.remove();});}
function redrawDiagrams(){
try{const lib=LIB_ON.mermaid&&typeof __esbuild_esm_mermaid_nm!=="undefined"?__esbuild_esm_mermaid_nm.mermaid:null;
const M=lib&&(lib.default||lib);if(!M||!MM_READY) return;
M.initialize(mermaidConfig());
for(let i=MM_BOXES.length-1;i>=0;i--){const x=MM_BOXES[i];
if(!x.box.isConnected){MM_BOXES.splice(i,1);continue;}
if(x.box.classList.contains("bad")) continue;
mermaidDraw(M,x.box,x.code,()=>{});}}catch(e){/* the diagram library may refuse: the old drawings stay */}}
function codeBlock(lang,code){return el("pre",{},el("code",lang?{"data-lang":lang}:{},code));}
/* A DIAGRAM THAT COULD MAKE THE PAGE ASK FOR AN ADDRESS IS NOT HANDED TO THE LIBRARY (SPEC.md section 6).
The renderer fetches nothing a file names, and mermaid, even started securityLevel "strict", will request an
address its source names (an image node, an icon, a picture in a label, a style that carries url()). So the
source is read here first, as text, and a diagram that holds any of the following is shown as its source with
one quiet line, and mermaid never sees it. This is a refusal of a broad class, not a parse of mermaid:
what cannot be ruled out is refused. Returns "" for a diagram that may be drawn, else a short name for the
first thing found (the tests name each one).
directive a %%{ ... }%% directive: it can set a theme's CSS, a font address or a style
frontmatter a line of three or more dashes: mermaid's own front matter, which carries the same settings
shape-block an @{ ... } block with a key outside the plain ones below, with an image, icon, address or
link inside it, or never closed (this is where img: and icon: live)
key the words img, icon, image, src, href, url, link, links, sprite or properties written as a
key (followed by : or =), in any diagram type
line a line that opens with click, callback, link, links or properties
markdown-image ![ in a diagram's text (a label can hold Markdown)
html an HTML tag other than the plain ones (br, b, i, u, s, strong, em, sub, sup, small, code,
with no attributes); <> marks are not tags and are left alone
entity an HTML or mermaid character entity (< or #60;), which mermaid turns back into a tag
address ://, which an address needs
css a function that fetches (url, src, image, image-set, cross-fade, element, paint, expression,
local), an at-rule (@import, @font-face and the rest), or, on a style, classDef or linkStyle
line, anything outside letters, digits, spaces and # % . , : ; ( ) - _ ' "
backslash a backslash that touches no bracket or slash (CSS reads \75 rl( as url()
Not done: parsing mermaid. A diagram that passes is still drawn by a library started securityLevel "strict"
with htmlLabels off, behind the Content-Security-Policy on the hosted page, and test/mermaid-browser.js draws
a corpus of diagrams and a matrix of probes with a request counter, so a gap shows there. */
const MM_SAFE_KEYS=new Set(["shape","label","form","pos","w","h","constraint","type","alias","ticket","assigned","priority","class","labeltype"]);
const MM_PLAIN_TAGS=/<\/?(?:br|b|i|u|s|strong|em|sub|sup|small|code)\s*\/?>/gi;
const MM_CSS_FNS=new Set(["rgb","rgba","hsl","hsla","hwb","lab","lch","oklab","oklch","calc","min","max","clamp"]);
function diagramRisk(code){
const src=String(code==null?"":code),lines=src.split(/\r\n|\r|\n/);
if(/%%\s*\{/.test(src)) return "directive";
if(lines.some(l=>/^\s*-{3,}\s*$/.test(l))) return "frontmatter";
for(let at=src.indexOf("@{");at>=0;at=src.indexOf("@{",at+2)){
const end=src.indexOf("}",at);if(end<0) return "shape-block";
const body=src.slice(at+2,end);
if(/\b(?:img|icon|image|src|url|href|link)/i.test(body)||/:\/\/|\/\//.test(body)) return "shape-block";
for(const k of body.matchAll(/(?:^|[,\s])["']?([A-Za-z_][\w-]*)["']?\s*:/g)) if(!MM_SAFE_KEYS.has(k[1].toLowerCase())) return "shape-block";}
if(/(?:^|[\s{,"'(\[$])(?:img|icon|image|images|src|href|url|link|links|sprite|properties)["']?\s*[:=]/i.test(src)) return "key";
if(lines.some(l=>/(?:^|;)\s*(?:click|callback|link|links|properties)\b/i.test(l))) return "line";
if(src.includes("![")) return "markdown-image";
const bare=src.replace(/<<[^<>\n]*>>/g,"").replace(MM_PLAIN_TAGS,"");
if(/<[A-Za-z!?\/]/.test(bare)) return "html";
if(/?\w*;|#\w+;/.test(lines.map(l=>/^\s*(?:style|classdef|linkstyle)\b/i.test(l)?l.replace(/#(?=[0-9a-f]*[a-f])(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8});/gi,"#;"):l).join("\n"))) return "entity";
if(src.includes("://")) return "address";
if(/(?:^|[^\w-])(?:url|src|image|image-set|cross-fade|element|paint|expression|local)\(/i.test(src)||/(?:^|[\s;{}])@[A-Za-z-]/.test(src)) return "css";
for(const l of lines){if(!/^\s*(?:style|classdef|linkstyle)\b/i.test(l)) continue;
if(!/^[\w\s#%.,:;()\-'"]*$/.test(l)) return "css";
for(const f of l.matchAll(/([A-Za-z_-][\w-]*)\(/g)) if(!MM_CSS_FNS.has(f[1].toLowerCase())) return "css";}
if(/(?{box.innerHTML="";box.classList.add("bad");
box.append(el("p",{class:"mfig-note",role:"note"},strings("ui")[typeof key==="string"?key:"figureErr"]),codeBlock("mermaid",code));};
/* a diagram whose source could cause a request never reaches the library */
if(diagramRisk(code)){box.classList.add("bad");
fail("figureRisk");return box;}
/* the page is still arriving: the library is not woken yet, and the diagram is drawn again when it has */
if(document.readyState==="loading"){LIB_WAITED=true;box.append(el("p",{class:"mfig-note",role:"status"},strings("ui").figureWait));return box;}
/* the library is awake: draw the diagram into the box */
const draw=()=>{try{
const lib=typeof __esbuild_esm_mermaid_nm!=="undefined"?__esbuild_esm_mermaid_nm.mermaid:null;
const M=lib&&(lib.default||lib);
if(!M||typeof M.render!=="function"){fail();return;}
if(!MM_READY){M.initialize(mermaidConfig());MM_READY=true;}
MM_BOXES.push({box,code});
mermaidDraw(M,box,code,fail);
}catch(e){fail();}};
try{
if(useLib("mermaid")){draw();return box;}
/* the library is a part beside this page and is on its way: a quiet line now, the drawing when it arrives, and the source with
a note if it cannot be had (that failure holds for this open of the booklet only: the next open asks again) */
box.append(el("p",{class:"mfig-note",role:"status"},strings("ui").figureWait));
/* a box the page no longer shows (a redraw while the part was on its way replaced it: its isConnected is false) is left as
it is, so a part that arrives after redraws costs one drawing per diagram on the page, not one per box ever made */
const arrived=ok=>{if(box.isConnected===false) return;if(!ok){fail("figurePart");return;}box.innerHTML="";draw();};
whenLib("mermaid").then(arrived,()=>arrived(false));
}catch(e){fail();}
return box;}
/* one figure, by the id on its fence: a mermaid fence is drawn, any other
fence is shown as the code it is */
function figureFor(id,scope){const fs=BOOK&&BOOK.figures,f=fs&&((scope&&fs[addrOf(scope,id)])||fs[id]);
if(!f) return null;
return f.lang==="mermaid"?mermaidNode(f.code):codeBlock(f.lang,f.code);}
/* what a prose block hands mdNodes, to draw formulas, diagrams and embeds */
const figureHooks=scope=>({
math:mathNode,
fence:(lang,code)=>lang==="mermaid"?mermaidNode(code):null,
embed:t=>{const m=/^[^#|]*#\^([A-Za-z0-9-]+)\s*(?:\|.*)?$/.exec(t);return m?figureFor(m[1],scope):null;}});
/* ======================= 8. WIDGET ENGINES =======================
the two engines a widget names (svg-regions, grid-select) and the helpers they share. */
/* A tag field holds several comma-separated words. Everything before the
last comma is confirmed (splitList already gives "trimmed, non-empty,
in order" for that); the text after it is still being typed or dictated
and stays uncommitted until a comma, Enter, or blur turns it into one
more confirmed word. */
const parseTagValue=(raw,stored)=>{const s=String(raw||"");
/* A word already stored that holds a comma ("Warm, tender") is one word when the text still holds it whole, so focusing the
field and leaving it cannot split it. The field is only ever written from the stored words, and `stored` names them. */
const keep=(stored||[]).filter(w=>typeof w==="string"&&w.includes(","));
if(keep.length){const segs=s.split(","),out=[];
for(let i=0;in&&i+k<=segs.length&&segs.slice(i,i+k).join(",").trim()===w){n=k;whole=w;}}
out.push(whole===null?segs[i].trim():whole);i+=n;}
const tail=out.pop();return {confirmed:out.filter(nonEmpty),inProgress:tail||""};}
const i=s.lastIndexOf(",");
return i<0?{confirmed:[],inProgress:s.trim()}
:{confirmed:splitList(s.slice(0,i)),inProgress:s.slice(i+1).trim()};};
/* Vocabulary suggestions for whatever's typed after the last comma. Exact
normalized substring match only: a handful of words per widget needs no
fuzzy scoring. An empty fragment matches everything, so a bare trailing comma
prompts the full remaining vocabulary rather than nothing. */
const matchVocab=(fragment,vocab)=>{const f=norm(fragment||"");
return (vocab||[]).filter(v=>norm(v.label||"").includes(f));};
/* Typing the same word twice is a no-op: the first occurrence's exact
casing and spelling is kept. */
const dedupeLabels=arr=>{const seen=new Set();
return (arr||[]).filter(x=>{const k=norm(x);if(seen.has(k)) return false;seen.add(k);return true;});};
/* Redraw a row of controls without replacing the ones that stay. A press that began on a control a redraw replaced is lost (the
browser sends no click to a node that has left the page), and leaving a typing field redraws the controls beside it. `keyOf`
names each item, `make(item)` builds a control, `update(node,item)` brings a kept one up to date; the controls end up in the
items' order, and a control that was in place stays where it is. */
function syncKids(host,items,keyOf,make,update){
const have=new Map();Array.from(host.children).forEach(n=>{if(n&&n.__key!==undefined) have.set(n.__key,n);});
const want=items.map(it=>{const k=keyOf(it);let n=have.get(k);
if(n) have.delete(k); else{n=make(it);n.__key=k;}
if(update) update(n,it);return n;});
have.forEach(n=>host.removeChild(n));
want.forEach((n,i)=>{if(host.children[i]!==n) host.insertBefore(n,host.children[i]||null);});}
/* Run `f` once a press that is under way is over (a pointer held down, then the click it ends in), else on the next turn. Leaving a
typing field is what starts a press on a control beside it, and what that leaves draws (a chip, a pill) moves the controls under
the pointer: the press would land on what has moved there. A fallback runs it anyway if the pointer's release never arrives. */
let PRESSING=false,PRESS_TARGET=null;const AFTER_PRESS=[],HELD=new Set();
const runAfterPress=()=>{AFTER_PRESS.splice(0).forEach(f=>{try{f();}catch(e){}});};
if(typeof document!=="undefined"&&document.addEventListener){
document.addEventListener("pointerdown",e=>{PRESSING=true;PRESS_TARGET=e&&e.target||null;HELD.forEach(b=>{b.__held=false;});HELD.clear();},true);
const over=()=>{PRESSING=false;setTimeout(runAfterPress,0);};
document.addEventListener("pointerup",over,true);document.addEventListener("pointercancel",over,true);}
/* Leaving a typing field adds the words typed, and the reader may be leaving it to press the very control that word names (an option,
a sense word, an item): the words have then pressed it already, and the press that follows must not undo it. `nextTo(e)` is what
the field is being left for (the focus's new place, or the pointer's press); `holdPress(btn)` has btn's next press only confirm
(heldPress); it ends when the focus leaves btn or any new press begins. */
const nextTo=e=>e?(e.relatedTarget||(PRESSING?PRESS_TARGET:null)):null;
const within=(btn,t)=>!!btn&&!!t&&(btn===t||(typeof btn.contains==="function"&&btn.contains(t)));
function holdPress(btn){btn.__held=true;HELD.add(btn);
if(btn.addEventListener) btn.addEventListener("blur",()=>{btn.__held=false;HELD.delete(btn);},{once:true});}
function heldPress(btn){if(!btn.__held) return false;btn.__held=false;HELD.delete(btn);return true;}
/* hold the one of `nodes` the field is being left for (`dest`), when `ok` says the words typed have just pressed it */
const holdIn=(nodes,dest,ok)=>{if(dest&&nodes) Array.from(nodes).forEach(n=>{if(within(n,dest)&&ok(n)) holdPress(n);});};
function afterPress(f){if(!PRESSING){setTimeout(f,0);return;}AFTER_PRESS.push(f);setTimeout(()=>{PRESSING=false;runAfterPress();},1500);}
/* ======================= tagInput =======================
A comma-separated, suggest-as-you-type field over a small vocabulary,
shared by svg-regions and grid-select: type or dictate several words,
see them as chips, get suggestions from the widget's own vocabulary
while still being free to add a word that isn't in it. The whole
selection is re-derived from the field's text on every keystroke, so
backspacing a word out of the text removes it — no separate remove path
to keep in sync with what's visibly there.
opts: {values():current labels in order, vocab():[{id,label}] to
suggest from, onChange(labels, dest, was): replace the selection with exactly
these labels (as the field is left, `dest` is where the focus goes, nextTo,
and `was` the labels before), placeholder, hint}. onChange must never rebuild anything
this field's own DOM lives inside: replacing the node being typed into
drops the focus and the word in progress. */
let tagInputSeq=0;
function tagInput(opts){
const dlId="taglist"+(++tagInputSeq);
const wrap=el("div",{class:"tagfield"});
if(opts.hint) wrap.append(el("p",{class:"hint"},opts.hint));
const inp=el("input",{type:"text",class:"field tag-in",style:"min-height:0",
placeholder:opts.placeholder||"",list:dlId,"aria-label":opts.placeholder||""});
const dl=el("datalist",{id:dlId});
const chips=el("div",{class:"tagchips"});
/* the chips that stay are not replaced: Tab from the field lands on one, and the redraw at blur must not take the focus with it */
const drawChips=()=>syncKids(chips,opts.values()||[],label=>label,label=>el("span",{class:"tagchip"},label,
el("button",{type:"button",class:"x",onclick:()=>{
opts.onChange((opts.values()||[]).filter(x=>x!==label));refresh();}},"×")));
/* a listed word whose label holds a comma is one word, as a stored one is (parseTagValue) */
const known=()=>[...(opts.values()||[]),...(opts.vocab()||[]).map(v=>String(v.label||"")).filter(l=>l.includes(","))];
/* Datalist only ever matches/replaces the WHOLE input value, which is
wrong for a multi-value field — so every option is written out as the
full replacement string ("everything already confirmed" + this one
candidate), not just the candidate word on its own. */
const drawSuggestions=inProgress=>{dl.innerHTML="";
const confirmed=opts.values()||[];
const prefix=confirmed.length?confirmed.join(", ")+", ":"";
const already=new Set(confirmed.map(norm));
matchVocab(inProgress,(opts.vocab()||[]).filter(v=>!already.has(norm(v.label))))
.slice(0,8).forEach(v=>dl.append(el("option",{value:prefix+v.label})));};
/* Only ever touches inp.value when the field is NOT the one the person
is actively typing into — a button click elsewhere, for instance.
Never resets it out from under an in-progress keystroke. */
const refresh=()=>{if(document.activeElement!==inp) inp.value=(opts.values()||[]).join(", ");
drawChips();drawSuggestions(parseTagValue(inp.value,known()).inProgress);};
inp.addEventListener("input",()=>{
const {confirmed,inProgress}=parseTagValue(inp.value,known());
const next=dedupeLabels(confirmed);
const cur=opts.values()||[];
if(next.join("\0")!==cur.join("\0")) opts.onChange(next);
drawChips();drawSuggestions(inProgress);});
/* A dangling in-progress word (typed but no trailing comma yet) commits
on blur, so nothing typed or dictated is silently dropped. */
const commit=e=>{
const {confirmed,inProgress}=parseTagValue(inp.value,known());
const next=dedupeLabels(inProgress?[...confirmed,inProgress]:confirmed);
const cur=opts.values()||[];
if(next.join("\0")!==cur.join("\0")) opts.onChange(next,nextTo(e),cur);};
inp.addEventListener("blur",e=>{commit(e);afterPress(refresh);});
/* the same, when the page is closed or reloaded (commitOwnFields) */
inp.commitOwn=()=>{commit(null);refresh();};
refresh();
wrap.append(inp,dl,chips);
return {el:wrap,refresh};}
/* A cell's colour is the widget's, like its name: the renderer carries no
stylesheet keyed by particular cell ids. Cells with no colour fall back to a
neutral ramp by position, so an uncoloured widget still reads as a grid
rather than a list. */
const FALLBACK_TINT=[0,1,2,3,4,5].map(i=>"var(--cell-"+i+")");
const colourOf=c=>c&&typeof c.color==="string"&&COLOUR_NAMES.includes(c.color)?c.color:null;
const cellColor=(c,i)=>{const t=colourOf(c);
if(t) return {tint:"var(--tone-"+t+")",deep:"var(--tone-"+t+"-deep)",tone:true};
return {tint:FALLBACK_TINT[i%FALLBACK_TINT.length],deep:"var(--tint-ink)"};};
const cellStyle=(c,i)=>{const k=cellColor(c,i);return `background:${k.tint};--qd:${k.deep}`+(k.tone?`;--qt:${k.tint}`:"");};
/* a sense word's label, or the code itself for a word the reader typed that no sense lists */
const senseLabel=(W,code)=>{for(const g of (W.senses||[])) for(const w of (g.words||[]))
if(w.id===code) return tx(w.label,code);
return code;};
/* ======================= ENGINE: svg-regions =======================
Given a widget carrying one or more figures — each an SVG whose shapes have
a `data-r` region id — let a person point at a region and say what it is
like there. The renderer knows the interaction; the widget knows the body. */
function svgRegions(e,W,keys,opts){opts=opts||{};const key=keys[0];
const u=strings("ui");
const C=wordsOf(W.copy);
const figs=W.figures||[];
const labelOf=id=>{const r=(W.regions||[]).find(x=>x.id===id);return r?tx(r.label,id):id;};
const senseWord=code=>senseLabel(W,code);
const figFor=id=>figs.find(f=>(f.regions||[]).includes(id));
const wrap=el("div",{});
let active=null, side=(figs[0]||{}).id||null;
/* drawing reads the answer; only an answer writes it */
const rows=()=>Array.isArray(e[key])?e[key].filter(isRegionRow):[];
const rowFor=id=>rows().find(r=>r.id===id);
const sides=el("div",{class:"sideswitch",role:"group","aria-label":C.sideLbl||""});
/* `readonly` (SPEC.md section 7): the figure is shown and its regions named, and nothing can be pointed at or answered */
const fig=el("div",{class:"svgfig"});
if(opts.readonly) fig.classList.add("ro");
const chips=el("div",{class:"bodychips"});
const panel=el("div",{class:"sensepanel"});
const summary=el("div",{class:"bodysummary"});
const setActive=id=>{active=id;const f=figFor(id);if(f) side=f.id;drawAll();};
const setSide=s=>{side=s;const f=figs.find(x=>x.id===s);
if(active&&f&&!(f.regions||[]).includes(active)) active=null;drawAll();};
const drawSides=()=>{sides.innerHTML="";if(figs.length<2) return;
figs.forEach(f=>{const n=rows().filter(r=>(f.regions||[]).includes(r.id)).length;
sides.append(el("button",{type:"button",class:"sidebtn","aria-pressed":String(side===f.id),
onclick:()=>setSide(f.id)},tx(f.label,f.id),n?el("span",{class:"sn"},String(n)):null));});};
const drawFig=()=>{const f=figs.find(x=>x.id===side)||figs[0];
fig.innerHTML="";fig.append(...sanitizeSvg(f&&f.svg));
fig.querySelectorAll(".rg").forEach(node=>{
const id=node.getAttribute("data-r");
if(opts.readonly){node.setAttribute("role","img");node.setAttribute("aria-label",labelOf(id));return;}
node.classList.toggle("on",!!rowFor(id)||active===id);
node.setAttribute("tabindex","0");node.setAttribute("role","button");
node.setAttribute("aria-label",labelOf(id));
node.onclick=()=>setActive(id);
node.onkeydown=ev=>{if(ev.key==="Enter"||ev.key===" "){ev.preventDefault();setActive(id);}};});};
const drawChips=()=>{
if(opts.readonly){chips.innerHTML="";const f=figs.find(x=>x.id===side)||figs[0];
/* a widget with no figure names its places from `regions` */
(f?(f.regions||[]):(W.regions||[]).map(r=>r.id)).forEach(id=>chips.append(el("span",{class:"emw"},labelOf(id))));return;}
syncKids(chips,W.chips||[],id=>id,id=>el("button",{type:"button",class:"pill-btn",onclick:()=>setActive(id)},labelOf(id)),
(n,id)=>n.setAttribute("aria-pressed",String(!!rowFor(id)||active===id)));};
/* The label for a sense code, and back again. A typed/dictated word that
matches no known sense stays a freeform code exactly as typed — the
same fallback senseWord() already uses for a code it doesn't
recognise, which is what lets it round-trip through a saved file. */
const labelToSense=label=>{for(const g of (W.senses||[])) for(const w of (g.words||[]))
if(norm(tx(w.label,w.id))===norm(label)) return w.id;
return label;};
const drawPanel=()=>{panel.innerHTML="";
if(!active){panel.append(el("p",{class:"hint",style:"margin:0"},C.none||""));return;}
panel.append(el("h4",{},labelOf(active)),el("p",{class:"hint",style:"margin:0"},C.pick||""));
panel.append(tagInput({
values:()=>((rowFor(active)||{}).s||[]).map(senseWord),
vocab:()=>(W.senses||[]).flatMap(g=>(g.words||[]).map(w=>({id:w.id,label:tx(w.label,w.id)}))),
/* typing here must never rebuild this panel, which would destroy the
field being typed into: only what lives outside it is refreshed */
onChange:(labels,dest,was)=>{const codes=labels.map(labelToSense);
let r=rowFor(active);
/* leaving the field for a sense word's button, having typed that word: it is pressed now, and the press confirms it */
holdIn(dest&&panel.querySelectorAll&&panel.querySelectorAll("button.sense"),dest,b=>{const w=b.getAttribute("data-w");return codes.includes(w)&&!(was||[]).map(labelToSense).includes(w);});
if(codes.length){if(!r){r={id:active,s:[]};e[key]=[...rows(),r];}r.s=codes;}
else if(r) e[key]=rows().filter(x=>x!==r);
markDirty();drawChips();drawSummary();paintSenses();},
placeholder:u.tagPh||"",hint:u.tagHint||""}).el);
(W.senses||[]).forEach(g=>{
const set=el("div",{class:"senseset"},el("div",{class:"lbl2"},tx(g.label,g.id)));
const ws=el("div",{class:"sensewrap"});
(g.words||[]).forEach(w=>{const row=rowFor(active);const on=!!row&&row.s.includes(w.id);
/* what is pressed is read when the button is pressed, not when it was drawn: a word typed into the field has changed it */
const sb=el("button",{type:"button",class:"sense","data-w":w.id,"aria-pressed":String(on),onclick:()=>{
if(heldPress(sb)) return;
let r=rowFor(active);const now=!!r&&r.s.includes(w.id);
if(!r){r={id:active,s:[]};e[key]=[...rows(),r];}
r.s=now?r.s.filter(x=>x!==w.id):[...r.s,w.id];
if(!r.s.length) e[key]=rows().filter(x=>x!==r);
markDirty();drawAll();}},tx(w.label,w.id));
ws.append(sb);});
set.append(ws);panel.append(set);});
const row=rowFor(active);
if(row) panel.append(el("p",{style:"margin:.7rem 0 0"},
el("button",{type:"button",class:"small",onclick:()=>{e[key]=rows().filter(x=>x!==row);markDirty();drawAll();}},C.clear||"")));};
/* the words panel's sense buttons, brought up to date with the answer (typing a sense word's label presses it) */
const paintSenses=()=>{const row=rowFor(active);
panel.querySelectorAll("button.sense").forEach(b=>b.setAttribute("aria-pressed",String(!!row&&row.s.includes(b.getAttribute("data-w")))));};
const drawSummary=()=>syncKids(summary,rows(),r=>r.id,r=>el("div",{class:"bodyrow"},
el("button",{type:"button",class:"x",title:C.clear||"","aria-label":(C.clear||"")+": "+labelOf(r.id),
onclick:()=>{e[key]=rows().filter(x=>x.id!==r.id);markDirty();drawAll();}},"×"),
el("button",{type:"button",class:"r rlink",onclick:()=>setActive(r.id)},labelOf(r.id)),
el("span",{})),
(n,r)=>{const t="— "+r.s.map(senseWord).join(", "),sp=n.lastChild;if(sp&&sp.textContent!==t) sp.textContent=t;});
const drawAll=()=>{drawSides();drawFig();drawChips();if(!opts.readonly){drawPanel();drawSummary();}};
drawAll();
wrap.append(el("div",{class:"bodywrap"},el("div",{},sides,fig,chips),opts.readonly?null:panel));
if(!opts.readonly) wrap.append(summary);
/* `describe`: what each region says about itself, its `note`, under the figure (as a grid-select's cells do) */
if(opts.describe&&(W.regions||[]).some(r=>r.note))
wrap.append(el("div",{class:"qdesc"},...(W.regions||[]).filter(r=>r.note).map(r=>
el("div",{class:"qd qd-plain"},el("strong",{},tx(r.label,r.id)),tx(r.note,"")))));
return wrap;}
/* ======================= ENGINE: grid-select =======================
A two-axis grid of cells, each holding the widget's items as buttons to press
and a field to type a word of the person's own into. A pressed item, or a typed
word kept as "x|cell|word", is one entry in the answer the engine is given,
keyed by field id. The widget supplies the axes, the cells (with their tone),
the items and the vocabulary; read-only, it shows what was chosen and nothing
to press. */
/* a grid-select answer's item as words: a word the reader typed is kept as "x|cell|word" and reads back as the word */
const gridLabel=(W,id)=>{if(String(id).startsWith("x|")) return id.split("|").slice(2).join("|");
const it=(W.items||[]).find(x=>x.id===id);return it?tx(it.label,id):id;};
function gridSelect(e,W,keys,opts){opts=opts||{};const key=keys[0];
const u=strings("ui");
const cells=W.cells||[];const ax=W.axes||{};
/* drawing reads the answer; only a press or a typed word writes it */
const chosen=()=>Array.isArray(e[key])?e[key].filter(isStr):[];
const extra=id=>id.startsWith("x|");
const itemLabel=id=>gridLabel(W,id);
const box=el("div",{});
const wrap=el("div",{class:"quadwrap"});
wrap.append(el("div",{class:"ax",style:"grid-column:2;grid-row:1"},tx(ax.top,"")),
el("div",{class:"ax v",style:"grid-column:1;grid-row:2"},tx(ax.left,"")));
const grid=el("div",{class:"quad",role:"group"});
cells.forEach((c,ci)=>{const qb=el("div",{class:"q"+(colourOf(c)?" toned":""),style:cellStyle(c,ci)},el("div",{class:"qn"},tx(c.label,c.id)));
const btns=el("div",{class:"qb"});
let tag=null;
/* a freeform word is kept in the same answer, as "x|cell|word" */
const idsFor=()=>[...(W.items||[]).filter(i=>i.cell===c.id).map(i=>i.id),
...chosen().filter(id=>id.startsWith("x|"+c.id+"|"))];
const drawBtns=()=>{
if(opts.readonly){btns.innerHTML="";idsFor().forEach(id=>btns.append(el("span",{class:"emw"},itemLabel(id))));return;}
/* the buttons that stay are not replaced: a press that began on one, right after typing in the field, would be lost */
syncKids(btns,idsFor(),id=>id,id=>{
const bt=el("button",{type:"button",class:"emb"},itemLabel(id));bt.__id=id;
bt.addEventListener("click",()=>{if(heldPress(bt)) return;const now=chosen().includes(id);
e[key]=now?chosen().filter(x=>x!==id):[...chosen(),id];
bt.setAttribute("aria-pressed",String(!now));markDirty();
/* keep the tag field's own chips in sync with a button click */
if(tag) tag.refresh();});
return bt;},
(bt,id)=>bt.setAttribute("aria-pressed",String(chosen().includes(id))));};
drawBtns();
/* One instance per cell, always visible. The buttons below it work
identically either way. */
if(!opts.readonly){
const known=(W.items||[]).filter(i=>i.cell===c.id);
tag=tagInput({
values:()=>[...known.filter(k=>chosen().includes(k.id)).map(k=>itemLabel(k.id)),
...chosen().filter(id=>id.startsWith("x|"+c.id+"|")).map(itemLabel)],
vocab:()=>known.map(k=>({id:k.id,label:tx(k.label,k.id)})),
/* typing here must never rebuild this field's own DOM; the button row
below carries no typing of its own, so it redraws at once */
onChange:(labels,dest)=>{
const newIds=[],was=chosen().slice();
labels.forEach(label=>{
const match=known.find(k=>norm(tx(k.label,k.id))===norm(label));
newIds.push(match?match.id:"x|"+c.id+"|"+label);});
/* the answer keeps the order the reader picked: what stays keeps its place, a word added goes last */
const mine=id=>id.startsWith("x|"+c.id+"|")||known.some(k=>k.id===id);
const stay=chosen().filter(id=>!mine(id)||newIds.includes(id));
e[key]=[...stay,...newIds.filter(id=>!stay.includes(id))];
/* leaving the field for the button of an item just typed: it is pressed now, and the press confirms it */
holdIn(btns.children,dest,bt=>bt.__id!==undefined&&chosen().includes(bt.__id)&&!was.includes(bt.__id));
markDirty();drawBtns();},
placeholder:u.tagPh||"",hint:u.tagHint||""});
qb.append(tag.el);}
qb.append(btns);grid.append(qb);});
wrap.append(grid,el("div",{class:"ax v r",style:"grid-column:3;grid-row:2"},tx(ax.right,"")),
el("div",{class:"ax",style:"grid-column:2;grid-row:3"},tx(ax.bottom,"")));
if(opts.readonly) wrap.classList.add("gq");
box.append(wrap);
if(opts.describe&&cells.some(c=>c.note))
box.append(el("div",{class:"qdesc"},...cells.map((c,ci)=>
el("div",{class:"qd"+(colourOf(c)?" toned":""),style:cellStyle(c,ci)},
el("strong",{style:`color:${cellColor(c,ci).deep}`},tx(c.label,c.id)),tx(c.note,"")))));
return box;}
/* what each engine kept, as words: the one place that knows how its answer reads back (the kept-entry card and a query use it).
A svg-regions answer is a list of {id}; a grid-select one a list of item ids, or "x|cell|word" for a word the reader typed. */
const regionNames=(W,vals)=>vals.map(r=>{const rg=(W.regions||[]).find(x=>x.id===r.id),name=rg?tx(rg.label,r.id):r.id,
ws=(Array.isArray(r.s)?r.s:[]).map(c=>senseLabel(W,c));return ws.length?name+" — "+ws.join(", "):name;});
const gridNames=(W,vals)=>vals.map(id=>gridLabel(W,id));
const ENGINES={"svg-regions":{draw:svgRegions,summary:regionNames},"grid-select":{draw:gridSelect,summary:gridNames}};
/* ======================= 9. QUESTIONS AND BLOCKS =======================
drawing a block (a question, prose, a row, a widget), and keeping an activity's drafts and entries. */
/* a text box bound to obj[key]: what is typed is kept at once */
function textField(obj,key,rows=2){const ta=el("textarea",{class:"field",rows});ta.value=typeof obj[key]==="string"?obj[key]:"";
const grow=()=>{ta.style.height="auto";ta.style.height=Math.max(ta.scrollHeight+4,rows*24+18)+"px";};
ta.addEventListener("input",()=>{obj[key]=ta.value;markDirty();grow();});
if(nonEmpty(ta.value)) requestAnimationFrame(grow);
return ta;}
/* render a list of blocks, in reading order */
function renderBlocks(list,draft,activityId,dc){const host=el("div",{});
list.forEach(b=>host.append(renderBlock(b,draft,activityId,dc)));
return host;}
function entryCard(e,type,onDelete){const u=strings("ui");
const tag=tx((moduleOf(type)||{}).title,shortId(activityOf(type).id));
/* an entry is read back through the blocks that asked for it */
const {card}=entryBody(e,blocksOf(type),[el("span",{class:"tag"},...words(tag,true)),fmtWhen(e.ts)],{asWritten:true});
if(onDelete){const foot=el("div",{class:"foot"});const btn=el("button",{type:"button",class:"small danger"},u.del);
btn.addEventListener("click",()=>{foot.innerHTML="";foot.append(el("span",{class:"inlineconfirm"},u.delConfirm,
el("button",{type:"button",class:"small danger",onclick:onDelete},u.yes),el("button",{type:"button",class:"small",onclick:()=>{foot.innerHTML="";foot.append(btn);}},u.no)));});
foot.append(btn);card.append(foot);}
return card;}
function chipStrip(id){const u=strings("ui");const list=[...keptFor(id)].sort((a,b)=>b.ts.localeCompare(a.ts));
const wrap=el("div",{});
if(!list.length){wrap.append(el("p",{class:"chips-empty"},u.noEarlier));return wrap;}
const strip=el("div",{class:"chips",role:"group","aria-label":u.earlier});const panel=el("div",{});
const draw=()=>{strip.innerHTML="";panel.innerHTML="";
list.forEach(e=>{const on=viewAt(id).chip===e.ts;const c=el("button",{type:"button",class:"chip","aria-pressed":String(on)},fmtWhen(e.ts));
c.addEventListener("click",()=>{viewAt(id).chip=on?null:e.ts;draw();});strip.append(c);
if(on) panel.append(entryCard(e,id,()=>{removeEntry(id,e.ts);viewAt(id).chip=null;render();}));});};
draw();wrap.append(strip,panel);return wrap;}
function removeEntry(id,ts){const arr=keptFor(id);const i=arr.findIndex(x=>x.ts===ts);if(i>=0){arr.splice(i,1);markDirty();}}
function finalizeRow(onFinalize){const u=strings("ui");return el("div",{class:"finrow"},el("button",{type:"button",class:"finalize",onclick:onFinalize},u.finalize),el("span",{class:"hint"},u.finalizeHint));}
/* every activity keeps its drafts and its kept entries under its own id */
/* whether a draft holds anything: for activity `id`, a draft whose only answers are pairs for picks taken back counts as empty
(SPEC.md section 5, "A question asked for each pick") */
const anyFilled=(e,id)=>{const fu=new Map(id?blocksOf(id).filter(q=>q.each).map(q=>[keysOf(q)[0],q]):[]);
return Object.keys(e||{}).some(k=>{if(!holdsSomething(e[k])) return false;const q=fu.get(k);
return !q||wrongShape(q,e[k],widgetFor(q))||heldPairs(q,e).some(p=>holdsSomething(p.a));});};
const draftFor=id=>{DRAFTS.custom=DRAFTS.custom||nullMap();return hasOwn(DRAFTS.custom,id)?DRAFTS.custom[id]:(DRAFTS.custom[id]=nullMap());};
const keptFor=id=>{STATE.entries=STATE.entries||{};STATE.entries[id]=STATE.entries[id]||[];return STATE.entries[id];};
/* where an activity's answers live: a repeating one keeps a draft, any other the
booklet's answers, keyed by question id as the file's own `answers` record is */
const answersFor=id=>activityOf(id).kind==="entry"?draftFor(id):answersIn(scopeOfAddr(id));
/* every block of an activity, on every page and inside every group */
const allBlocksOf=a=>blocksAll(activityBlocks(a));
const writesKeys=b=>{const k=keysOf(b);return Array.isArray(k)?k.filter(x=>typeof x==="string"):[];};
const foldName=b=>tx(b.label,b.id);
/* a fold holding an answer is drawn open: any question in it, a group's too, holding what the reader gave (a number, a pick) */
const blockFilled=(b,draft)=>blocksAll(b.blocks||[b]).flatMap(keysOf).some(k=>holdsSomething(draft[k]));
/* A question's heading and hint, drawn into `box`: the heading as an h3 (as a
label for the field with id `forId`, when one is given) and the help text as an
aside, each only when there is one. Returns the heading's text. (A text
question puts its help text inside its label, so it is drawn on its own.) */
function questionHead(box,b,forId){
const head=tx(b.label,"");
if(nonEmpty(head)) box.append(forId?el("label",{class:"lbl",for:forId},...words(head,true)):el("h3",{},...words(head)));
const help=tx(b.help,"");if(nonEmpty(help)) box.append(el("p",{class:"aside"},...words(help)));
return plainText(head);}
/* The drawing of each kind of block (below, DRAW_BLOCK), each called as (block, draft, activityId, the draw, the question's
controls: its own hint and solution, which renderBlock draws at its end; null for any other block).
A booklet's prose, drawn from its Markdown: see mdNodes. A citation mark,
[^id], becomes a numbered superscript: see READING MATERIAL. */
function drawMarkdown(b,draft,activityId,dc){const box=el("div",{class:"pblock md"});
box.append(...mdBlockNodes(b,activityId,undefined,dc));
return box;}
/* a row: its cells side by side when there is room, one under another when there is not */
function drawRow(b,draft,activityId,dc){const box=el("div",{class:"pblock row"});
blocksIn(b).forEach(c=>{const cell=el("div",{class:"rowcell"});
blocksIn(c).forEach(x=>{if(x&&typeof x==="object") cell.append(renderBlock(x,draft,activityId,dc));});
box.append(cell);});
return box;}
/* read-only: what was kept elsewhere in this module; owns no answer key */
function drawQuery(b,draft,activityId){const box=el("div",{class:"pblock query"});
const v={...(b.view||{}),fields:b.fields},state=queryView(activityId,b);
const scope=scopeOfAddr(activityId);
if(b.data){box.append(...viewNodes(dataSet(b.data),v,{empty:b.empty||strings("dv").empty,state,scope}));return box;}
/* a summary (SPEC.md section 6) is not drawn yet: the line says so, in place of the view, never the plain entries as if the key were not there */
if(v.summary==="on"){box.append(el("p",{class:"hint"},strings("query").summaryLater));return box;}
const none={kind:"entries",rows:[],keys:new Set(),own:[],first:"date",lab:k=>k,qs:[]};
/* a question asked for each pick, named alone in `from:`, has no one value for tiles, bars or a line (SPEC.md section 6) */
if(b.field&&["tiles","bars","line"].includes(v.as)&&blocksOf(b.from).some(q=>q.id===b.field&&q.each)){
box.append(el("p",{class:"hint"},strings("dv").eachNotYet));return box;}
box.append(...viewNodes(queryReads(b,activityId)?entrySet(b,activityId,v.as||"cards"):none,v,{empty:b.empty||strings("query").empty,state,scope}));
return box;}
/* A widget block names a widget; the widget names an engine. The renderer
knows engines and nothing else, so an activity can carry a body map it
has never seen before as long as the file brings the widget with it. */
function drawWidget(b,draft,activityId){const W=widgetOf(b.widget,b.module);
const box=el("div",{});
if(!W){box.append(el("p",{class:"hint"},strings("ui").missingWidget(b.widget||"?")));return box;}
const eng=ENGINES[W.engine];
if(!eng){box.append(el("p",{class:"hint"},strings("ui").missingEngine(W.engine||"?")));return box;}
const C=wordsOf(W.copy);
const head=tx(b.label,C.h);if(nonEmpty(head)) box.append(el("h3",{},...(typeof b.label==="string"?words(head):[head])));
const lead=tx(b.help,C.p);if(nonEmpty(lead)) box.append(el("p",{class:"aside"},...(typeof b.help==="string"?words(lead):[lead])));
[C.stance,C.invite].filter(nonEmpty).forEach(x=>box.append(el("p",{class:"hint"},x)));
box.append(eng.draw(draft,W,keysOf(b),engOpts(b)));
return box;}
/* the question kinds of SPEC.md section 5: declared on the line, answered as
a position or a string, never invented syntax */
function drawText(b,draft,activityId){const box=el("div",{});const id=newId("f_");
const head=tx(b.label,"");const help=tx(b.help,"");
box.append(el("label",{class:"lbl",for:id},...words(head,true),nonEmpty(help)?el("span",{class:"hint"},...words(help,true)):null));
const ta=textField(draft,keysOf(b)[0],b.rows||2);ta.id=id;box.append(ta);return box;}
function drawLines(b,draft,activityId){const box=el("div",{});const K=keysOf(b)[0];
const head=questionHead(box,b);
const tl=el("div",{});
/* the lines typed so far, else one to type in (the draft holds them from the first keystroke, not the draw) */
const lines=()=>Array.isArray(draft[K])&&draft[K].length?draft[K].map(x=>isStr(x)?x:""):[""];
const draw=()=>{tl.innerHTML="";lines().forEach((v,i)=>{
const inp=el("input",{type:"text","aria-label":head});inp.value=v;
inp.addEventListener("input",()=>{const l=lines();l[i]=inp.value;draft[K]=l;markDirty();
if(i===l.length-1&&nonEmpty(inp.value)){l.push("");draw();tl.querySelectorAll("input")[i].focus();}});
inp.addEventListener("keydown",e=>{if(e.key==="Enter"){e.preventDefault();const nx=tl.querySelectorAll("input")[i+1];if(nx) nx.focus();}});
tl.append(el("div",{class:"oneline"},inp));});};
draw();box.append(tl);return box;}
/* ---- choice and multi. An answer is the position of a listed option (from 1) or, for the reader's own option
(`open`), their own words as a string: a choice holds one of them, a multi a list of them. Nothing of the
reader's is written into the file's option list. `[x]` options (b.answer, positions) are shown as right or wrong
once the reader has answered; nothing about that is stored. ---- */
const sameWords=(a,x)=>String(a).trim().toLowerCase()===String(x).trim().toLowerCase();
/* an answer as a list: positions and the reader's own strings */
const picksOf=v=>Array.isArray(v)?v:(v===null||v===undefined||v===""?[]:[v]);
/* the reader's own options offered again (SPEC.md section 5, "Words the reader wrote before"): the words they added to question
`b` (under key K) in the kept entries of its activity, newest first by the time in their `ts`, at most 12 or the number its `earlier:` sets
(`earlier:0`, none), leaving out any that are listed, or in the answer now, or repeated. A daily activity's entry for today,
which the draft will replace, gives none, and neither does an answer not in its kind's shape. */
const tsTime=t=>{const n=Date.parse(t);return Number.isFinite(n)?n:null;};
/* newest first, over [time, entry] pairs, each `ts` parsed once (tsTime): by time when both parse and differ, else by `ts` as text */
const newestFirst=([p,a],[q,x])=>p!==null&&q!==null&&p!==q?q-p:String(x.ts).localeCompare(String(a.ts));
function ownEarlier(activityId,K,listed,now,b){const out=[],most=b&&b.earlier!==undefined?b.earlier:EARLIER_DEFAULT;
if(!(most>0)) return out;
const daily=activityOf(activityId).upsert==="day";
const kept=[...(((STATE.entries||{})[activityId])||[])].filter(e=>!(daily&&dayKey(e.ts)===todayDate())&&!(b&&wrongShape(b,e[K],null))).map(e=>[tsTime(e.ts),e]).sort(newestFirst).map(p=>p[1]);
for(const e of kept) for(const x of picksOf(e[K])){if(typeof x!=="string") continue;const t=x.trim();
if(!t||listed.some(o=>sameWords(plainText(o),t))||now.some(o=>typeof o==="string"&&sameWords(o,t))||out.some(o=>sameWords(o,t))) continue;
out.push(t);if(out.length>=most) return out;}
return out;}
/* Options short enough (at most four, each at most 24 characters) sit as pills beside their question when there is room,
under it when there is not; any longer list stands under the question as before. */
const shortOptions=l=>Array.isArray(l)&&l.length>0&&l.length<=4&&l.every(o=>typeof o==="string"&&o.length<=24);
/* A question's words keep a readable width, --q-text-min, and short options sit beside them (the class q-beside) only when the words at that
width and the options in one row fit the box's width; else the options are under the words. Measured when the box is shown and
whenever its width changes. The options' own widths are read as they stand, so it holds for any words they say. */
function besideWhenFit(box,pills){if(typeof ResizeObserver!=="function") return;
const root=()=>parseFloat(getComputedStyle(document.documentElement).fontSize)||16;
const len=(v,em)=>{const x=parseFloat(v)||0;return /rem\s*$/.test(v)?x*root():/em\s*$/.test(v)?x*em:x;};
const fit=()=>{const w=box.clientWidth;if(!w) return;
const css=getComputedStyle(box),pc=getComputedStyle(pills),gap=parseFloat(pc.columnGap)||0,em=parseFloat(css.fontSize)||root(),
need=len(css.getPropertyValue("--q-text-min").trim(),em)+len("1rem",em)+gap*Math.max(0,pills.children.length-1)
+Array.from(pills.children).reduce((a,c)=>a+c.offsetWidth,0);
box.classList.toggle("q-beside",w>=need);};
new ResizeObserver(fit).observe(box);
if(document.fonts&&document.fonts.ready) document.fonts.ready.then(fit);}
function drawChoice(b,draft,activityId,dc,qc){const listed=b.options||[],Q=strings("question");
const inline=shortOptions(listed)&&!b.open,box=el("div",{class:inline?"q-inline":null});
questionHead(box,b);
const K=b.keys[0],multi=b.type==="multi";
const right=Array.isArray(b.answer)?b.answer:(b.answer?[b.answer]:[]);
const graded=right.length>0,chosen=()=>picksOf(draft[K]);
let checked=false;
let byReader=false;
/* `later`: the answer is changed now and the options are drawn once any press under way is over (leaving the Add-your-own field) */
const put=(list,later)=>{draft[K]=multi?list:(list.length?list[list.length-1]:null);checked=false;markDirty();byReader=true;
if(later) afterPress(draw);else draw();};
const toggle=(n,later)=>{const c=chosen(),on=c.includes(n);put(on?c.filter(x=>x!==n):multi?[...c,n]:[n],later);};
const addOwn=(text,later,dest)=>{const t=String(text).trim();if(!t) return;
const at=listed.findIndex(o=>sameWords(plainText(o),t));
if(at>=0){if(!chosen().includes(at+1)){toggle(at+1,later);
/* leaving the field for the very option just typed: it is pressed now, and the press confirms it */
holdIn(pills.children,dest,n=>n.__key&&String(n.__key).split(":")[0]==="o"+(at+1));}
return;}
if(chosen().some(x=>typeof x==="string"&&sameWords(x,t))) return;
put(multi?[...chosen(),t]:[t],later);};
const dropOwn=t=>put(chosen().filter(x=>x!==t));
/* what each listed option shows once the reader has answered, if anything */
const markOf=n=>{const c=chosen(),on=c.includes(n),isRight=right.includes(n);
if(!graded) return null;
if(!multi){if(typeof c[0]!=="number") return null;
return on?(isRight?"right":"wrong"):(isRight&&!right.includes(c[0])?"answer":null);}
if(!checked) return null;
return on?(isRight?"right":"wrong"):(isRight?"answer":null);};
const MARK={right:["✓ ",Q.right],wrong:["✗ ",Q.notQuite],answer:["✓ ",Q.theAnswer]};
const pills=el("div",{class:"pills"}),live=el("p",{class:"q-live hint","aria-live":"polite"}),acts=el("div",{class:"q-acts"});
/* an option's reply (SPEC.md section 5) is shown under the options when it is picked: a choice's picked option, a multi's every
picked option in option order. A reply is content: never stored, never counted as an option. */
const replies=el("div",{class:"q-replies","aria-live":"polite"});
const SP=qc&&qc.sol;
if(SP) live.classList.add("q-live-sr");
const draw=()=>{const ae=document.activeElement,at=ae&&ae.parentNode===pills?Array.from(pills.children).indexOf(ae):-1,
onCheck=!!ae&&ae.parentNode===acts;
live.textContent="";replies.innerHTML="";
const now=chosen();
/* a graded multi (it has Check) shows its replies with its marks, once Check is pressed; any other question on the pick */
if(b.replies&&(!(multi&&graded)||checked)){const got=now.filter(x=>typeof x==="number").sort((a,x)=>a-x);
(multi?got:got.slice(0,1)).forEach(n=>{if(b.replies[n-1]) replies.append(el("div",{class:"q-reply"},
...b.replies[n-1].split("\n").map(l=>el("p",{},...words(l)))));});}
/* the options, the reader's own words and the earlier ones offered again, redrawn without replacing a button that stays: the
field adds its words when it is left, and a press that began on a button this replaced would be lost */
syncKids(pills,[
...listed.map((o,idx)=>{const mark=markOf(idx+1);return {k:"o"+(idx+1)+(mark?":"+mark:""),n:idx+1,o,mark};}),
...now.filter(x=>typeof x==="string").map(t=>({k:"w:"+t,own:t})),
...(b.open?ownEarlier(activityId,K,listed,now,b).map(t=>({k:"e:"+t,again:t})):[])],it=>it.k,it=>{
if(it.own!==undefined){const t=it.own;return el("span",{class:"pill-own"},
el("button",{type:"button",class:"pill-btn","aria-pressed":"true",onclick:()=>dropOwn(t)},t),
el("button",{type:"button",class:"pill-x","aria-label":Q.ownRemove(t),onclick:()=>dropOwn(t)},"×"));}
if(it.again!==undefined){const t=it.again;return el("button",{type:"button",class:"pill-btn","aria-pressed":"false",onclick:()=>addOwn(t)},t);}
/* an option whose mark changes is a new button (its key holds the mark); the others stay */
const {n,o,mark}=it;const pb=el("button",{type:"button",class:"pill-btn"+(mark?" q-"+mark:""),onclick:()=>{if(heldPress(pb)) return;toggle(n);}},...words(o,true),
mark?el("span",{class:"q-mk"}," "+MARK[mark][0]+MARK[mark][1]):null);return pb;},
(nd,it)=>{if(it.n!==undefined) nd.setAttribute("aria-pressed",String(chosen().includes(it.n)));});
const wantCheck=graded&&multi&&now.length>0;
syncKids(acts,wantCheck?[1]:[],()=>"check",()=>el("button",{type:"button",class:"small",onclick:()=>{checked=true;byReader=true;draw();}},Q.check));
if(graded){const c=chosen(),shown=multi?checked:typeof c[0]==="number";
if(shown){const missed=right.some(n=>!c.includes(n)),extra=c.some(x=>typeof x==="number"&&!right.includes(x)),
bad=multi?(missed||extra):!right.includes(c[0]);
live.textContent=bad?Q.liveNot(right.map(n=>listed[n-1]).filter(Boolean).map(plainText).join(", ")):Q.liveRight;
if(SP) SP.set(bad?"warn":"good",byReader);}
else if(SP) SP.set(null);}
else if(SP) SP.set(!multi&&chosen().length?"neutral":null,byReader);
byReader=false;
/* the options were rebuilt: the keyboard focus goes back to the one it was on */
if(at>=0&&pills.children[at]&&pills.children[at].focus) pills.children[at].focus({preventScroll:true});
else if(onCheck&&acts.children[0]&&acts.children[0].focus) acts.children[0].focus({preventScroll:true});};
draw();box.append(pills,replies);
if(inline) besideWhenFit(box,pills);
if(b.open){const inp=el("input",{type:"text",class:"field q-own","aria-label":Q.addOwn,placeholder:Q.addOwn,autocomplete:"off"});
/* the words in the field are added as Enter or Add would add them: on Enter, on Add, when the field is left and when the page is
closed or reloaded (commitOwnFields). On a choice they replace the pick. */
const take=(later,dest)=>{const t=inp.value;if(t==="") return;inp.value="";addOwn(t,later,dest);};
const go=()=>{take();inp.focus();};
inp.commitOwn=()=>take();
inp.addEventListener("blur",e=>take(true,nextTo(e)));
inp.addEventListener("keydown",e=>{if(e.key==="Enter"){e.preventDefault();go();}});
box.append(el("div",{class:"q-ownrow"},inp,el("button",{type:"button",class:"small",onclick:go},Q.addBtn)));}
if(graded) box.append(acts,live);
return box;}
function drawScale(b,draft,activityId){const anchors=b.anchors||[];
const inline=shortOptions(anchors.map(a=>a.text)),box=el("div",{class:inline?"q-inline":null});
questionHead(box,b);
const K=b.keys[0];const pills=el("div",{class:"pills"});
const draw=()=>{pills.innerHTML="";anchors.forEach(a=>{const on=draft[K]===a.n;
pills.append(el("button",{type:"button",class:"pill-btn","aria-pressed":String(on),onclick:()=>{
draft[K]=on?null:a.n;markDirty();draw();}},...words(a.text,true)));});};
draw();box.append(pills);
if(inline) besideWhenFit(box,pills);
return box;}
/* A matrix: items answered on one shared scale of anchors. Wide enough for each item's words, at their readable width, to sit beside
a row of the anchors (--mx-text-min, less than a single question's, since the anchors are shared), each item is a row (grid). When it is not, each item is drawn as a single-choice question of its own, the
anchors its options, as other questionnaire systems do on a phone (stack). Either way the answer is the same list, one anchor
number per item, by position, under the same key, so a width that changes back and forth loses nothing (SPEC.md section 5). A probe,
hidden, is as wide as the words' readable width plus the anchors in one row: that is what the room is measured against. */
function drawMatrix(b,draft,activityId){const box=el("div",{});const items=b.items||[],anchors=b.anchors||[];
questionHead(box,b);
const K=b.keys[0];const uid=newId("m_");
const cur=i=>Array.isArray(draft[K])?draft[K][i]:null;
const set=(i,n)=>{const arr=Array.isArray(draft[K])?draft[K].slice():[];
while(arr.length{const g=el("div",{class:"mx"});
items.forEach((it,i)=>{
const row=el("div",{class:"mxrow",role:"radiogroup","aria-label":plainText(it)});
row.append(el("span",{class:"mxitem"},...words(it)));
const opts=el("div",{class:"mxopts"});
anchors.forEach(a=>{
const inp=el("input",{type:"radio",name:uid+"_"+i,value:String(a.n),"data-i":String(i),"data-n":String(a.n),"aria-label":plainText(it)+": "+plainText(a.text)});
if(cur(i)===a.n) inp.checked=true;
inp.addEventListener("change",()=>set(i,a.n));
opts.append(el("label",{class:"mxopt"},inp,el("span",{},...words(a.text,true))));});
row.append(opts);g.append(row);});
return g;};
const stack=()=>{const g=el("div",{class:"mx mx-q"});
items.forEach((it,i)=>{
const q=el("div",{class:"mxq",role:"group","aria-label":plainText(it)}),pills=el("div",{class:"pills"});
const paint=()=>Array.from(pills.children).forEach((x,k)=>x.setAttribute("aria-pressed",String(cur(i)===anchors[k].n)));
anchors.forEach(a=>pills.append(el("button",{type:"button",class:"pill-btn","data-i":String(i),"data-n":String(a.n),"aria-pressed":String(cur(i)===a.n),
onclick:()=>{set(i,cur(i)===a.n?null:a.n);paint();}},...words(a.text,true))));
q.append(el("h3",{},...words(it)),pills);g.append(q);});
/* held, the stem covers what scrolls under it: an item the keyboard moves into there is scrolled clear of it, its words
too, as far as keeping the answer in focus on screen allows. Only the keyboard's focus: a press is never moved. A matrix
with no words has no stem */
g.addEventListener("focusin",e=>{const h=box.firstElementChild,t=e.target,q=t.closest&&t.closest(".mxq");
if(!h||h.tagName!=="H3"||!q||!t.matches(":focus-visible")) return;
const s=h.getBoundingClientRect().bottom,r=t.getBoundingClientRect(),top=q.getBoundingClientRect().top;if(top>=s) return;
const d=Math.max(s+12-r.top,Math.min(s+12-top,innerHeight-12-r.bottom));if(d) window.scrollBy(0,-d);});
return g;};
const probe=el("div",{class:"mxprobe"},...anchors.map(a=>el("span",{},...words(a.text,true))));
const view=el("div",{});let mode=null;
/* the view is rebuilt from the draft, which holds every answer; the keyboard focus goes back to the same item and anchor */
const place=m=>{if(m===mode) return;
const ae=document.activeElement,was=ae&&view.contains&&view.contains(ae)?[ae.getAttribute("data-i"),ae.getAttribute("data-n")]:null;
mode=m;view.innerHTML="";view.append(m==="stack"?stack():grid());box.classList.toggle("mx-asq",m==="stack");
if(was&&was[0]!==null){const x=view.querySelector(`[data-i="${was[0]}"][data-n="${was[1]}"]`);if(x&&x.focus) x.focus({preventScroll:true});}};
place("grid");
box.append(el("div",{class:"mxwrap"},el("div",{class:"mxprobebox","aria-hidden":"true"},probe),view));
if(typeof ResizeObserver==="function"){const wrap=box.lastChild;
new ResizeObserver(()=>{const w=wrap.clientWidth;if(w) place(w>=probe.offsetWidth?"grid":"stack");}).observe(wrap);}
return box;}
function drawNumber(b,draft,activityId){const box=el("div",{});const K=b.keys[0];
const id=newId("f_");
questionHead(box,b,id);
const attrs={type:b.type,id,class:"field"};
if(b.type==="number"){if(b.min!=null) attrs.min=b.min;if(b.max!=null) attrs.max=b.max;if(b.step!=null) attrs.step=b.step;}
const inp=el("input",attrs);if(typeof draft[K]==="number"||typeof draft[K]==="string") inp.value=String(draft[K]);
inp.addEventListener("input",()=>{draft[K]=b.type==="number"?(inp.value===""?null:Number(inp.value)):inp.value;markDirty();});
box.append(inp);return box;}
const DRAW_BLOCK={
markdown:drawMarkdown,
callout:calloutNode,
row:drawRow,
query:drawQuery,
widget:drawWidget,
text:drawText,
lines:drawLines,
choice:drawChoice,
multi:drawChoice,
scale:drawScale,
matrix:drawMatrix,
number:drawNumber,
date:drawNumber};
/* A question whose answer is not in its kind's shape (SPEC.md section 8) is drawn read-only: its heading, a notice saying so and
what the file holds, as written, with nothing to press, so no tap writes it and the answer stays as it is. A question asked
for each pick (`each:`) is drawn as its heading and a line saying this page does not draw it yet; its answer stays as written.
Null for any other block, which its own drawer draws. */
function keptQuestion(b,draft){if(!QUESTION_KINDS.has(b.type)) return null;
const K=keysOf(b)[0],W=widgetFor(b),v=K===undefined?undefined:draft[K],Q=strings("question");
const wrong=wrongShape(b,v,W);if(!wrong&&!b.each) return null;
/* a widget with no title of its own is headed by its widget's words, as drawWidget heads it */
const box=el("div",{class:"pblock q-kept"});questionHead(box,W&&!nonEmpty(tx(b.label,""))?{...b,label:tx(wordsOf(W.copy).h,"")}:b);
if(!wrong){box.append(el("p",{class:"hint"},Q.eachNotYet));return box;}
box.append(...keptNote(b,W,v));
return box;}
/* the notice for an answer kept as written, and what the file holds, as written (keptQuestion; a kept entry's card) */
const keptNote=(b,W,v)=>[el("p",{class:"hint"},strings("question").keptAsWritten(W?W.engine:b.type,!!W,!!b.each)),
el("pre",{class:"q-kept-v"},el("code",{},JSON.stringify(v,null,1)))];
function renderBlock(b,draft,activityId,dc){
const kq=keptQuestion(b,draft);
/* a question's own hint and solution are its controls: handed to its drawer (choice and multi tell the solution what the
reader did) and drawn at the end of its block */
const qc=qControls(b,draft,activityId,dc);
const node=kq?kq:hasOwn(DRAW_BLOCK,b.type)?DRAW_BLOCK[b.type](b,draft,activityId,dc,qc)
:el("p",{class:"hint"},strings("ui").unknownBlock(b.type||"?",b.id||"?"));
if(qc) node.append(qc.node);
if(placeOf(b)!=="folded") return node;
/* a callout the author folded, not a hint or a solution, is the fold switch's (dc.folds, on an activity's screen): open while
the switch is on */
if(b.type==="callout"&&!hintKind(b)&&dc&&dc.folds){const n=foldedCallout(b,draft,activityId,dc,dc.folds.open?{open:true}:undefined);
dc.folds.n++;n.setAttribute("class",n.getAttribute("class")+" au-fold");return n;}
return b.type==="callout"?foldedCallout(b,draft,activityId,dc):
cardNode({mode:"fold",cls:"card-sub",hue:"slate",title:foldName(b),marked:true,open:blockFilled(b,draft),body:[node]});}
/* Is this callout a hint or a solution ("hint", "solution", or ""): its kind says, whether it is a question's own (drawn in the
question) or one drawn where it stands. Either way it never shows a teaser. */
function hintKind(b){const k=String(b&&b.kind!=null?b.kind:"").toLowerCase();return k==="hint"||k==="solution"?k:"";}
/* A callout folded with a minus sign, or a hint or solution folded under its question, as a fold card: its kind's
picture, its title (a hint or a solution is called that, in the reader's language, not by the raw kind), and the
first words of its text as the teaser. A hint or a solution has no teaser: a closed one gives nothing away (`x`: one in its question). */
function foldedCallout(b,draft,activityId,dc,x){
const Rt=strings("reading"),C=strings("cards"),hs=hintKind(b);x=x||{};
/* a kind that means something keeps its colour and picture; any other (a group) is drawn on plain paper */
const look=hs||!hasOwn(CALLOUT_LOOK,String(b.kind).toLowerCase())?null:calloutLook(b.kind),named=tx(b.title,"");
/* an untitled callout is called by its kind's word; a kind the renderer does not know is called Note, as before, only drawn plain */
const word=look||(hs?null:calloutLook(b.kind));
let title=nonEmpty(named)?named:hs?C[hs]:word?Rt.kind[word.label]:foldName(b);
/* a leading emoji in the title is the picture; otherwise the kind's icon */
const lead=cardPicture(title,null),emo=lead.picture&&lead.picture.emoji;
const picture=emo?{emoji:emo}:look?{icon:icon(look.icon)}:hs?{icon:icon(hs==="hint"?"lightbulb":"circle-check")}:null;
const parts=blocksIn(b).filter(x=>x&&typeof x==="object").map(x=>({x,n:renderBlock(x,draft,activityId,dc)}));
let teaser="";
if(!hs){const L=sectionLede(parts.filter(p=>p.x.type==="markdown").flatMap(p=>Array.from(p.n.children||[])));
teaser=L.para?clip(textOf(L.para),220):L.text;}
/* an untitled group is not a note: its fold is named by the first words of what it holds, its prose or else its first question */
if(!hs&&isGroup(b)&&!nonEmpty(named)){const q=blocksAll(blocksIn(b)).find(isQuestion),first=nonEmpty(teaser)?teaser:q?plainText(tx(q.label,"")):"";
if(nonEmpty(first)){title=clip(first,80);teaser="";}}
const body=parts.map(p=>p.n);
if(b.unknown) body.unshift(el("p",{class:"hint rd-unknown"},Rt.unknownKind(b.kind)));
/* a hint or a solution is a compact fold: one quiet line, a small icon, no tile, no teaser */
return cardNode({mode:"fold",cls:"card-sub"+(look?" rd-k-"+look.label:hs?"":" card-plain")+(!hs&&!look&&!picture?" card-notile":"")+(x.tone?" sol-"+x.tone:"")+(x.inQ?" q-in":"")+(x.sol?" sol-in":""),hue:look?look.hue:"slate",
title:emo?lead.title:title,marked:nonEmpty(named)||!(hs||word),picture,teaser,status:x.status,size:hs?"compact":null,open:!!x.open||blockFilled(b,draft),body});}
/* A solution inside its question: the compact fold, shut, giving nothing away. `set(tone)` is how the question tells it what the
reader has done: "good" (right), "warn" (not right, "Look again") or "neutral" (a choice with no right answer, once picked) opens
it in that tone, and only when the tone changes, so a reader who shut it is not argued with; null (the answer no longer carries
a verdict) takes the tone and label off and leaves it open or shut as the reader left it (the view's `shut`, by question). */
function solutionPart(q,draft,activityId,dc){const sol=q.solution,Q=strings("question"),shut=viewAt(activityId).shut;let last=null,open=false;
const make=t=>foldedCallout(sol,draft,activityId,dc,{inQ:true,sol:true,tone:t,open,status:t==="warn"?Q.lookAgain:t==="good"?Q.right:""});
let cur=make(null);
const keep=n=>n.addEventListener("toggle",()=>{if(n!==cur) return;open=n.open;if(open) shut.delete(q.id);else if(last) shut.add(q.id);});keep(cur);
return {get node(){return cur;},
set(t,byReader){if(t===last||(!t&&!last)) return;last=t;
/* a verdict the reader's own pick or Check produced opens it; a redraw never reopens one the reader shut */
if(t){if(byReader||!shut.has(q.id)){open=true;shut.delete(q.id);}}
const next=make(t),old=cur;cur=next;keep(next);if(old.parentNode) old.replaceWith(next);}};}
/* A question's own hint and solution (the parser put them on it), as small controls in its own block, a hint first (as the author
writes them); null for a block with neither. */
function qControls(q,draft,activityId,dc){const sol=ownPart(q,"solution")?solutionPart(q,draft,activityId,dc):null,hint=ownPart(q,"hint");
if(!sol&&!hint) return null;
return {node:el("div",{class:"q-ctls"},hint?foldedCallout(hint,draft,activityId,dc,{inQ:true}):null,sol&&sol.node),sol};}
/* An activity marked `upsert:"day"` (the file's `repeat daily`) keeps one entry per
calendar day rather than one per Finalize: finalizing again on the same day
replaces that day's entry instead of adding a second one. */
function finalizeEntry(id){const u=strings("ui");
const draft=draftFor(id);
if(!anyFilled(draft,id)){toast(u.nothingYet);return;}
const kept=keptFor(id);
/* an entry is identified by its ts (SPEC section 8), so two kept in the same millisecond must not share one: the later takes the next free millisecond */
const e=nullMap(clone(draft)),taken=new Set(kept.map(x=>x.ts));let at=Date.now();while(taken.has(new Date(at).toISOString())) at++;
e.ts=new Date(at).toISOString();
/* lines typed and left blank are not kept, whichever block owns them; an answer not in its kind's shape is kept as written */
const qs=blocksOf(id).filter(b=>QUESTION_KINDS.has(b.type)),asWritten=k=>qs.some(b=>keysOf(b)[0]===k&&wrongShape(b,e[k],widgetFor(b)));
for(const k of Object.keys(e)) if(Array.isArray(e[k])&&e[k].every(x=>typeof x==="string")&&!asWritten(k)) e[k]=e[k].filter(nonEmpty);
if(activityOf(id).upsert==="day"){
const idx=kept.findIndex(x=>dayKey(x.ts)===dayKey(e.ts));
if(idx>=0) kept[idx]=e; else kept.push(e);
} else kept.push(e);
DRAFTS.custom[id]=nullMap();
unsavedEntries++;dirty=true;viewAt(id).chip=e.ts;hookChanged("typed");render();saveLocal();toast(u.kept);}
/* a daily activity's empty draft shows today's entry, to be finished again (settle) */
function seedUpsertDraft(id,m,draft){
if(m.upsert!=="day"||anyFilled(draft,id)) return;
const today=keptFor(id).find(x=>dayKey(x.ts)===todayDate());if(!today) return;
/* a draft that counts as empty may still hold pairs for picks taken back: they are the reader's newer work, so whatever the
draft holds wins, a follow-up's pairs over the entry's pick by pick, the entry's pairs for other picks beside them */
const {ts,...rest}=clone(today);
for(const k of Object.keys(draft)){const v=draft[k],was=rest[k],q=blocksOf(id).find(x=>x.each&&keysOf(x)[0]===k);if(!holdsSomething(v)) continue;
if(q&&Array.isArray(v)&&Array.isArray(was)&&!wrongShape(q,v,widgetFor(q))&&!wrongShape(q,was,widgetFor(q))){
const mine=new Set(pairsOf(v).map(x=>pickKey(x.pick)));rest[k]=[...v,...was.filter(x=>!(isPair(x)&&mine.has(pickKey(x.pick))))];}
else rest[k]=v;}
Object.assign(draft,rest);}
/* ======================= 10. QUERY VIEWS =======================
row sets, roles, the views (table, list, tiles, cards, bars, line), the pipeline and its one control. */
const fieldLabel=b=>tx(b.label,b.id);
/* A kept answer to one question, as [label, what to show], or null when there is nothing to show.
One reader per kind of question (the kinds QUESTION_KINDS names), each called with the question
`b`, the kept entry `e` and the key `k` the entry holds its answer under. */
const choiceSummary=(b,e,k)=>{const ns=picksOf(e[k]).filter(x=>typeof x==="string"?x.trim()!=="":x!==null&&x!==undefined);
if(!ns.length) return null;const listed=b.options||[];
/* a position is the listed option's words; a string is the reader's own option, as they wrote it */
return [fieldLabel(b),wordsNode(...ns.flatMap((n,i)=>[...(i?[{lit:", "}]:[]),typeof n==="string"?{lit:n}:(listed[n-1]||{lit:"#"+n})]))];};
const numberSummary=(b,e,k)=>{const v=e[k];if(v==null||v==="") return null;
return [fieldLabel(b),String(v)];};
const SUMMARY={
widget:(b,e,k)=>{const W=widgetOf(b.widget,b.module);if(!W) return null;
const vals=e[k];if(!Array.isArray(vals)||!vals.length) return null;
const eng=hasOwn(ENGINES,W.engine)?ENGINES[W.engine]:null;
if(!eng) return null;const names=eng.summary(W,vals);
/* a place with words is a line of its own ("Feet — Restless, Sore"); plain names read as one list */
return [fieldLabel(b),names.some(n=>String(n).includes(" — "))?el("ul",{class:"plain"},...names.map(n=>el("li",{},n))):names.join(", ")];},
lines:(b,e,k)=>{const items=(e[k]||[]).filter(nonEmpty);if(!items.length) return null;
return [fieldLabel(b),el("ul",{class:"plain"},...items.map(x=>el("li",{},x.trim())))];},
text:(b,e,k)=>{const v=e[k];if(!nonEmpty(v)) return null;
return [fieldLabel(b),v.trim()];},
choice:choiceSummary,
multi:choiceSummary,
scale:(b,e,k)=>{const v=e[k];if(v==null) return null;const a=(b.anchors||[]).find(x=>x.n===v);
return [fieldLabel(b),a?wordsNode(a.text):String(v)];},
matrix:(b,e,k)=>{const v=e[k];if(!Array.isArray(v)||!v.some(x=>typeof x==="number")) return null;
const items=b.items||[];
return [fieldLabel(b),el("ul",{class:"plain"},...items.map((it,i)=>{const a=(b.anchors||[]).find(x=>x.n===v[i]);
return el("li",{},wordsNode(it,{lit:": "},typeof v[i]==="number"?(a?a.text:{lit:String(v[i])}):{lit:"\u2013"}));}))];},
number:numberSummary,
date:numberSummary};
function fieldSummary(b,e){const keys=keysOf(b);if(!keys.length||!holdsSomething(e[keys[0]])) return null;
return hasOwn(SUMMARY,b.type)?SUMMARY[b.type](b,e,keys[0]):null;}
const widgetFor=b=>b&&b.type==="widget"?widgetOf(b.widget,b.module):null;
/* the source a question asked for each pick is asked of: a question or widget in its own activity (resolveEach made sure).
Found once per question: a design change parses the whole text again, so its blocks are new and none is found stale */
const EACH_SRC=new WeakMap();
function eachSource(q){if(!q||!q.each) return null;if(EACH_SRC.has(q)) return EACH_SRC.get(q);
const l=bookActivities().map(allBlocksOf).find(x=>x.includes(q)),s=l?l.find(b=>QUESTION_KINDS.has(b.type)&&b.id===q.each)||null:null;
EACH_SRC.set(q,s);return s;}
/* a follow-up's pairs whose pick the source holds in the same record `e`, in the source's order, with each pick: [{pick, a}] */
function heldPairs(q,e){const src=eachSource(q),k=keysOf(q)[0];if(!src||k===undefined) return [];
return sourcePicks(src,e[keysOf(src)[0]],widgetFor(src)).map(p=>({p,x:pairFor(e[k],p)})).filter(o=>o.x).map(o=>({...o.x,pick:o.p}));}
/* a pick's words, as a part wordsNode takes: a listed option's words, the reader's own as written, a widget's place or item */
function pickPart(src,p){const W=widgetFor(src);
if(W) return {lit:String(W.engine==="svg-regions"?regionNames(W,[{id:p}])[0]:W.engine==="grid-select"?gridLabel(W,p):p)};
return typeof p==="string"?{lit:p}:((src.options||[])[p-1]||{lit:"#"+p});}
const PICK_SEP="\u0001";
/* What one question shows of a kept answer, as fields [{key, parts, node, v}]: `parts` its label (for wordsNode), `node` what it
shows, `v` the value. One field for most questions. None for an answer not in its kind's shape (SPEC.md section 8: a view leaves
it out of its row). A question asked for each pick has one field per pick its source holds in the same record, in the source's
order, labelled " · " (section 6). */
function entryFields(q,e){const k=keysOf(q)[0];if(k===undefined||!e) return [];
if(wrongShape(q,e[k],widgetFor(q))) return [];
if(!q.each){const r=fieldSummary(q,e);return r?[{key:q.id,parts:[r[0]],node:r[1],v:e[k]}]:[];}
const one={...q};delete one.each;const src=eachSource(q);
return heldPairs(q,e).flatMap(x=>{const r=fieldSummary(one,{[k]:x.a});
return r?[{key:q.id+PICK_SEP+pickKey(x.pick),parts:[r[0],{lit:" \u00b7 "},pickPart(src,x.pick)],node:r[1],v:x.a}]:[];});}
/* A query reads what was kept in the SAME module: the kept entries of an
activity that keeps them, newest first, and nothing across a module boundary
(the activities outside every module fence are one module). `from` and `activityId` are addresses. */
/* whether `from` is an activity of the module activity `activityId` is in, and whether a query reads it (a question it names, `field`,
being one of its blocks): the one test both draws and status lines make */
const sameModule=(from,activityId)=>!!holderOf(from)&&scopeOfAddr(from)===scopeOfAddr(activityId);
const queryReads=(b,activityId)=>sameModule(b.from,activityId)&&(!b.field||blocksOf(b.from).some(x=>x.id===b.field));
function queryEntries(from,activityId,days){
if(!sameModule(from,activityId)) return [];
return inSpan([...keptFor(from)],days).sort((a,b)=>String(b.ts).localeCompare(String(a.ts)));}
/* `limit: N days` (SPEC.md section 6, a day as section 8 defines it): the entries whose own calendar date, in the reader's time zone,
is today or one of the N-1 dates before it. Today is taken now, when the page is drawn. An entry whose `ts` will not parse, or
is later than that moment (even later today), is in no span. No `days` is every entry. */
function inSpan(ents,days){if(!days) return ents;
/* compared as moments, not as date text, so any year holds: from the first moment of the oldest day to now (taken once) */
const now=new Date(),first=new Date(now);first.setDate(first.getDate()-(days-1));first.setHours(0,0,0,0);
const from=first.getTime(),to=now.getTime();
return ents.filter(e=>{const t=e?Date.parse(e.ts):NaN;return Number.isFinite(t)&&t>=from&&t<=to;});}
/* A kept entry as a card: a `.when` line holding `whenKids` (none when null),
then what each question in `qs` recorded, as a `dl`. Returns the card and how
many questions had an answer to show. */
function entryBody(e,qs,whenKids,extra){extra=extra||{};
const dl=el("dl",{});let rows=0;
for(const q of qs){const k=keysOf(q)[0],W=widgetFor(q);
/* a kept entry's own card shows an answer not in its kind's shape as written, with the notice; a view leaves it out */
if(extra.asWritten&&k!==undefined&&wrongShape(q,e[k],W)){rows++;
dl.append(el("dt",{},wordsNode(fieldLabel(q))),el("dd",{},...keptNote(q,W,e[k])));continue;}
for(const f of entryFields(q,e)){rows++;dl.append(el("dt",{},wordsNode(...f.parts)),el("dd",{},f.node));}}
return {card:cardNode({mode:"item",cls:"entry",picture:extra.picture||null,kids:[whenKids?el("div",{class:"when"},...(extra.lead?extra.lead(whenKids):whenKids)):null,dl]}),rows};}
function queryCard(e,from,fields,extra){
/* `fields` names the questions, and their order is the order shown */
const qs=fields?fields.map(id=>blocksOf(from).find(q=>q.id===id)).filter(Boolean):blocksOf(from);
const {card,rows}=entryBody(e,qs,e.ts?[fmtWhen(e.ts)]:null,extra);
return rows?card:null;}
/* ---- query views: table, list and tiles over a data block's rows, or
over kept entries. Nothing is computed here: no filter, no expression, no
aggregate. A view takes the rows in the order it is given, optionally the
first `limit` of them, optionally gathered under a field's value. A value is
plain text, never Markdown or HTML (`el` sets text, never markup). */
const hasOwn=(o,k)=>Object.prototype.hasOwnProperty.call(o||{},k);
const cellText=v=>v==null?"":Array.isArray(v)?v.map(x=>x==null?"":String(x)).join(", "):String(v);
/* the text of a drawn cell, parts joined by "; " (textOf, further down, joins with nothing: it reads headings and paragraphs) */
const nodeText=n=>typeof n==="string"?n:(n&&typeof n.plain==="string"?n.plain:n&&n.nodeType===3?n.textContent:Array.from((n&&(n.childNodes||n.children))||[]).map(nodeText).join("; "));
/* what a view draws from: {kind, rows, keys:Set, own:[key], first, lab(key), sortVal(row,key)}.
`own` is the source's own fields in order, and `first` is the first of them,
whatever the query's `fields:` says. */
function dataSet(d){
const keys=new Set(Object.keys(d.fields||{}));d.rows.forEach(r=>Object.keys(r).forEach(k=>keys.add(k)));
const own=d.fields?Object.keys(d.fields):Object.keys(d.rows[0]||{});
const lab=k=>hasOwn(d.fields,k)?d.fields[k]:k;
return {kind:"data",rows:d.rows,keys,own,first:own[0],lab,sortVal:(r,k)=>r[k]};}
/* kept entries as rows: each question is a field and the date is the field `date`.
A chart has them oldest-first (the pipeline's `limit` then keeps the newest N). A row is
kept when it answers at least one of the questions `fields:` names (or any). */
function entrySet(b,activityId,as){
const numeric=as==="bars"||as==="line";
const all=blocksOf(b.from).filter(q=>keysOf(q).length);
const qs=b.field?all.filter(q=>q.id===b.field):all;
const ents=b.field&&activityOf(b.from).kind!=="entry"?[peekAnswers(scopeOfAddr(b.from))]:queryEntries(b.from,activityId,b.view&&b.view.days);
const named=(b.fields&&!b.field)?qs.filter(q=>b.fields.includes(q.id)):[];
const need=named.length?named:qs;
const raw=new Map(),ent=new Map(),mk=new Map();
/* a question asked for each pick is one field per pick (entryFields): its fields, in the order rows first show them */
const picks=new Map(qs.filter(q=>q.each).map(q=>[q.id,new Map()]));
let rows=ents.map(e=>{const r={},w={};
if(e.ts){r.date=numeric?dayMonth(e.ts):fmtWhen(e.ts);
const t=Date.parse(e.ts);w.date=Number.isFinite(t)?t:String(e.ts);}
let any=false;
for(const q of qs) for(const f of entryFields(q,e)){
const n=(q.type==="number"||q.type==="scale")&&isFinite(parseFloat(f.v))?Number(f.v):null;
r[f.key]=numeric&&n!==null?n:nodeText(f.node);
if(f.node&&f.node.parts) mk.set(r,{...mk.get(r),[f.key]:f.node.parts});
if(n!==null) w[f.key]=n;
if(picks.has(q.id)&&!picks.get(q.id).has(f.key)) picks.get(q.id).set(f.key,f.parts);
if(need.includes(q)) any=true;}
if(!any) return null;
raw.set(r,w);ent.set(r,e);return r;}).filter(Boolean);
if(numeric) rows.reverse();
const expand=k=>picks.has(k)?[...picks.get(k).keys()]:[k],pickParts=k=>{for(const m of picks.values()) if(m.has(k)) return m.get(k);return null;};
const own=["date",...qs.flatMap(q=>expand(q.id))];
const labw=k=>k==="date"||pickParts(k)?undefined:fieldLabel(qs.find(q=>q.id===k));
const lab=k=>k==="date"?strings("dv").date:pickParts(k)?pickParts(k).map(x=>typeof x==="object"?x.lit:plainText(x)).join(""):plainText(labw(k));
return {kind:"entries",from:b.from,rows,keys:new Set(own),own,first:"date",lab,labw,qs,mk,expand,
entryOf:r=>ent.get(r),sortVal:(r,k)=>{const w=raw.get(r);return w&&hasOwn(w,k)?w[k]:r[k];}};}
const unset=v=>v==null||v==="";
const sortCmp=(a,b)=>{
if(unset(a)||unset(b)) return unset(a)&&unset(b)?0:unset(a)?1:-1;
if(typeof a==="number"&&typeof b==="number") return a-b;
return cellText(a).localeCompare(cellText(b),undefined,{numeric:true,sensitivity:"base"});};
/* A role is the part a field plays in a row. Each query key of that name names the
field; the default is the field with the role's own name; the label falls back to
the source's first field when the query gives no `label:` at all. A role this view does not draw, or that no row carries,
is null. Decided once per query: every view reads its roles from here. */
function rolesOf(set,v,as){
const drawn=VIEW_DRAWS[as]||[];
const has=k=>!!k&&set.keys.has(k)&&set.rows.some(r=>cellText(r[k])!=="");
const out={};
for(const n of ROLE_NAMES){
if(!drawn.includes(n)){out[n]=null;continue;}
const want=v[n]||n;
out[n]=has(want)?want:(n==="label"&&!v.label&&has(set.first)?set.first:null);}
return out;}
const TONES=["good","warn","bad"];
const rowTone=(r,roles)=>{const t=roles.tone?cellText(r[roles.tone]).trim():"";return TONES.includes(t)?t:"";};
const pill=(text,tone)=>el("span",{class:"pill"+(tone?" tone-"+tone:"")},text);
const partsOf=x=>(Array.isArray(x)?x:[x]).map(cellText).filter(t=>t!=="");
const rolePart=(r,k)=>k?cellText(r[k]):"";
/* a cell's words: over kept entries a chosen option's marks (a formula) are drawn, as the card draws them */
const cellNode=(set,r,k)=>{const p=set.mk&&set.mk.get(r);return p&&p[k]?wordsNode(...p[k]):cellText(r[k]);};
const toneWords=t=>t?el("span",{class:"dv-sr"},strings("dv").tone[t]):null;
/* A `link` role's value as the words that lead a row: a heading (`#Heading`), a module (`x.booklet`) or a web address leads there, as the same
target does in prose (linkHooks and mkLink are the prose path); anything else is plain text. The value is a target, never Markdown. */
function leadNodes(set,r,roles,kids){
const t=roles.link?cellText(r[roles.link]).trim():"";
if(!t) return kids;
if(isModuleLink(t)) return [moduleLinkNode(t,kids)];
if(t[0]==="#"&&t[1]!=="^"){const H=linkHooks(set.scope||""),text=textOf(kids);
let n=H.link(t,text);if(typeof n==="string") n=H.anchor(t.slice(1).trim(),kids);
return n&&typeof n==="object"?[n]:kids;}
return WEB_ADDRESS.test(t)?[mkLink(kids,t,undefined,{opts:{},refs:{}})]:kids;}
/* an `icon` role's value drawn as the sprite's picture of that name; a value that is not a name in the sprite draws nothing */
const iconOf=(r,roles)=>{const n=roles.icon?cellText(r[roles.icon]).trim():"";return n?icon(n):null;};
/* Every view is called the same way, (set, groups, roles, cols, o): the rows to draw in their groups (one group with no name
unless the query groups them), the roles resolved, the other fields, and `o` with a nested list's tree (`tree`) and the
table's sort headings (`sort`). A view draws its groups' headings itself (groupHead). */
const isGrouped=groups=>groups.some(g=>g.name!==null);
const groupHead=g=>el("div",{class:"dv-group",role:"heading","aria-level":"3"},g.name||"–",el("span",{class:"dv-count"},strings("dv").rows(g.rows.length)));
function viewTable(set,groups,roles,cols,o){const dvText=strings("dv"),ctl=o&&o.sort;
const numeric=k=>set.rows.some(r=>typeof r[k]==="number")&&set.rows.every(r=>r[k]==null||typeof r[k]==="number");
const table=el("table",{class:"dv-table"});
const on=k=>ctl&&ctl.state.sk===k&&ctl.state.dir!==0;
const heads=cols.map(c=>{
const cls=numeric(c.key)?"num":null;
const name=c.raw?words(c.raw,true):[c.label];
if(!ctl) return el("th",{scope:"col",class:cls},...name);
const btn=el("button",{type:"button",class:"dv-sort","data-key":c.key,"aria-label":dvText.sortBy(c.label)},...name);
btn.addEventListener("click",()=>ctl.sortBy(c.key));
return el("th",{scope:"col","aria-sort":on(c.key)?(ctl.state.dir===1?"ascending":"descending"):"none",class:cls},btn);});
table.append(el("thead",{},el("tr",{},...heads)));
/* the first column that is not the badge leads the row: it carries the row's picture and its link */
const lead=(cols.find(c=>!(roles.badge&&c.key===roles.badge))||{}).key;
const cell=(r,c)=>{
if(roles.badge&&c.key===roles.badge){const t=rowTone(r,roles);
return el("td",{},...partsOf(r[c.key]).map(x=>pill(x,t)),toneWords(t));}
if(c.key===lead&&(roles.link||roles.icon)){const pic=iconOf(r,roles);
return el("td",{class:numeric(c.key)?"num":null},pic?el("span",{class:"dv-i"},pic):null,...leadNodes(set,r,roles,[cellText(r[c.key])]));}
return el("td",{class:numeric(c.key)?"num":null},cellNode(set,r,c.key));};
groups.forEach(g=>{const body=el("tbody",{});
if(g.name!==null) body.append(el("tr",{class:"dv-grouprow"},el("th",{scope:"rowgroup",colspan:String(cols.length)},
g.name||"–",el("span",{class:"dv-count"},dvText.rows(g.rows.length)))));
g.rows.forEach(r=>body.append(el("tr",{},...cols.map(c=>cell(r,c)))));
table.append(body);});
return [el("div",{class:"md-table dv-wrap"},table)];}
function viewList(set,groups,roles,cols,o){const tree=o&&o.tree;
/* the parts of one row, shared by a plain row and by the summary of a row that has children */
const parts=r=>{const t=rowTone(r,roles),badges=roles.badge?partsOf(r[roles.badge]):[];
const extras=cols.filter(c=>cellText(r[c.key])!=="");
const val=rolePart(r,roles.value),lab=rolePart(r,roles.label),note=rolePart(r,roles.note);
const pic=iconOf(r,roles);
return [
pic?el("span",{class:"dv-i"},pic):null,
badges.length?el("span",{class:"dv-b"},...badges.map(x=>pill(x,t))):null,
lab!==""?el("span",{class:"dv-t"},...leadNodes(set,r,roles,[lab])):null,
extras.length?el("span",{class:"dv-x"},...extras.map(c=>el("span",{class:"dv-f",title:c.label},
el("span",{class:"dv-sr"},c.label+": "),cellNode(set,r,c.key)))):null,
val!==""?el("span",{class:"dv-val"+(t&&!badges.length?" tone-"+t:"")},val):null,
note!==""?el("span",{class:"dv-n"},note):null,
toneWords(t)];};
const row=r=>el("li",{class:"dv-row"},...parts(r));
/* nested (see nestRows): a row with children is a native disclosure, a row without is a plain row */
const node=r=>{const kids=tree.kids.get(r);if(!kids) return row(r);
return el("li",{class:"dv-node"},el("details",{"data-id":cellText(r.id).trim(),open:tree.open(r)},
el("summary",{class:"dv-row"},...parts(r)),el("ul",{class:"dv-list"},...kids.map(node))));};
if(tree) return [el("ul",{class:"dv-list dv-tree"},...tree.roots.map(node))];
const list=g=>el("ul",{class:"dv-list"},...g.rows.map(row));
/* a grouped list: each group a card, the cards in a row of columns when there is room */
if(isGrouped(groups)) return [el("div",{class:"dv-groups"},...groups.map(g=>el("div",{class:"dv-gcard"},groupHead(g),list(g))))];
return groups.map(list);}
function viewTiles(set,groups,roles){
/* no row carries a value: there is no big figure to draw, so the tiles are a strip of pills */
const pills=!roles.value||!set.rows.some(r=>cellText(r[roles.value])!=="");
const tiles=g=>el("ul",{class:"dv-tiles"+(pills?" dv-pills":"")},...g.rows.map(r=>{
const t=rowTone(r,roles),note=rolePart(r,roles.note);
const pic=iconOf(r,roles);
if(pills) return el("li",{class:"pill"+(t?" tone-"+t:"")},pic?el("span",{class:"dv-i"},pic):null,
el("span",{class:"dv-l"},...leadNodes(set,r,roles,[rolePart(r,roles.label)])),note!==""?el("span",{class:"dv-n"},note):null,toneWords(t));
return cardNode({mode:"item",tag:"li",cls:"dv-tile"+(t?" tone-"+t:"")+(roles.link&&cellText(r[roles.link]).trim()?" dv-lead":""),picture:pic?{icon:pic}:null,kids:[
el("span",{class:"dv-v"},rolePart(r,roles.value)),
el("span",{class:"dv-l"},...leadNodes(set,r,roles,[rolePart(r,roles.label)])),
note!==""?el("span",{class:"dv-n"},note):null,toneWords(t)]});}));
return isGrouped(groups)?groups.flatMap(g=>[groupHead(g),tiles(g)]):groups.map(tiles);}
/* cards: one card per row. A kept entry is a dated card, drawn by queryCard. A data block's row reads the shared roles as the other
views do: the label leads (and is the row's link), the note is the line under it, the badge is pills, the value is the figure (as a
tile's), the tone colours the card, the icon is the picture; every field that plays no role is a line, as on a kept entry's card. */
function viewCards(set,groups,roles,cols){
const cards=g=>g.rows.map(r=>{const pic=iconOf(r,roles),picture=pic?{icon:pic}:null;
if(set.kind==="entries") return queryCard(set.entryOf(r),set.from,cols.map(c=>c.key),{picture,lead:kids=>leadNodes(set,r,roles,kids)});
const lab=rolePart(r,roles.label),note=rolePart(r,roles.note),val=rolePart(r,roles.value),t=rowTone(r,roles),
badges=roles.badge?partsOf(r[roles.badge]):[],lines=cols.filter(c=>cellText(r[c.key])!==""),dl=el("dl",{});
lines.forEach(c=>dl.append(el("dt",{},c.label),el("dd",{},cellText(r[c.key]))));
return cardNode({mode:"item",cls:"entry entry-row"+(t?" tone-"+t:""),picture,teaser:note,teaserCls:"dv-n",
titleNode:lab!==""?el("div",{class:"k"},...leadNodes(set,r,roles,[lab])):null,
kids:[badges.length?el("span",{class:"dv-b"},...badges.map(x=>pill(x,t))):null,val!==""?el("span",{class:"dv-v"},val):null,lines.length?dl:null,toneWords(t)]});}).filter(Boolean);
return isGrouped(groups)?groups.flatMap(g=>[groupHead(g),...cards(g)]):cards(groups[0]);}
/* ---- chart views: `as: bars` and `as: line`. Plain SVG from numbers and
text nodes, coloured only through the theme's tokens (the CSS classes ch-*).
Nothing is computed from the rows beyond the scale a picture needs: no
aggregate, no binning, no stacking. A value that is not a number draws
nothing. The same rows are always offered as a table. */
const isNum=v=>typeof v==="number"&&Number.isFinite(v);
const decimalsOf=s=>{const x=Math.abs(s);return x>=1?0:Math.min(10,Math.ceil(-Math.log10(x)));};
function niceNum(x,round){const e=Math.floor(Math.log10(x)),f=x/Math.pow(10,e);
const n=round?(f<1.5?1:f<3?2:f<7?5:10):(f<=1?1:f<=2?2:f<=5?5:10);return n*Math.pow(10,e);}
/* a "nice" scale over [lo,hi]: its ends are multiples of a round step, with 3 to 5 ticks where that can be had */
function niceScale(lo,hi){
if(!isNum(lo)||!isNum(hi)){lo=0;hi=1;}
if(lo>hi){const t=lo;lo=hi;hi=t;}
if(lo===hi){if(lo===0) hi=1;else if(lo>0) lo=0;else hi=0;}
let best=null;
for(const want of [4,5,3,6,2]){
const step=niceNum(niceNum(hi-lo,false)/(want-1),true),d=decimalsOf(step)+1;
const min=Number((Math.floor(lo/step+1e-9)*step).toFixed(d)),max=Number((Math.ceil(hi/step-1e-9)*step).toFixed(d));
const ticks=[];for(let k=0;k<=Math.round((max-min)/step)&&k<50;k++) ticks.push(Number((min+k*step).toFixed(d)));
best={min,max,step,ticks};
if(ticks.length>=3&&ticks.length<=5) break;}
return best;}
/* which of n labels to write along an axis so none overlaps: the first, then every
`step`th, then the last; each gap is at least labelW pixels over a plot width plotW */
function thinLabels(n,plotW,labelW){
if(n<=0) return [];if(n===1) return [0];
const per=plotW/(n-1),step=Math.max(1,Math.ceil(labelW/Math.max(per,0.0001)));
const out=[];for(let i=0;i<=n-1-step;i+=step) out.push(i);
if(out[out.length-1]!==n-1) out.push(n-1);
return out;}
/* the series of a line chart: one per field, each a list of numbers or null (a gap) */
function lineSeries(rows,keys){return keys.map(k=>rows.map(r=>isNum(r[k])?r[k]:null));}
/* where a line's y scale begins: zero when zero is within reach of the data, else a nice value under the minimum */
function lineDomain(vals){
const v=vals.filter(isNum);if(!v.length) return niceScale(0,1);
const lo=Math.min(...v),hi=Math.max(...v);
return niceScale((lo>=0&&lo<0.6*hi)?0:lo,hi);}
const fmtNum=v=>{try{return new Intl.NumberFormat(locale(),{maximumFractionDigits:2}).format(v);}catch(e){return String(Math.round(v*100)/100);}};
/* a tick label cut to n characters, as it stands (clip, below, also collapses whitespace) */
const chClip=(s,n)=>s.length>n?s.slice(0,n-1)+"…":s;
/* "Show the numbers": the same rows as a table, closed until asked for */
function chartNumbers(set,keys,rows){const C=strings("dv").chart;
const cols=keys.filter(Boolean).map(k=>({key:k,label:set.lab(k)}));
return el("details",{class:"ch-nums"},el("summary",{},C.numbers),...viewTable(set,[{name:null,rows}],{},cols,null));}
function viewBars(set,groups,roles){const dvText=strings("dv"),C=dvText.chart,rows=groups[0].rows,lk=roles.label,vk=roles.value;
const vals=vk?rows.map(r=>r[vk]).filter(isNum):[];
const sc=niceScale(Math.min(0,...(vals.length?vals:[0])),Math.max(0,...(vals.length?vals:[0])));
const span=sc.max-sc.min,zero=(0-sc.min)/span*100;
const list=el("ul",{class:"ch-bars"});
rows.forEach(r=>{const x=vk?r[vk]:undefined,tone=rowTone(r,roles);
const track=sv("svg",{class:"ch-track",viewBox:"0 0 100 10",preserveAspectRatio:"none","aria-hidden":"true",focusable:"false"});
if(sc.min<0) track.append(sv("line",{class:"ch-zero",x1:zero,x2:zero,y1:0,y2:10}));
if(isNum(x)&&x!==0){const a=Math.min(zero,zero+x/span*100),w=Math.max(Math.abs(x)/span*100,.6);
track.append(sv("rect",{class:"ch-bar"+(tone?" tone-"+tone:""),x:a,y:1,width:w,height:8}));}
list.append(el("li",{class:"ch-row"},el("span",{class:"ch-l"},rolePart(r,lk)),track,
el("span",{class:"ch-v"},isNum(x)?fmtNum(x):"–"),toneWords(tone)));});
const lo=vals.length?fmtNum(Math.min(...vals)):"–",hi=vals.length?fmtNum(Math.max(...vals)):"–";
return [el("div",{class:"ch ch-barchart",role:"group","aria-label":C.bars(rows.length,lo,hi)},list,
chartNumbers(set,[lk,vk],rows))];}
const CH_DASH=["","7 4","2 4","9 3 2 3","","4 2"];
function chartMarker(i,x,y,r){const cls="ch-pt ch-s"+(i%6);
switch(i%4){
case 0: return sv("circle",{class:cls,cx:x,cy:y,r});
case 1: return sv("rect",{class:cls,x:x-r,y:y-r,width:2*r,height:2*r});
case 2: return sv("path",{class:cls,d:`M${x} ${y-r-1}L${x+r+1} ${y+r}L${x-r-1} ${y+r}Z`});
default: return sv("path",{class:cls,d:`M${x} ${y-r-1}L${x+r+1} ${y}L${x} ${y+r+1}L${x-r-1} ${y}Z`});}}
function viewLine(set,groups,roles,cols){const dvText=strings("dv"),C=dvText.chart,rows=groups[0].rows,lk=roles.label;
const keys=cols.map(c=>c.key);
const series=lineSeries(rows,keys),all=series.flat().filter(isNum);
const labels=rows.map(r=>rolePart(r,lk)),names=cols.map(c=>c.label);
const lo=all.length?fmtNum(Math.min(...all)):"–",hi=all.length?fmtNum(Math.max(...all)):"–";
const box=el("div",{class:"ch ch-linechart"});
const holder=el("div",{class:"ch-plot"});
const legend=keys.length>1?el("ul",{class:"ch-legend"},...keys.map((k,i)=>el("li",{},
sv("svg",{class:"ch-sw",viewBox:"0 0 28 12",width:"28",height:"12","aria-hidden":"true",focusable:"false"},
sv("line",{class:"ch-line ch-s"+(i%6),x1:2,x2:26,y1:6,y2:6,"stroke-dasharray":CH_DASH[i%6]||null}),chartMarker(i,14,6,3)),
el("span",{},names[i])))):null;
const draw=W=>{W=Math.max(240,Math.round(W||640));
const H=Math.round(Math.min(340,Math.max(200,W*.42)));
const sc=lineDomain(all),yw=Math.max(...sc.ticks.map(t=>fmtNum(t).length))*7+10;
const lw=Math.min(14,Math.max(1,...labels.map(l=>l.length)))*6.6+10;
const L=Math.max(yw,10),R=Math.max(10,Math.ceil(lw/2)),padTop=12,B=30;
const x0=Math.max(L+8,Math.ceil(lw/2)),x1=W-R,pw=Math.max(x1-x0,1),ph=H-padTop-B;
const n=rows.length,X=i=>n===1?(x0+x1)/2:x0+i*pw/(n-1),Y=t=>padTop+ph-(t-sc.min)/(sc.max-sc.min)*ph;
const svg=sv("svg",{class:"ch-svg",viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":C.line(n,names.join(", "),lo,hi),focusable:"false"});
sc.ticks.forEach(t=>{svg.append(sv("line",{class:t===sc.min?"ch-axis":"ch-grid",x1:L,x2:W-R+2,y1:Y(t),y2:Y(t)}),
sv("text",{class:"ch-tick",x:L-6,y:Y(t)+4,"text-anchor":"end"},fmtNum(t)));});
thinLabels(n,pw,lw).forEach(i=>svg.append(sv("text",{class:"ch-tick",x:X(i),y:H-B+17,"text-anchor":"middle"},chClip(labels[i],14))));
series.forEach((s,si)=>{let seg=[];
const flush=()=>{if(seg.length>1) svg.append(sv("polyline",{class:"ch-line ch-s"+(si%6),"stroke-dasharray":CH_DASH[si%6]||null,
points:seg.map(p=>p[0]+","+p[1]).join(" ")}));seg=[];};
s.forEach((val,i)=>{if(val===null){flush();return;}seg.push([X(i),Y(val)]);});flush();
s.forEach((val,i)=>{if(val===null) return;
const m=chartMarker(si,X(i),Y(val),3.5);
m.append(sv("title",{},(keys.length>1?names[si]+" – ":"")+labels[i]+": "+fmtNum(val)));svg.append(m);});});
holder.textContent="";holder.append(svg);
return svg;};
draw(640);
let last=0;
if(typeof ResizeObserver==="function") new ResizeObserver(es=>{
if(!holder.isConnected) return;
const w=Math.round(es[0].contentRect.width);if(w>0&&Math.abs(w-last)>=4){last=w;draw(w);}}).observe(holder);
box.append(...[legend,holder,chartNumbers(set,[lk,...keys],rows)].filter(Boolean));
return [box];}
/* the view functions by the names QUERY_VIEWS lists (the contract test pins that list), each drawing a data set's rows, its
groups and a nested list's tree (see "Every view is called the same way") */
const VIEW_FNS={cards:viewCards,table:viewTable,list:viewList,tiles:viewTiles,bars:viewBars,line:viewLine};
/* ---- the pipeline: the one place that decides what a query draws. The
default view, the empty text, `limit`, the reader's filter and sort, and the
grouping are all here, once; a view is handed rows already filtered and sorted,
and the roles already resolved. The reader's sort and filter are one control,
viewControls, the same on every view; its state (`opts.state`, see queryView) is
kept for as long as this booklet is open and never written anywhere. ---- */
const CONTROLS_MIN=9;
const newViewState=()=>({open:false,q:"",fk:"",fv:"",sk:"",dir:0,opens:new Set()});
/* a query's sort and filter in its activity's view, by its place among the activity's queries (which holds when another
module's text changes the numbers the parser gives blocks) */
function queryView(activityId,b){const q=viewAt(activityId).queries,n=allBlocksOf(activityOf(activityId)).filter(x=>x.type==="query").indexOf(b);
if(!q.has(n)) q.set(n,newViewState());return q.get(n);}
/* A list whose rows carry a parent is nested. The tree is built in one pass over a
Map keyed by the rows' ids (the first row with an id wins). A row whose parent is
empty or not among the rows is a top row; a cycle is cut at the first row met, which
is then drawn at the top. Children keep the file's order. `limit` counts top rows. */
function nestRows(rows,pk,limit){
const idOf=r=>cellText(r.id).trim(),byId=new Map();
rows.forEach(r=>{const i=idOf(r);if(i&&!byId.has(i)) byId.set(i,r);});
const up=new Map();
rows.forEach(r=>{const p=byId.get(cellText(r[pk]).trim());up.set(r,p&&p!==r?p:null);});
const done=new Set();
rows.forEach(r=>{const path=new Set();let x=r;
while(x&&!done.has(x)){if(path.has(x)){up.set(x,null);break;}path.add(x);x=up.get(x);}
path.forEach(y=>done.add(y));});
const kids=new Map(),roots=[];
rows.forEach(r=>{const p=up.get(r);if(p){if(!kids.has(p)) kids.set(p,[]);kids.get(p).push(r);}else roots.push(r);});
const top=limit?roots.slice(0,limit):roots,flat=[];
/* depth first with a stack of its own: a chain thousands deep is a file that opens, not a stack overflow */
const walk=r=>{const st=[r];while(st.length){const x=st.pop();flat.push(x);const k=kids.get(x);if(k) for(let i=k.length-1;i>=0;i--) st.push(k[i]);}};
top.forEach(walk);
return {roots:top,kids,up,rows:flat};}
const uniq=a=>a.filter((x,i)=>x&&a.indexOf(x)===i);
/* the other fields a view shows: a table's columns, a list row's extras, a chart's series, a card's questions */
function colsOf(as,set,v,roles){
/* `fields:` naming a question asked for each pick names its every pick's field (entrySet's expand); a card shows the question */
const named=VIEW_DRAWS[as].includes("fields")?v.fields:undefined,fields=named&&as!=="cards"&&set.expand?named.flatMap(set.expand):named,
mk=ks=>ks.map(k=>({key:k,label:set.lab(k),raw:set.labw&&set.labw(k)}));
/* the fields that play no role: a list's extras and a data block's card lines (never `id`, the group or the parent) */
const rest=()=>{const d=VIEW_DRAWS[as],skip=new Set([...ROLE_NAMES.map(n=>roles[n]),d.includes("group")?v.group:null,d.includes("parent")?v.parent||"parent":null,"id"]);return set.own.filter(k=>!skip.has(k));};
if(as==="table") return mk(fields||set.own);
if(as==="list") return mk(fields||rest());
if(as==="line") return mk(fields||(roles.value?[roles.value]:[]));
if(as==="cards") return mk(set.kind!=="entries"?(fields||rest()):fields?fields.filter(k=>set.qs.some(q=>q.id===k)):set.qs.map(q=>q.id));
return [];}
const sortRows=(rows,set,state)=>{
return rows.map((r,i)=>({r,i,k:set.sortVal(r,state.sk)})).sort((x,y)=>
unset(x.k)||unset(y.k)?((unset(x.k)&&unset(y.k)?0:unset(x.k)?1:-1)||x.i-y.i):(sortCmp(x.k,y.k)*state.dir||x.i-y.i)).map(x=>x.r);};
const passes=(r,state,shown)=>{
if(state.fk&&state.fv!==""&&!partsOf(r[state.fk]).includes(state.fv)) return false;
const q=state.q.trim().toLowerCase();
return !q||shown.some(k=>cellText(r[k]).toLowerCase().includes(q));};
const setHidden=(n,on)=>{if(on) n.setAttribute("hidden","");else n.removeAttribute("hidden");};
/* The one sort-and-filter control. Drawn once above a query's rows when there are
CONTROLS_MIN or more; changing it re-draws only the rows (redraw), never the page. */
function viewControls(state,info,redraw){const K=strings("dv").ctl;
const opts=[];info.shown.forEach(k=>{const n=new Map();
info.rows.forEach(r=>partsOf(r[k]).forEach(x=>n.set(x,(n.get(x)||0)+1)));
if(n.size>=2&&n.size<=8&&[...n.values()].some(c=>c>1)) opts.push({k,vals:[...n.keys()]});});
const picks=[];
const show=el("select",{class:"dv-sel","aria-label":K.show},el("option",{value:""},K.all));
opts.forEach(g=>{const og=el("optgroup",{label:info.lab(g.k)});
g.vals.forEach(x=>{og.append(el("option",{value:String(picks.length)},x));picks.push({k:g.k,v:x});});show.append(og);});
if(state.fk&&!picks.some(p=>p.k===state.fk&&p.v===state.fv)){state.fk="";state.fv="";}
const btn=el("button",{type:"button",class:"dv-ctl-btn","aria-expanded":"false"},K.open);
const input=el("input",{type:"search",class:"dv-q","aria-label":K.search,placeholder:K.search,autocomplete:"off"});
const by=info.canSort?el("select",{class:"dv-sel","aria-label":K.sortBy},el("option",{value:""},K.original),
...info.sortKeys.map(k=>el("option",{value:k},info.lab(k)))):null;
const dir=info.canSort?el("button",{type:"button",class:"dv-dir"},"↑"):null;
const clear=el("button",{type:"button",class:"dv-clear"},K.clear);
const live=el("p",{class:"dv-live","aria-live":"polite"});
const bar=el("div",{class:"dv-bar",role:"group","aria-label":K.open},input,picks.length?show:null,by,dir,clear);
let total=info.rows.length,count=total;
const active=()=>!!(state.q.trim()||(state.fk&&state.fv!==""));
const sync=n=>{if(typeof n==="number") count=n;
btn.setAttribute("aria-expanded",String(!!state.open));setHidden(bar,!state.open);
if(input.value!==state.q) input.value=state.q;
show.value=state.fk?String(picks.findIndex(p=>p.k===state.fk&&p.v===state.fv)):"";
if(by){by.value=state.sk;dir.textContent=state.dir===-1?"↓":"↑";
dir.setAttribute("aria-label",state.dir===-1?K.desc:K.asc);
if(state.sk) dir.removeAttribute("disabled");else dir.setAttribute("disabled","");}
setHidden(clear,!(active()||state.sk));
live.textContent=active()?K.shown(count,total):"";};
const doClear=()=>{state.q="";state.fk="";state.fv="";state.sk="";state.dir=0;redraw();};
btn.addEventListener("click",()=>{state.open=!state.open;sync();if(state.open) input.focus();});
input.addEventListener("input",()=>{state.q=input.value;redraw();});
show.addEventListener("change",()=>{const p=picks[Number(show.value)];
if(show.value===""||!p){state.fk="";state.fv="";}else{state.fk=p.k;state.fv=p.v;}redraw();});
if(by){by.addEventListener("change",()=>{state.sk=by.value;state.dir=by.value?1:0;redraw();});
dir.addEventListener("click",()=>{if(!state.sk) return;state.dir=state.dir===-1?1:-1;redraw();});}
clear.addEventListener("click",()=>{doClear();input.focus();});
bar.addEventListener("keydown",e=>{if(e&&e.key==="Escape"){state.open=false;sync();btn.focus();}});
const node=el("div",{class:"dv-ctl"},el("div",{class:"dv-ctl-top"},btn),bar,live);
return {node,sync,clear:doClear,active};}
function viewNodes(set,v,opts){opts=opts||{};const dvText=strings("dv");
set={...set,scope:opts.scope||""};
const as=v.as||(set.kind==="entries"?"cards":"table"),drawn=VIEW_DRAWS[as];
let rows=set.rows,tree=null;
const pk=v.parent||"parent";
if(as==="list"&&rows.some(r=>cellText(r[pk]).trim()!=="")){tree=nestRows(rows,pk,v.limit);rows=tree.rows;}
else if(v.limit&&drawn.includes("limit")) rows=set.kind==="entries"&&(as==="bars"||as==="line")?rows.slice(-v.limit):rows.slice(0,v.limit);
if(!rows.length) return [el("p",{class:"hint"},opts.empty||dvText.empty)];
const roles=rolesOf({...set,rows},v,as),cols=colsOf(as,set,v,roles);
if(as==="table"&&!(roles.badge&&cols.some(c=>c.key===roles.badge))){roles.badge=null;roles.tone=null;}
const gk=drawn.includes("group")&&v.group&&!tree?v.group:null,canSort=as!=="line";
const shown=uniq([...(as==="cards"&&set.kind==="entries"?["date"]:[]),roles.badge,roles.label,roles.value,roles.note,roles.tone,...cols.map(c=>c.key)]);
const sortKeys=canSort?uniq([...(set.kind==="entries"?["date"]:[]),roles.label,roles.value,...cols.map(c=>c.key)]):[];
const state=opts.state||newViewState();
if(state.sk&&!sortKeys.includes(state.sk)){state.sk="";state.dir=0;}
const body=el("div",{class:"dv-body"+(as==="cards"?" dv-cards":"")});
let ctl=null;
const paint=focusKey=>{
const filtering=!!ctl&&ctl.active();
const kept=filtering?rows.filter(r=>passes(r,state,shown)):rows;
let shape=null;
if(tree){
/* a filter keeps each match with its ancestors, and opens those while it is on */
const keep=filtering?new Set(kept):null,force=new Set();
if(filtering) kept.forEach(r=>{for(let p=tree.up.get(r);p&&!force.has(p);p=tree.up.get(p)){force.add(p);keep.add(p);}});
const order=a=>{const b=keep?a.filter(x=>keep.has(x)):a;return state.sk&&state.dir?sortRows(b,set,state):b;};
const kids=new Map();tree.kids.forEach((a,p)=>{const b=order(a);if(b.length) kids.set(p,b);});
shape={roots:order(tree.roots),kids,open:r=>force.has(r)||state.opens.has(cellText(r.id).trim())};}
let groups=[{name:null,rows:kept}];
if(gk){groups=[];const at=new Map();
kept.forEach(r=>{const n=cellText(r[gk]);if(!at.has(n)){at.set(n,{name:n,rows:[]});groups.push(at.get(n));}at.get(n).rows.push(r);});}
if(canSort&&state.sk&&state.dir) groups.forEach(g=>{g.rows=sortRows(g.rows,set,state);});
body.textContent="";
if(!kept.length){
body.append(el("p",{class:"hint dv-none"},dvText.ctl.nothing+" ",el("button",{type:"button",class:"dv-clear",onclick:()=>ctl.clear()},dvText.ctl.clear)));
}else body.append(...VIEW_FNS[as](set,groups,roles,cols,{tree:shape,sort:tctl}));
if(ctl) ctl.sync(kept.length);
if(focusKey!=null&&body.querySelectorAll){const b=Array.from(body.querySelectorAll("button.dv-sort")).find(x=>x.getAttribute("data-key")===focusKey);if(b&&b.focus) b.focus();}};
/* the table's headings set the same state the control does: ascending, descending, off */
const tctl={state,sortBy:k=>{
if(state.sk!==k){state.sk=k;state.dir=1;}else if(state.dir===1) state.dir=-1;else{state.sk="";state.dir=0;}
paint(k);}};
if(rows.length>=CONTROLS_MIN) ctl=viewControls(state,{rows,shown,sortKeys,canSort,lab:set.lab},()=>paint());
paint();
const panel=as==="table"||as==="bars"||as==="line"||(as==="list"&&!gk);
const box=el("div",{class:"dv-box"+(panel?" dv-panel":"")},ctl?ctl.node:null,body);
/* what the reader opens in a nested list is kept with the rest of the query's state. A toggle does not
bubble, so this listens on the way down; while a filter is on, the ancestors it opened are not remembered. */
if(tree) box.addEventListener("toggle",e=>{const d=e&&e.target;
if(!d||String(d.tagName).toLowerCase()!=="details"||(ctl&&ctl.active())) return;
const id=d.getAttribute("data-id");if(id===null) return;
if(d.open) state.opens.add(id);else state.opens.delete(id);},true);
return [box];}
/* ======================= 11. TABS AND PAGES, LINKS, READING MATERIAL =======================
an activity's tabs and pages, the fold switch, links between activities, citations and callouts. */
/* ======================= AN ACTIVITY'S TABS AND PAGES =======================
An activity is a list of PAGES, each a list of blocks: `pages: [{id, tab,
blocks}, …]`, one page when no thematic break splits it. A TAB is an area of
the activity meant to be very different from the rest (SPEC.md section 3):
`tabs: [{id, title}]`, and each page names the tab it is in. The pages in no
tab, before the first tab line, are the activity's opening, drawn above the
tab bar on every tab. A page without a page line has no name and no menu
(named pages are pageTree's); the pager at the activity's foot (activityFooter) moves through
the pages of the activity, or of the tab on show.
Tabs and pages are presentation. The activity keeps one id, one draft and one
set of kept entries whichever tab or page a block is on. Which tab and which
page show are view state for this visit, per activity (each tab keeps the
page it was left on), and go neither in the file nor in the browser save. */
const blocksIn=p=>p&&Array.isArray(p.blocks)?p.blocks:[];
/* every block an activity holds, on whichever page, as stored */
const activityBlocks=a=>a.pages.flatMap(blocksIn);
/* an activity's (or a module's) tabs, null for none; a bar is drawn for two or more (one tab is no choice to make) */
const tabsIn=x=>x&&Array.isArray(x.tabs)&&x.tabs.length?x.tabs:null;
/* the pages the pager runs through: tab `t`'s, or every page of an activity with no tabs */
const tabPages=(a,t)=>tabsIn(a)?a.pages.filter(p=>p.tab===t):a.pages;
/* an activity's opening, when it has tabs: the blocks of the pages in no tab */
const tabOpening=a=>tabsIn(a)?a.pages.filter(p=>!p.tab).flatMap(blocksIn):[];
/* ---- A TAB'S PAGES (format 0.17, SPEC.md section 3; settlePages makes them) ----
A screen of a tab (a `***` part) may belong to a named page: `np`, the page's id among the tab's, and the activity's `npages` hold
each page's title, icon and parent. The screens of a tab are in reading order, so a page's parts are side by side; the words before
the tab's first page line are its first page, which has no id and so no title, no parent and no children. pageTree(a, tab) is that
tab's pages in reading order: {pages, top, named (does the tab hold a named page), of (screen -> its page)}, each page
{id, title, icon, parent, kids, parts}. A tab with no named page keeps the 0.16 foot (Back, "Page 2 of 3", Next).
Not yet supported here, and nothing is lost by it: the page menu and its search, the "Mentioned on" list, a trail that starts at
the booklet, and an `icon:` drawn at the head of a page (cards show it). */
const PAGE_TREES=new WeakMap();
function pageTree(a,t){let m=PAGE_TREES.get(a);if(!m) PAGE_TREES.set(a,m=new Map());const k=t||"";
if(m.has(k)) return m.get(k);
const pages=[],byId=new Map(),of=new Map(),list=a.npages||[];
tabPages(a,t).forEach(s=>{const hit=s.np&&byId.get(s.np);
if(hit){hit.parts.push(s);of.set(s,hit);return;}
const n=s.np?list.find(x=>x.id===s.np&&(x.tab||"")===(s.tab||"")):null;
const pg={id:n?n.id:null,named:!!n,title:n?n.title:"",icon:n?n.icon:undefined,up:n?n.parent||"":"",parent:null,kids:[],parts:[s]};
pages.push(pg);of.set(s,pg);if(n) byId.set(n.id,pg);});
pages.forEach(pg=>{const p=pg.up&&byId.get(pg.up);if(p&&p!==pg){pg.parent=p;p.kids.push(pg);}});
const T={pages,top:pages.filter(pg=>!pg.parent),named:pages.some(pg=>pg.named),of};
m.set(k,T);return T;}
/* a page's blurb, for its card under its parent: the first paragraph of its first prose */
function pageBlurb(pg){const f=pg.parts.flatMap(blocksIn).find(b=>b.type==="markdown");return f?firstPara(f.text):"";}
/* the pages above a page, from its top page down, each a link, and the page itself plain (the reference renderer's trail; the screen's own head
already names the activity and the tab) */
function pageTrail(id,pg){const K=strings("wiki"),up=[];
for(let p=pg.parent;p;p=p.parent) up.unshift(p);
const crumb=(p,to)=>el("li",{},to?el("button",{type:"button",class:"wk-crumb",onclick:()=>turnPage(id,p.parts[0],false)},...words(p.title,true)):el("span",{"aria-current":"page"},...words(p.title,true)));
return el("nav",{class:"wk-crumbs","aria-label":K.crumbs},el("ol",{},...up.map(p=>crumb(p,true)),crumb(pg,false)));}
/* ---- which tab and page are showing: this visit only (the view's) ---- */
const showPage=(id,pid)=>{viewAt(id).page=pid;};
function currentPage(id){const a=activityOf(id),at=viewAt(id).page,ps=tabsIn(a)?a.pages.filter(p=>p.tab):a.pages;
return ps.find(p=>p.id===at)||ps[0]||a.pages[0];}
/* Show tab `t`, on the page it was left on this visit, else its first; the focus stays on the tab bar. */
function chooseTab(id,t){const a=activityOf(id),v=viewAt(id),cur=currentPage(id),ps=tabPages(a,t);if(!ps.length) return;
if(cur.tab) v.tabs[cur.tab]=cur.id;
showPage(id,(ps.find(p=>p.id===v.tabs[t])||ps[0]).id);render();refocus("#tb-"+t);}
/* Turn to page `p` of the tab on show: the content's top comes into view and takes the focus. One at a time arrives on the
page before at its last step, and on any other as it arrives anywhere (the first step with a question still to answer). */
function turnPage(id,p,last){const steps=viewAt(id).steps;
if(last&&oatPref()==="one") steps[p.id]=Infinity;else delete steps[p.id];
showPage(id,p.id);render();
/* what comes next just under the bar, so the new section is read from its start: the activity's top (its column: title, tab
bar), or, past an opening drawn above it (`ap-open`: the activity's above its tabs, the module's above a one-activity
module), what follows that opening; the focus on the section's body: a screen reader says which it is (pageBody names it) */
const n=document.querySelector("#main .ap-body"),c=n&&n.closest(".col"),o=c&&[...c.querySelectorAll(":scope>.ap-open")].pop(),t=o?o.nextElementSibling:c;
if(t&&t.getBoundingClientRect) window.scrollTo({top:Math.max(0,window.scrollY+t.getBoundingClientRect().top-(parseFloat(document.documentElement.style.getPropertyValue("--ap-top"))||0))});
if(n&&n.focus) n.focus({preventScroll:true});}
/* after a redraw, put the focus back where the reader was working */
function refocus(sel){const n=document.querySelector(sel);if(n&&!n.disabled) n.focus();}
/* A tab bar, the ARIA tabs pattern: a button for each tab, the one on show selected and the only one in the Tab order, all
controlling the one panel drawn (tbpanel, an id no tab's can be); Left and Right (and Home, End) move to a tab and show it. It scrolls sideways
inside itself when the tabs do not fit, so the page never does. `pick(id)` shows a tab. */
function tabBar(tabs,cur,label,pick){const bar=el("div",{class:"tabbar",role:"tablist","aria-label":label});
tabs.forEach((t,i)=>{const on=t.id===cur;
const b=el("button",{type:"button",class:"tab",role:"tab",id:"tb-"+t.id,"aria-selected":String(on),"aria-controls":"tbpanel",tabindex:on?"0":"-1"},...words(tx(t.title,t.id),true));
b.addEventListener("click",()=>{if(!on) pick(t.id);});
b.addEventListener("keydown",e=>{const k=e.key,n=tabs.length,j=k==="ArrowRight"?(i+1)%n:k==="ArrowLeft"?(i+n-1)%n:k==="Home"?0:k==="End"?n-1:-1;
if(j<0) return;e.preventDefault();if(tabs[j].id!==cur) pick(tabs[j].id);});
bar.append(b);});
/* the edges the tabs run past, if any: "start", "end" or both (see the stylesheet's fade); measured once laid out, and as
it scrolls or changes size */
const more=()=>{const m=bar.scrollWidth-bar.clientWidth,l=Math.abs(bar.scrollLeft),v=m>1?((l>1?"start ":"")+(l{const b=bar.querySelector('[aria-selected="true"]');if(!b) return;
const r=b.getBoundingClientRect(),br=bar.getBoundingClientRect();bar.scrollLeft+=r.left-br.left-(br.width-r.width)/2;};
bar.addEventListener("scroll",more,{passive:true});
if(typeof ResizeObserver==="function") new ResizeObserver(()=>{show();more();}).observe(bar);
return bar;}
/* the panel under a tab bar: what the tab on show draws; in the Tab order, so a tab of prose alone can be reached and read */
const tabPanel=(cur,...kids)=>el("div",{class:"tabpanel",role:"tabpanel",id:"tbpanel",tabindex:"0","aria-labelledby":"tb-"+cur},...kids);
/* ---- a module's tabs ----
The tab lines of a module's opening declare its tabs; an activity is in the one its `tab:` names, else in the first. The
module's screen lists the activities of the tab on show under its tab bar (the view's, for this visit), and opening an
activity puts it on that activity's tab, so the overview opens where the reader came from. */
const moduleTabOf=(mod,a)=>{const ts=tabsIn(mod);return !ts?null:ts.some(t=>t.id===a.tab)?a.tab:ts[0].id;};
const moduleTabNow=mod=>{const ts=tabsIn(mod),v=viewAt(moduleScreen(mod.id)).tab;return !ts?null:ts.some(t=>t.id===v)?v:ts[0].id;};
/* what the page on show draws, in order: the tab's opening, then the page */
function pageBlocksShown(id){return [...tabOpening(activityOf(id)),...blocksIn(currentPage(id))];}
/* ---- THE FOLD SWITCH ----
The renderer folds nothing on its own: what is folded is what the author folded with `-`. A screen that shows a callout
the author folded (not a hint or a solution, which stay as their question has them) has one switch, Open all, that opens
or folds exactly those (renderBlock marks them, dc.folds); a screen with none has no switch. The reader's choice is the
browser's, kept per booklet (bookletPref), never the file's. */
const FOLD_KEY="booklet.ui.folds.";
const foldsOpen=()=>bookletPref(FOLD_KEY)==="open";
function foldSwitch(){const on=foldsOpen();
const b=el("button",{type:"button",class:"fold-sw",role:"switch","aria-checked":String(on)},el("span",{class:"fold-knob","aria-hidden":"true"}),strings("fold").openAll);
b.addEventListener("click",()=>{const now=!foldsOpen();setBookletPref(FOLD_KEY,now?"open":"folded");b.setAttribute("aria-checked",String(now));
document.querySelectorAll("#main details.au-fold").forEach(d=>{d.open=now;});});
return b;}
/* ---- drawing ---- */
/* The tab on show (or the activity, with no tabs): the step control and the fold switch, the part on show (all at once, or
one group at a time) and its endnotes. The lists are read afresh on every redraw. */
function pageBody(id,draft,dc){const cur=currentPage(id),own=blocksIn(cur);
/* of two or more pages, the body is named for its page ("Page 2 of 3"), which is said when a page turn puts the focus on it */
const A=activityOf(id),ps=tabPages(A,cur.tab||null),T=pageTree(A,cur.tab||null),pg=T.named?T.of.get(cur):null,pt=pg?pg.parts.indexOf(cur):0;
const of=pg?(pg.parts.length>1?strings("pages").partOf(pt+1,pg.parts.length):null):ps.length>1?strings("pages").of(ps.indexOf(cur)+1,ps.length):null;
const box=el("div",{class:"ap-body",tabindex:"-1",role:of?"group":null,"aria-label":of});
/* a named page: the pages above it, then its title, on every part of it (a page's title heads each part) */
if(pg&&pg.named){if(pg.parent) box.append(pageTrail(id,pg));box.append(el("h3",{class:"pg-title"},...words(pg.title,true)));}
/* reading material (see READING MATERIAL): the endnotes print shows go below
the blocks, and only when there is something for them */
/* (the citation scope began in renderActivity, before the head was drawn) */
/* two or more groups: the reader may take them one at a time (kept per booklet, like the fold switch) */
const units=stepUnits(own),one=units.length>=2&&oatPref()==="one";
/* one at a time, what comes before the first group of the part on show (any blocks, however long) is its opening, drawn
whole above the steps, so it is there at every step and in none of them; all at once, it is drawn as it stands */
const lead=one?own.slice(0,own.findIndex(isGroup)):[];
const content=one?[renderBlocks(lead,draft,id,dc),stepPage(id,draft,units,dc)]:[renderBlocks(own,draft,id,dc)];
const tools=[units.length>=2?stepControl(one):null,dc.folds&&dc.folds.n?foldSwitch():null].filter(Boolean);
box.append(...[tools.length?el("div",{class:"ap-tools"},...tools):null,...content].filter(Boolean));
/* after a page's last part, the pages under it as cards (a page that is only a contents page has nothing else) */
if(pg&&pg.kids.length&&pt===pg.parts.length-1)
box.append(el("ol",{class:"modes wk-cards",role:"list"},...pg.kids.map((k,i)=>activityCard(null,k.title,pageBlurb(k),"",i,k.icon,()=>turnPage(id,k.parts[0],false)))));
const notes=endnotesFor(id,false,dc);if(notes) box.append(notes);
citeReopen(dc);
return box;}
/* ---- ONE AT A TIME ----
The part on show (an activity, a tab, or a `***` section of either) that holds two or more groups (`[!group]`) can be taken
one group at a time: a progress line and dots, and the group, drawn open; the foot's Back and Next (activityFooter) step
through them, and Next on the last step turns the section. The reader's choice is the browser's
(booklet.ui.steps., see bookletPref), never the file's. Every step is drawn and the others are only hidden, so an
answer is never lost on a step, and print shows them all. What comes between two groups goes with the group after it, and
what comes after the last, with the last; a question outside every group is never a step of its own. */
const OAT_KEY="booklet.ui.steps.";
/* A browser's preference for one booklet (one at a time, the fold switch), under its library entry: its link (`from`), the
same each time a link opens it, else its id. One an older release kept under the booklet's id is read until set. */
const prefOwner=()=>(currentEntry&¤tEntry.from)||currentId||"";
const bookletPref=key=>{const v=lsGet(key+prefOwner());return v!==null?v:lsGet(key+(BOOK.id||""));};
const setBookletPref=(key,v)=>{lsSet(key+prefOwner(),v);};
const oatPref=()=>bookletPref(OAT_KEY);
const setOatPref=v=>setBookletPref(OAT_KEY,v);
const isQuestion=q=>QUESTION_KINDS.has(q.type);
const isGroup=b=>!!b&&b.type==="callout"&&String(b.kind).toLowerCase()==="group";
/* the steps of a list of blocks: {blocks, group} for each group, with what comes between it and the group before it, and the
last with what follows it (what comes before the first group is the opening, not a step); none, unless there are two or more */
function stepUnits(list){const units=[];let pre=null;
(Array.isArray(list)?list:[]).forEach(b=>{if(!b||typeof b!=="object") return;
if(isGroup(b)){units.push({blocks:[...(pre||[]),b],group:b});pre=[];}
else if(pre) pre.push(b);});
if(pre&&pre.length) units[units.length-1].blocks.push(...pre);
return units.length>=2?units:[];}
function stepControl(one){const S=strings("steps");
const btn=(val,text)=>{const on=(val==="one")===one;
return el("button",{type:"button","aria-pressed":String(on),onclick:()=>{if(on) return;
setOatPref(val);render();refocus('.oat-ctl button[aria-pressed="true"]');}},text);};
return el("div",{class:"oat-ctl",role:"group","aria-label":S.label},btn("one",S.one),btn("all",S.all));}
function stepPage(id,draft,units,dc){const S=strings("steps"),page=currentPage(id),steps=viewAt(id).steps,key=page.id;
const qs=u=>blocksAll(u.blocks).filter(isQuestion),said=q=>keysOf(q).some(k=>holdsSomething(draft[k]));
/* a step is done when each of its questions holds an answer; one with no question, once a step after it holds one */
const done=(u,i)=>{const q=qs(u);return q.length?q.every(said):units.slice(i+1).some(v=>qs(v).some(said));};
/* arriving, the first step not done is shown (turned back to, the last: turnPage) */
if(!(key in steps)){const f=units.findIndex((u,i)=>!done(u,i));steps[key]=f<0?0:f;}
steps[key]=Math.max(0,Math.min(units.length-1,steps[key]));
const n=units.length,host=el("div",{class:"oat"});
const prog=el("span",{class:"oat-prog",role:"status"}),dots=units.map(()=>el("span",{class:"oat-dot"}));
host.append(el("div",{class:"oat-head"},prog,el("span",{class:"oat-dots","aria-hidden":"true"},...dots)));
/* a step draws its group open, whether or not the author folded it; a group with no title is named by its place ("Step 2 of 3") */
const nodes=units.map((u,i)=>el("div",{class:"oat-unit",tabindex:"-1",...(nonEmpty(tx(u.group.title,""))?{}:{role:"group","aria-label":S.progress(i+1,n)})},...u.blocks.map(b=>renderBlock(b===u.group?{...b,placement:undefined}:b,draft,id,dc))));
host.append(...nodes);
const draw=()=>{const at=steps[key];
nodes.forEach((x,i)=>{if(i===at) x.removeAttribute("hidden");else x.setAttribute("hidden","");});
prog.textContent=S.progress(at+1,n);
dots.forEach((d,i)=>{d.className="oat-dot"+(done(units[i],i)?" on":"")+(i===at?" cur":"");});};
/* Back and Next are the foot's (dc.oat): a step redraws them, and moves the keyboard to the step now shown (its box takes
the focus, so a screen reader reads it) */
const go=d=>{steps[key]=Math.max(0,Math.min(n-1,steps[key]+d));draw();if(dc.foot) dc.foot();
const u=nodes[steps[key]];if(u&&u.focus) u.focus();};
dc.oat={n,at:()=>steps[key],go};
draw();dc.ticks.push(draw);
return host;}
/* ---- what prose is drawn as ---- */
const tagOf=n=>n&&typeof n==="object"?String(n.tagName||n.tag||"").toLowerCase():"";
const headLevel=n=>{const m=/^h([1-6])$/.exec(tagOf(n));return m?+m[1]:0;};
/* What a fold card shows under its title: its first paragraph (which is
then drawn there and nowhere else), else the first list item, else the first
table row, as a line of text. {para, rest, text}: `rest` is the body without
the paragraph; `text` is set only when there was no paragraph. */
function sectionLede(body){
const pi=body.findIndex(n=>tagOf(n)==="p");
if(pi>=0) return {para:body[pi],rest:body.filter((_,i)=>i!==pi),text:""};
let text="";
for(const n of body){
const li=tagOf(n)==="ul"||tagOf(n)==="ol"?n.querySelector("li"):null;
if(li){text=textOf(li);break;}
const tr=n.querySelector&&(tagOf(n)==="table"||/\bmd-table\b/.test((n.getAttribute&&n.getAttribute("class"))||""))
?n.querySelector("tbody tr")||n.querySelector("tr"):null;
if(tr){text=[...tr.children].map(c=>textOf(c).trim()).filter(Boolean).join(" · ");break;}}
return {para:null,rest:body,text:clip(text,220)};}
/* put a table in its own scrolling wrapper, so a wide one scrolls beside the page, not under it */
const tableWrap=n=>tagOf(n)==="table"?el("div",{class:"md-table"},n):n;
const mdBlockNodes=(b,activityId,prefix,dc)=>mdNodes(String(b.text||""),{...figureHooks(scopeOfAddr(activityId)),...linkHooks(scopeOfAddr(activityId)),idPrefix:prefix||"md-",imageScope:scopeOfAddr(activityId),
mark:id=>{if(activityId==="") return "";const c=citeText("[^"+id+"]",dc);return c.length===1&&typeof c[0]==="string"?c[0]:el("span",{},...c);}}).map(tableWrap);
/* ---- LINKS BETWEEN ACTIVITIES ----
`[[#Check yourself]]` and `[Check yourself](#check-yourself)` are ordinary
heading links. A heading is an activity's title, a tab's name, a heading in
its prose or the title of one of its questions. A link opens the activity
holding the heading, on the tab and page holding it. Text and slugs are compared the
way Obsidian and GitHub do: case and spacing never matter. */
const linkNorm=s=>String(s==null?"":s).replace(/\s+/g," ").trim().toLowerCase();
/* a heading as a link target (GitHub and Obsidian style: Unicode letters kept, the rest dropped); slugify, below, is for file names and ids */
const linkSlug=s=>linkNorm(String(s==null?"":s).normalize("NFC").replace(/[️]/g,""))
.replace(/[^\p{L}\p{M}\p{N}\s_-]/gu,"").replace(/\s/g,"-").replace(/^-+|-+$/g,"");
/* the words of a heading, without its emphasis, code ticks or link syntax */
const plainHeading=s=>String(s).replace(/[ \t]+#+[ \t]*$/,"")
.replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g,"$2").replace(/\[\[([^\]]*)\]\]/g,"$1")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g,"$1").replace(/[*_`]+/g,"").trim();
/* the ATX headings of a chunk of Markdown, outside its code fences */
const headingsOf=text=>atxLines(text).map(h=>h.text?plainHeading(h.text):"").filter(Boolean);
/* every heading an activity holds, with the page it is on (its title and its tabs' names marked so) */
function activityHeadings(a){const ps=a.pages,out=[];
const t=plainText(tx(a.title,null));if(t) out.push({text:t,page:ps[0].id,title:true});
(tabsIn(a)||[]).forEach(tb=>{const p=ps.find(x=>x.tab===tb.id),n=plainText(tx(tb.title,null));if(p&&n) out.push({text:n,page:p.id,tab:true});});
ps.forEach(p=>{
/* a named page's title is a heading of its first part (format 0.17) */
{const q=p.np?pageTree(a,p.tab||null).of.get(p):null,n=q&&q.named&&q.parts[0]===p?plainText(tx(q.title,null)):"";if(n) out.push({text:n,page:p.id});}
/* prose gives its headings, a row and its cells are gone into, any other block gives its title (a question's, then its own
hint's and solution's) */
eachBlock(blocksIn(p),b=>{if(b.type==="markdown"){headingsOf(b.text).forEach(h=>out.push({text:h,page:p.id}));return false;}
if(b.type==="row"||b.type==="cell") return true;
const l=plainText(tx(b.label,null));if(l) out.push({text:l,page:p.id});
return QUESTION_KINDS.has(b.type);});});
return out;}
/* The first activity (page) in the booklet holding a heading with this text, or,
with bySlug, whose heading has this GitHub-style slug; a module's tab (one of a module with a screen of its own) comes
before its activities, as its line does. Nothing when none does. */
function findHeading(mods,target,bySlug){
let want;try{want=bySlug?linkSlug(decodeURIComponent(target)):linkNorm(target);}catch(e){want=bySlug?linkSlug(target):linkNorm(target);}
if(!want) return null;
const is=text=>(bySlug?linkSlug(text):linkNorm(text))===want;
for(const m of mods){
for(const t of isMulti(m)&&tabsIn(m)||[]){const n=plainText(tx(t.title,null));if(n&&is(n)) return {mod:m.id,mtab:t.id,heading:n};}
for(const a of m.activities) for(const h of activityHeadings(a))
if(is(h.text)) return {act:a.id,page:h.page,heading:h.text,title:!!h.title,tab:!!h.tab};}
return null;}
function scrollToHeading(text){const want=linkNorm(text);
const n=[...document.querySelectorAll("#main h2,#main h3,#main h4,#main h5,#main h6")].find(h=>linkNorm(h.textContent)===want&&!(h.closest&&h.closest(".mhead-lede,.mhead-more")))||null;
/* a heading inside a folded callout: open the callout first */
for(let p=n;p&&p!==document.body;p=p.parentNode) if(p.classList&&p.classList.contains("card-fold")&&!p.open) p.open=true;
if(n&&n.scrollIntoView) n.scrollIntoView({block:"start"});else window.scrollTo({top:0});}
/* Follow a link to a heading: its activity on the tab and page holding it, scrolled to the heading. A link to an activity's
title shows the activity from its top (go), and one to a tab's name shows the tab with the focus on it; one to a module's
tab, the module's screen on that tab. A heading in the opening of an activity with tabs is above every tab: the tab on
show stays. */
function openHeading(hit){
if(hit.mtab){const v=moduleScreen(hit.mod);viewAt(v).tab=hit.mtab;if(screen!==v) go(v);else render();refocus("#tb-"+hit.mtab);return;}
const away=screen!==hit.act,a=activityOf(hit.act),pg=a.pages.find(p=>p.id===hit.page),stay=!!(tabsIn(a)&&pg&&!pg.tab);
if(away){if(!stay) showPage(hit.act,hit.page);go(hit.act);}
else if(!stay&¤tPage(hit.act).id!==hit.page){showPage(hit.act,hit.page);render();}
if(hit.tab){const t=document.querySelector('#main [role="tab"][aria-selected="true"]');
if(t){if(t.scrollIntoView) t.scrollIntoView({block:"nearest"});t.focus({preventScroll:true});}return;}
if(!(away&&hit.title)) scrollToHeading(hit.heading);}
function headingLink(hit,kids){
return el("a",{href:"#",class:"alink",onclick:e=>{if(e&&e.preventDefault) e.preventDefault();openHeading(hit);}},...kids);}
/* what a prose block hands mdNodes so these links lead somewhere. A link is looked for in its own module
(the bare activities together are one module), never in another's: a heading that only another module has
is plain text, as a heading that is nowhere is. */
const linkHooks=scope=>{const mods=()=>allModules().filter(m=>scopeOfModule(m)===scope);
return {
moduleLink:(target,kids)=>moduleLinkNode(target,kids),
link:(target,label)=>{if(isModuleLink(target)) return moduleLinkNode(target,label!==undefined?[label]:null);
if(target[0]!=="#"||target[1]==="^") return null;
const h=target.slice(1).trim(),text=label!==undefined?label:h,hit=findHeading(mods(),h,false);
return hit?headingLink(hit,[text]):text;},
anchor:(slug,kids)=>{const hit=findHeading(mods(),slug,true);
return hit?headingLink(hit,kids):textOf(kids);}};};
/* ======================= READING MATERIAL =======================
Reading inside an activity (SPEC.md section 6): a footnote's mark, `[^id]`,
in the prose becomes a small number that costs the reader nothing; pressed
or hovered it shows the citation: the source's title, the page and the
quote, with a badge when the file says it was verified. A callout is drawn
in its kind's colour and picture (CALLOUT_LOOK); Booklet's three kinds
(`diff`, `law`, `opinion`) also keep their own border pattern. The
citations of the page on show print as endnotes. The renderer only DISPLAYS
whether a citation was verified; it never checks a quote against anything. */
/* the mark: [^ then an id of letters, digits, _ . : or -, then ] */
const CITE_RX=/\[\^([A-Za-z0-9][A-Za-z0-9_.:-]*)\]/g;
const citeIds=s=>typeof s==="string"&&s.includes("[^")?[...s.matchAll(CITE_RX)].map(m=>m[1]):[];
const regList=v=>Array.isArray(v)?v.filter(x=>x&&typeof x==="object"):[];
const isWhole=v=>typeof v==="number"?Number.isInteger(v)&&v>0:/^\d+$/.test(String(v==null?"":v).trim());
/* like nonEmpty, but for a value that may be a number (a page, 0): anything but null and a blank string */
const hasVal=v=>v!=null&&String(v).trim()!=="";
const clip=(s,n)=>{s=String(s==null?"":s).replace(/\s+/g," ").trim();return s.length>n?s.slice(0,n-1).trimEnd()+"…":s;};
/* a module's sources and citations, by key and by id, read out of its footnotes
(the first of a repeated one wins; the linter reports the repeat). The activities outside every fence are one module
(SPEC.md section 4): a footnote one defines is found from all. Source keys are kept apart by their list's place. */
function registries(mod){const sources=new Map(),cites=new Map();
const own=scopeOfModule(mod)===""?allModules().filter(m=>scopeOfModule(m)===""):[mod];
/* a module's own footnotes first, then the data section's (the booklet's own list) for any id it does not define */
[...own,BOOK].forEach((o,i)=>{const key=k=>i+":"+k,srcs=regList(o&&o.sources);
for(const s of srcs) if(typeof s.key==="string"&&s.key&&!sources.has(key(s.key))) sources.set(key(s.key),s);
for(const c of regList(o&&o.citations)) if(typeof c.id==="string"&&c.id&&!cites.has(c.id))
cites.set(c.id,srcs.some(s=>s.key===c.source)?{...c,source:key(c.source)}:c);});
return {sources,cites};}
/* The texts of a block that may carry marks, in reading order: a question's are its own hint's and solution's, drawn inside it,
so they come before anything after the question (an unfolded hint written between them included). */
function markedTexts(b){const out=[];eachBlock([b],x=>{if(x.type==="markdown") out.push(String(x.text||""));});return out;}
/* ---- the scope: one screen's numbering, worked out once per booklet ---- */
/* A module's opening is drawn with its activity when the module is one activity (so its marks and the activity's number on
together, 1, 2, ...), and on the module's own screen otherwise, where it is numbered on its own. */
const openingWithActivity=mod=>!!mod&&!isMulti(mod)&&Array.isArray(mod.opening)&&mod.opening.length>0;
/* The numbering of the marks screen `v` draws: an activity's screen numbers every page in order, every block the reader can
see, in order, after the opening of a module that is that one activity; a module's own screen numbers its opening. It is
a pure function of the design: {screen, mod, sources, cites, nums}, worked out the first time a screen asks and kept for
as long as the design is (a design is replaced, never changed), so drawing only reads it. Null for a screen that draws
no marks (the home screen, the list, a module's screen with no opening) or an activity no module holds, whose text is
drawn as it is. */
const CITE_SCOPES=new WeakMap();
function citeScope(v){let all=CITE_SCOPES.get(BOOK);if(!all) CITE_SCOPES.set(BOOK,all=new Map());
if(all.has(v)) return all.get(v);
const own=isModuleScreen(v)?moduleForScreen(v):null,mod=own||(hasOwn(SCREENS,v)?null:holderOf(v));
/* a module's screen draws its opening and the prose of each of its tabs (renderModulePage), numbered in that order */
const lists=own?(own.activities.length?[own.opening,...(tabsIn(own)||[]).map(t=>t.blocks)].filter(l=>Array.isArray(l)&&l.length):null)
:mod?[...(openingWithActivity(mod)?[mod.opening]:[]),...activityOf(v).pages.map(blocksIn)]:null;
let R=null;
if(lists&&lists.length){const {sources,cites}=registries(mod),nums=new Map();
for(const list of lists) for(const b of list) for(const s of markedTexts(b)) for(const id of citeIds(s))
if(cites.has(id)&&!nums.has(id)) nums.set(id,nums.size+1);
R={screen:v,mod,sources,cites,nums};}
all.set(v,R);return R;}
/* The marks a draw draws: its screen's numbering, with the draw's own bookkeeping, the first mark of each citation (it
carries the anchor) and every mark drawn, in order (the sources list's ways back, a language switch). render() makes it
for the screen on show, so the marks a page's head draws (a module that is one activity draws its opening there) and
the marks of its blocks are one numbering, and no anchor is used twice. A mark holds it for its panel and preview. */
const citeDraw=v=>{const R=citeScope(v);return R&&{...R,marks:new Map(),drawn:new Map()};};
/* A text with its marks drawn: the same single string when it has none. A
mark is held to the word before it (and to a mark right before it), because
a browser may otherwise break the line just before a button and leave the
number alone at the start of the next line. */
function citeText(s,dc){s=s==null?"":String(s);
if(!s.includes("[^")) return [s];
const R=dc.cite;if(!R) return [s];
const out=[];let at=0,glue=null;
for(const m of s.matchAll(CITE_RX)){
const pre=s.slice(at,m.index),mk=markNode(R,m[1]);
if(!pre&&glue) glue.append(mk);
else{const w=pre.match(/\S+$/),word=w?w[0]:"",head=pre.slice(0,pre.length-word.length);
if(head) out.push(head);
glue=el("span",{class:"rd-glue"},word||null,mk);out.push(glue);}
at=m.index+m[0].length;}
if(at{hideTip();openCite(R,id,b);});
/* a mouse, not a finger or a pen: the pointer says which it is, where a
media query only says what the device's main pointer is */
b.addEventListener("pointerenter",e=>{if(!e||e.pointerType==="mouse") showTip(R,id,b);});
b.addEventListener("pointerleave",hideTip);
b.addEventListener("blur",hideTip);
return el("sup",{class:"rd-sup"},b);}
/* ---- the panel: one for the page, made the first time a mark is drawn ---- */
let citePanelEl=null,citeTipEl=null,citeFrom=null;
/* which citation the open panel shows: the screen whose numbering it is, which of its marks on the page, and in what
language (one to open again once a redraw has drawn it is the draw's: dc.again) */
let citeAt=null;
function citePanel(){if(citePanelEl) return citePanelEl;
const p=el("div",{class:"rd-panel",id:"rd-panel",role:"dialog","aria-modal":"false",
"aria-labelledby":"rd-panel-h",hidden:true});
p.addEventListener("keydown",e=>{if(e.key!=="Escape") return;
e.preventDefault();if(e.stopPropagation) e.stopPropagation();closeCite();});
document.body.append(p);return citePanelEl=p;}
const citeOpen=()=>!!citePanelEl&&!citePanelEl.hasAttribute("hidden");
function pageLine(c,Rt){const p=hasVal(c.page)?String(c.page).trim():"";
/* a page may be a place that is not a number ("art. 14"), shown as written */
return !p?"":isWhole(p)?Rt.page(p):p;}
/* A footnote that names no source (a reference written as it was given, with no title and quote to read out of it) is a plain
reference: its text is drawn as it stands, with no quotation marks and no line about a missing source. The red line is for a
citation that names a source the module does not hold. */
const plainRef=(c,src)=>!src&&!hasVal(c.source);
function citeDetail(c,src,Rt){const out=[];
if(plainRef(c,src)){const t=tx(c.quote,"");
if(nonEmpty(t)) out.push(el("p",{class:"rd-psrc rd-plain"},t));
const pl=pageLine(c,Rt);if(pl) out.push(el("p",{class:"rd-ppage"},pl));
return out;}
if(src){const ed=tx(src.edition,"");
out.push(el("p",{class:"rd-psrc"},el("cite",{},tx(src.title,src.key)),nonEmpty(ed)?", "+ed:null));}
else out.push(el("p",{class:"rd-psrc rd-bad"},Rt.noSource(String(c.source==null?"":c.source))));
const pl=pageLine(c,Rt);if(pl) out.push(el("p",{class:"rd-ppage"},pl));
const q=tx(c.quote,"");if(nonEmpty(q)) out.push(el("blockquote",{class:"rd-q"},el("mark",{},q)));
if(hasVal(c.verifiedOn)) out.push(el("p",{class:"rd-badge"},el("span",{"aria-hidden":"true"},"✓"),
el("span",{},Rt.verified(longDate(String(c.verifiedOn).trim()))),
hasVal(c.verifiedBy)?el("span",{class:"rd-how"},Rt.how+": "+String(c.verifiedBy).trim()):null));
return out;}
function openCite(R,id,from,opts){const c=R&&R.cites.get(id);if(!c) return false;
const Rt=strings("reading"),p=citePanel(),src=R.sources.get(c.source)||null;
const at=from?(R.drawn.get(id)||[]).indexOf(from):-1;
citeAt={screen:R.screen,id,occ:at<0?0:at,lang};
/* the page moves over to make room beside it (from 900px wide; see the CSS) */
document.documentElement.setAttribute("data-cite-open","");
if(citeFrom&&citeFrom!==from) citeFrom.setAttribute("aria-expanded","false");
citeFrom=from||null;if(from) from.setAttribute("aria-expanded","true");
p.innerHTML="";
const h=el("h2",{class:"rd-ph",id:"rd-panel-h",tabindex:"-1"},Rt.cite(R.nums.get(id)));
p.append(el("div",{class:"rd-phead"},h,el("button",{type:"button",class:"small rd-x",onclick:()=>closeCite()},Rt.close)),
...citeDetail(c,src,Rt));
p.removeAttribute("hidden");
/* the page has moved over, so bring the mark back into view if it left it */
if(from&&from.isConnected&&from.scrollIntoView) from.scrollIntoView({block:"nearest"});
if(!(opts&&opts.focus===false)) h.focus();
return true;}
/* closing gives the focus back to the mark it came from, unless a redraw is
taking the mark away (refocus false) */
function closeCite(refocus){if(!citeOpen()) return;citePanelEl.setAttribute("hidden","");
document.documentElement.removeAttribute("data-cite-open");citeAt=null;
const f=citeFrom;citeFrom=null;
if(f){f.setAttribute("aria-expanded","false");if(refocus!==false) f.focus();}}
/* after a redraw for a language switch: the same citation, from the same mark */
function citeReopen(dc){const a=dc.again,R=dc.cite;dc.again=null;if(!a||!R||a.screen!==R.screen||!R.cites.has(a.id)) return;
const drawn=R.drawn.get(a.id)||[];
openCite(R,a.id,drawn[a.occ]||drawn[0]||null,{focus:false});}
/* leaving the screen closes its panel, and gives the page its room back */
function citeLeave(v){if(citeOpen()&&!(citeAt&&citeAt.screen===v)) closeCite(false);}
/* ---- the hover preview: a mouse's glance at the quote, and nothing else ---- */
function citeTip(){if(citeTipEl) return citeTipEl;
const t=el("div",{class:"rd-tip","aria-hidden":"true",hidden:true});document.body.append(t);return citeTipEl=t;}
function showTip(R,id,at){if(citeOpen()&&citeFrom===at) return;
const c=R&&R.cites.get(id);if(!c) return;
const Rt=strings("reading"),t=citeTip(),q=tx(c.quote,""),src=R.sources.get(c.source);
t.innerHTML="";
t.append(el("span",{},nonEmpty(q)?(plainRef(c,src)?clip(q,220):Rt.quoted(clip(q,220))):Rt.cite(R.nums.get(id))),
el("span",{class:"rd-tw"},[src?tx(src.title,src.key):"",pageLine(c,Rt)].filter(nonEmpty).join(" · ")));
t.removeAttribute("hidden");
/* kept inside the window, below the mark or above it near the bottom */
const r=at.getBoundingClientRect(),W=window.innerWidth,H=window.innerHeight,w=t.offsetWidth,h=t.offsetHeight;
t.style.left=Math.max(8,Math.min(r.left-8,W-w-8))+"px";
t.style.top=(r.bottom+8+h>H?Math.max(8,r.top-8-h):r.bottom+8)+"px";}
function hideTip(){if(citeTipEl) citeTipEl.setAttribute("hidden","");}
/* ---- callouts ---- */
let rdSeq=0;
/* a hint or a solution straight after a question but drawn outside it (`outside`) is drawn plain: no kind's colour or label */
/* a group is a titled container, drawn plain as its fold is: its title, no kind's word or colour, a group to a screen reader */
function calloutNode(b,draft,activityId,dc){const Rt=strings("reading"),grp=isGroup(b),plain=!!b.outside||grp,look=plain?null:calloutLook(b.kind),k=look?look.label:"";
const u="rd-c"+(++rdSeq),title=tx(b.title,"");
const box=el("div",{class:"rd-callout"+(look?" rd-hued hue-"+look.hue+" rd-k-"+look.label:""),role:grp?"group":"note","data-kind":k||null});
const head=el("div",{class:"rd-chead"}),named=[];
if(look){const pic=icon(look.icon);
if(pic) head.append(el("span",{class:"rd-ci","aria-hidden":"true"},pic));
head.append(el("span",{class:"rd-cl",id:u+"-k"},Rt.kind[k]));named.push(u+"-k");}
const body=el("div",{class:"rd-cb",id:u+"-b"});
/* the kind's word heads the box; a plain one (a group) has none, and an untitled group has no head at all (any other callout keeps its head, empty or not) */
if(!(grp&&!nonEmpty(title))) box.append(head);
if(nonEmpty(title)){box.append(el("p",{class:"rd-ctitle",id:u+"-t"},...words(title,false,true)));named.push(u+"-t");}
if(b.unknown) box.append(el("p",{class:"hint rd-unknown"},Rt.unknownKind(b.kind)));
if(named.length) box.setAttribute("aria-labelledby",named.join(" "));
blocksIn(b).forEach(x=>{if(x&&typeof x==="object") body.append(renderBlock(x,draft,activityId,dc));});
box.append(body);
return box;}
/* Before a page's head is drawn (and a module's opening on its own screen): the panel closed (the mark it came from is being
redrawn). */
function readingBegin(dc){
/* A switch of the interface language redraws the page: the panel is opened
again once its mark is drawn (citeReopen, dc.again), in the new language, and the
focus stays where the reader put it. Any other redraw closes it. */
dc.again=citeOpen()&&citeAt&&dc.cite&&citeAt.screen===dc.cite.screen&&citeAt.lang!==lang?citeAt:null;
closeCite(false);hideTip();}
/* ---- print: the citations as endnotes at the end of the page on show ---- */
function citeRefText(c,src,Rt){const bits=[];
if(src){const ed=tx(src.edition,"");bits.push(tx(src.title,src.key)+(nonEmpty(ed)?", "+ed:""));}
const pl=pageLine(c,Rt);if(pl) bits.push(pl);
const q=tx(c.quote,"");
return bits.join(". ")+(bits.length&&nonEmpty(q)?". ":"")+(nonEmpty(q)?(plainRef(c,src)?q:Rt.quoted(q)):"");}
/* the endnotes of the page on show (or, given `shown`, of the blocks a module's own screen draws: its opening and its tab's prose) */
function endnotesFor(id,shown,dc){const R=dc.cite;if(!R) return null;
const here=[],mod=R.mod;shown=shown||[...(openingWithActivity(mod)?mod.opening:[]),...pageBlocksShown(id)];
for(const b of shown) for(const s of markedTexts(b)) for(const cid of citeIds(s))
if(R.nums.has(cid)&&!here.includes(cid)) here.push(cid);
if(!here.length) return null;
here.sort((a,b)=>R.nums.get(a)-R.nums.get(b));
const Rt=strings("reading"),ol=el("ol",{class:"rd-nlist"});
here.forEach(cid=>{const c=R.cites.get(cid);
ol.append(el("li",{value:String(R.nums.get(cid))},citeRefText(c,R.sources.get(c.source)||null,Rt)));});
return el("section",{class:"rd-notes","aria-labelledby":"rd-notes-h"},el("h3",{id:"rd-notes-h"},Rt.notes),ol);}
/* ======================= 12. THE SHELL =======================
the bar, the theme menu, the library, home, the module and activity screens, dialogs, export, add a module. */
/* a pick on the toggle is the reader's choice, in every booklet */
function pickLang(l){setLangPref(l);render();}
/* The header toggle, always all three buttons; the language in force's is pressed (ES for es-AR). Called at the end of renderBar. */
function applyLangToggle(){
document.querySelectorAll(".lang button").forEach(b=>{const to=b.dataset.lang;
b.setAttribute("aria-pressed",String(to===primaryOf(lang)));
/* named for the language it gives, in that language (EN is "English" to
everyone), so a screen reader says a name rather than two letters */
b.setAttribute("lang",to);b.setAttribute("aria-label",LANG_NAMES[to]||to);b.setAttribute("title",LANG_NAMES[to]||to);});}
/* go to screen `v`; returns the draw */
function go(v){screen=v;const dc=render();window.scrollTo({top:0});return dc;}
function tabName(){const d=describeBooklet(BOOK);const t=tx(d.title,"");
if(typeof t==="string"&&nonEmpty(t)) return t.trim();
const p=d.parts.map(x=>tx(x,"")).filter(x=>typeof x==="string"&&nonEmpty(x));
return p.length?p.join(", "):strings("home").h1;}
function renderBar(){const t=strings("ui");const atList=screen===LIB_SCREEN,Lb=strings("library");
/* a tab is named after the booklet in it, so several open booklets can be told
apart, and by the same name "Your booklets" gives it */
document.documentElement.lang=lang;document.title=atList?Lb.title:tabName();document.body.dataset.view=screen;syncBookletTheme();
/* The way out climbs one level: an activity leads to its booklet's home,
and a booklet's home leads to "Your booklets". */
const toList=screen===HOME_SCREEN&&!!currentId;
document.getElementById("btnHome").textContent=atList?t.app:toList?"← "+Lb.back:(screen===HOME_SCREEN?"":"← ")+t.app;
const dl=document.getElementById("btnExport");dl.textContent=t.export;dl.hidden=atList;
document.getElementById("dropZone").textContent=t.dropZone;document.getElementById("dropVeil").textContent=t.dropVeil;
for(const [id,key] of [["loadTitle","loadTitle"],["loadHelp","loadHelp"],["loadOr","loadOr"],["loadCancel","loadCancel"],["loadGo","loadGo"],
["clearTitle","clearTitle"],["clearHelp","clearHelp"],["clearCancel","clearCancel"],["clearGo","clearGo"]]) document.getElementById(id).textContent=t[key];
if(atList) document.getElementById("loadTitle").textContent=Lb.load;
document.getElementById("langGroup").setAttribute("aria-label",t.pLang);
drawThemeSelect(t);
document.getElementById("rendererVer").textContent="v"+RENDERER_VERSION;
if(storageOk) document.getElementById("clearHelp").textContent=t.clearHelp+" "+t.clearHelpStored;
drawSaveState();
applyLangToggle();
}
/* the booklet that is open asks for its look; the list of booklets never does */
let THEME_SHOWN="";
function syncBookletTheme(){const B=window.bookletTheme;if(!B||!B.custom) return;
const spec=screen!==LIB_SCREEN&¤tId&&BOOK&&BOOK.theme&&Object.keys(BOOK.theme).length?BOOK.theme:null;
const key=spec?JSON.stringify(spec):"";
if(key===THEME_SHOWN) return;THEME_SHOWN=key;
B.custom(spec?themeDerive(spec,themeBaseTable):null);}
/* The reader's theme: a menu in the bar. The choice and the device-following live
in the head script (window.bookletTheme); this only draws the control in the
language in force and passes the choice on. Native