Drop the spec file to render it
(s.match(/^[ \t]*/)[0]).replace(/\t/g, ' ').length;
const isComment = l => /^\s*\/\//.test(l);
function splitTop(str, sep = ','){
const out = []; let depth = 0, cur = '', q = null;
for (const ch of str){
if (q){ cur += ch; if (ch === q) q = null; continue; }
if (ch === '"' ) { q = ch; cur += ch; continue; }
if ('({<['.includes(ch)) depth++;
if (')}>]'.includes(ch)) depth--;
if (ch === sep && depth === 0){ out.push(cur); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur);
return out.map(s => s.trim()).filter(Boolean);
}
function screensIn(list){
const out = [];
for (let part of splitTop(list)){
part = part.trim();
let m = part.match(/^(\d{2})\s*-\s*(\d{2})\b/);
if (m){ for (let x = +m[1]; x <= +m[2] && x - +m[1] < 40; x++) out.push(String(x).padStart(2, '0')); continue; }
m = part.match(/^W(\d{1,2})\s*-\s*W?(\d{1,2})\b/);
if (m){ for (let x = +m[1]; x <= +m[2] && x - +m[1] < 40; x++) out.push('W' + x); continue; }
m = part.match(/^((?:W\d{1,2}|\d{2}b?)(?:\/(?:W\d{1,2}|\d{2}b?))*)(?=$|[\s"'.,:;)])/);
if (m && !UNIT_AFTER.test(part.slice(m[1].length))) m[1].split('/').forEach(t => out.push(t));
}
return out.filter(t => SCR_TOKEN.test(t));
}
function leadingScreens(text){
const out = [];
for (const part of String(text).split(',')){
const got = screensIn(part.trim().split(/\s+/).slice(0, 1).join(' ') + (UNIT_AFTER.test(part.trim().replace(/^\S+/, '')) ? ' min' : ''));
if (!got.length) break;
out.push(...got);
}
return out;
}
function parenScreens(text){
const out = [];
String(text).replace(/\(([^()]*)\)/g, (_, inner) => { out.push(...screensIn(inner)); return _; });
return out;
}
function splitTitleRefs(t){
const m = t.match(/^(.*?)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)\s*$/);
if (m && screensIn(m[2]).length) return {main: m[1], refs: m[2]};
return {main: t, refs: ''};
}
function prettyName(raw){
const {main, refs} = splitTitleRefs(raw.trim());
let name = main;
const pm = name.match(/^([^(]*?)(\s*\(.*\))?$/);
let head = pm ? pm[1] : name; const tail = pm && pm[2] ? pm[2] : '';
if (head === head.toUpperCase() && /[A-Z]/.test(head)) head = head.toLowerCase().replace(/^\s*\w/, c => c.toUpperCase());
name = head + tail;
return {name, refs};
}
function parseType(ts){
const m = ts.match(/^(#?[A-Za-z_][\w.]*)(\[\])?(?:\(([^)]*)\))?(?:<([^>]*)>)?(\[\])?\s*(.*)$/);
if (!m) return {type: ts, base: ts, mods: {}};
const t = {type: ts, base: m[1], listType: !!(m[2] || m[5]), enumVals: m[3] ? m[3].split('|').map(s => s.trim()).filter(Boolean) : null, mods: {}, trailing: m[6] || ''};
if (m[4]) m[4].split('|').forEach(p => { const [k, ...v] = p.split(':'); if (k.trim()) t.mods[k.trim()] = v.length ? v.join(':').trim() : true; });
return t;
}
function parse(src){
const L = src.replace(/\r\n?/g, '\n').split('\n');
const doc = {lines: L, edits: {}, title: '', titleEid: null, version: '', sections: [], models: [], objects: [],
endpoints: [], coded: {}, rates: [], screens: {}, warnings: [], modelIdx: {}, objIdx: {}, rateIdx: {}, items: []};
let n = 0, cur = null;
const R = (i, s, e) => { const id = 'e' + (n++); doc.edits[id] = {kind: 'range', i, s, e}; return id; };
const RT = (i, s, e) => {
const line = L[i]; if (e == null) e = line.length;
while (s < e && /\s/.test(line[s])) s++;
while (e > s && /\s/.test(line[e-1])) e--;
return {text: line.slice(s, e), eid: R(i, s, e)};
};
const BL = items => { const id = 'e' + (n++); doc.edits[id] = {kind: 'block', items}; return id; };
const addScr = (list) => { if (!cur) return; list.forEach(t => cur.screens.add(t)); };
const touch = (text) => { if (text) addScr(parenScreens(text)); };
let sec;
const newSec = (raw, line, intro) => {
const p = prettyName(raw);
sec = {raw, name: p.name, refs: p.refs, line, intro: !!intro, key: 's' + doc.sections.length, items: []};
sec.decision = /default|decision|confirm/i.test(raw);
sec.question = /open|question|todo|unresolved|parking/i.test(raw);
doc.sections.push(sec);
return sec;
};
newSec('Introduction', -1, true);
const push = (item) => { item.section = sec; sec.items.push(item); doc.items.push(item); return item; };
// comment block gatherer: first line prefix given; continuation = following comment lines indented >= minInd
function gather(i, firstPrefix, minInd){
const lines = [{i, prefix: firstPrefix}];
let j = i + 1; const cont = [];
while (j < L.length && isComment(L[j])){
const m = L[j].match(/^(\s*\/\/)(.*)$/); const body = m[2];
if (!body.trim() || visIndent(body) < minInd || BANNER_RE.test(L[j].trim())) break;
cont.push({j, pre: m[1], body}); j++;
}
if (cont.length){
const base = Math.min(...cont.map(c => (c.body.match(/^[ \t]*/)[0]).length));
cont.forEach(c => lines.push({i: c.j, prefix: c.pre + c.body.slice(0, base)}));
}
const text = lines.map(x => L[x.i].slice(x.prefix.length)).join('\n').replace(/\s+$/,'');
return {lines, text, eid: BL(lines), end: j - 1};
}
/* ----- brace blocks (fields) ----- */
function mkField(k, name, list, opt, typeStr, comment, cs){
const raw = L[k];
const f = {name, list, opt, line: k, children: null, enumDocs: [], extra: [], mods: {}};
const ni = raw.search(new RegExp('\\b' + name + '\\b'));
f.nameEid = ni >= 0 ? R(k, ni, ni + name.length) : null;
if (typeStr != null){
Object.assign(f, parseType(typeStr));
const ti = raw.indexOf(typeStr, ni >= 0 ? ni + name.length : 0);
if (ti >= 0) f.typeEid = R(k, ti, ti + typeStr.length);
}
if (comment != null && comment.trim()){
const c = RT(k, cs); f.comment = c.text; f.commentEid = c.eid;
addScr(leadingScreens(c.text)); touch(c.text);
}
return f;
}
function parseInline(str){
const out = [];
for (const piece of splitTop(str)){
let m = piece.match(/^(\w+)(\[\])?(\?)?\s*\{(.*)\}$/);
if (m){ out.push({name: m[1], list: !!m[2], opt: !!m[3], children: parseInline(m[4]), enumDocs: [], extra: [], mods: {}}); continue; }
m = piece.match(/^\.\.\.(#?\w+)$/);
if (m){ out.push({spread: m[1]}); continue; }
m = piece.match(/^(\w+)(\[\])?(\?)?\s+(.+)$/);
if (m){ out.push(Object.assign({name: m[1], list: !!m[2], opt: !!m[3], enumDocs: [], extra: []}, parseType(m[4].trim()))); continue; }
if (piece) out.push({name: piece, enumDocs: [], extra: [], mods: {}, base: ''});
}
return out;
}
function parseFields(k){
const fields = []; let last = null; const notes = [];
while (k < L.length){
const raw = L[k];
const {code, comment, cs} = splitComment(raw);
const c = code.trim();
if (c.startsWith('}')) return {fields, end: k, notes};
if (!c){
if (comment != null && comment.trim()){
const em = comment.match(/^\s*([\w-]+)\s*:\s+(.*)$/);
if (last && last.enumVals && em && last.enumVals.includes(em[1])){
const off = cs + comment.indexOf(em[2], comment.indexOf(':'));
const t = RT(k, off); last.enumDocs.push({value: em[1], text: t.text, eid: t.eid}); touch(t.text);
} else if (last && last.enumDocs.length && visIndent(comment) >= 4 && !em){
const t = RT(k, cs); last.extra.push(t);
} else if (last){
const t = RT(k, cs); last.extra.push(t); touch(t.text);
} else { const t = RT(k, cs); notes.push(t); }
} else if (raw.trim() && !isComment(raw)) {
// stray code line: keep as note
}
k++; continue;
}
let m = c.match(/^\.\.\.(#?\w+)$/);
if (m){ fields.push({spread: m[1], line: k}); last = null; k++; continue; }
m = c.match(/^(\w+)(\[\])?(\?)?\s*\{$/);
if (m){
const f = mkField(k, m[1], !!m[2], !!m[3], null, comment, cs);
const r = parseFields(k + 1); f.children = r.fields; fields.push(f); last = f; k = r.end + 1; continue;
}
m = c.match(/^(\w+)(\[\])?(\?)?\s*\{(.*)\}$/);
if (m){
const f = mkField(k, m[1], !!m[2], !!m[3], null, comment, cs);
f.children = parseInline(m[4]); fields.push(f); last = f; k++; continue;
}
m = c.match(/^(\w+)(\[\])?(\?)?\s+(.+?)\s*$/);
if (m){
const f = mkField(k, m[1], !!m[2], !!m[3], m[4], comment, cs);
fields.push(f); last = f; k++; continue;
}
m = c.match(/^(\w+)(\[\])?(\?)?$/);
if (m){ const f = mkField(k, m[1], !!m[2], !!m[3], '', comment, cs); f.base = ''; fields.push(f); last = f; k++; continue; }
notes.push({text: c}); k++;
}
doc.warnings.push('A block that starts near line ' + (k) + ' never closes with }.');
return {fields, end: L.length - 1, notes};
}
function parseModel(i, isObj, name, comment, cs){
const item = {t: isObj ? 'object' : 'model', name, line: i, screens: new Set(), notes: [], indexes: []};
cur = item;
if (comment && comment.trim()){ const c = RT(i, cs); item.note = c.text; item.noteEid = c.eid; addScr(leadingScreens(c.text)); touch(c.text); }
const r = parseFields(i + 1);
item.fields = r.fields; item.notes.push(...r.notes);
let j = r.end + 1, inIdx = false;
while (j < L.length && isComment(L[j]) && L[j].trim() && !BANNER_RE.test(L[j].trim())){
const m = L[j].match(/^(\s*\/\/)(.*)$/); const body = m[2];
const im = body.match(/^\s*Indexes?\s*:\s*(.*)$/i);
if (im || (inIdx && /^\s*\{/.test(body))){
inIdx = true;
const restStart = m[1].length + (im ? body.indexOf(im[1]) : 0);
const rest = L[j].slice(restStart);
const sc = splitComment(rest);
item.indexes.push({spec: sc.code.trim(), why: sc.comment ? sc.comment.trim() : ''});
} else {
inIdx = false;
if (/^\s*\d+[a-z]?\s*:/.test(body) || /^\s*End\b/i.test(body)) break;
const lm = body.match(/^\s*([A-Z][^:]{1,60}?):\s+/);
const t = RT(j, m[1].length);
if (lm) t.label = lm[1];
item.notes.push(t); touch(t.text);
}
j++;
}
item.end = j - 1;
item.key = (isObj ? 'o-' : 'm-') + name;
(isObj ? doc.objects : doc.models).push(item);
(isObj ? doc.objIdx : doc.modelIdx)[name] = item;
push(item); cur = null;
return j;
}
/* ----- endpoints ----- */
function parseEndpoint(i, titleLine){
const mm = L[i].trim().match(METHOD_RE);
const ep = {t: 'endpoint', method: mm[1], path: mm[2], line: titleLine != null ? titleLine : i, auth: [], rate: [], notes: [], rules: [],
accepts: null, returns: null, screens: new Set()};
cur = ep;
if (titleLine != null){
const tl = L[titleLine];
const tm = tl.match(/^(\s*\/\/\s*)(\d+[a-z]?)\s*:\s*(.*)$/);
ep.num = tm[2];
const full = tm[3].trim();
const {main, refs} = splitTitleRefs(full);
const s = tl.indexOf(full, tm[1].length + tm[2].length);
const t = RT(titleLine, s, s + main.length);
ep.title = t.text; ep.titleEid = t.eid; ep.refs = refs;
addScr(screensIn(refs));
} else { ep.title = mm[1] + ' ' + mm[2]; ep.num = ''; }
if (mm[3]) ep.notes.push({text: mm[3]});
let k = i + 1, mode = 'head';
while (k < L.length && L[k].trim() && !BANNER_RE.test(L[k].trim())){
const raw = L[k], tt = raw.trim();
if (/^\/\/\s*\d+[a-z]?\s*:/.test(tt) && k + 1 < L.length && METHOD_RE.test((L[k+1] || '').trim())) break;
let m;
if ((m = tt.match(/^(Accepts|Body|Params|Query|Input)\s*(\[\])?\s*\{(.*)$/i))){
if (/\}\s*(\/\/.*)?$/.test(m[3])){ ep.accepts = {fields: parseInline(m[3].replace(/\}\s*(\/\/.*)?$/, ''))}; k++; }
else { const r = parseFields(k + 1); ep.accepts = {fields: r.fields}; k = r.end + 1; }
mode = 'head'; continue;
}
if ((m = tt.match(/^(Returns|Response|Responds)\b\s*(.*)$/i))){
let rest = m[2].trim();
const ret = {list: false, ref: null, fields: null, text: ''};
const listM = rest.match(/^\[\]\s*(.*)$/); if (listM){ ret.list = true; rest = listM[1]; }
const sc = splitComment(rest); const restCode = sc.code.trim();
if (restCode === '{'){
const r = parseFields(k + 1); ret.fields = r.fields; k = r.end + 1;
} else if (restCode.startsWith('{')){
let buf = restCode, j = k;
const bal = s => (s.match(/\{/g) || []).length - (s.match(/\}/g) || []).length;
while (bal(buf) > 0 && j + 1 < L.length && L[j+1].trim()){ j++; buf += ' ' + L[j].trim(); }
ret.fields = parseInline(buf.replace(/^\{/, '').replace(/\}\s*(\/\/.*)?$/, ''));
k = j + 1;
} else if (/^#?[A-Za-z_]\w*$/.test(restCode)){
ret.ref = restCode.replace(/^#/, ''); k++;
} else { ret.text = rest; k++; }
ep.returns = ret; mode = 'head'; continue;
}
if ((m = raw.match(/^(\s*Rules\s*:\s*)(.*)$/i))){
mode = 'rules';
if (m[2].trim()){
const lines = [{i: k, prefix: m[1]}];
ep.rules.push({kind: 'para', lines});
}
k++; continue;
}
if (mode === 'rules'){
const bm = raw.match(/^(\s*[-*•]\s+)/);
const sm = raw.match(/^\s*([\w][\w ]{0,24}):\s*$/);
if (bm){ ep.rules.push({kind: 'bullet', lines: [{i: k, prefix: bm[1]}]}); }
else if (sm){ ep.rules.push({kind: 'sub', text: sm[1]}); }
else {
const last = ep.rules[ep.rules.length - 1];
const pre = raw.match(/^\s*/)[0];
if (last && last.lines) last.lines.push({i: k, prefix: pre});
else ep.rules.push({kind: 'para', lines: [{i: k, prefix: pre}]});
}
k++; continue;
}
// head lines: auth, rate limits, notes
for (const sent of tt.split(/\.\s+|\.$/).map(s => s.trim()).filter(Boolean)){
let r;
if ((r = sent.match(/^Rate[- ]limited\s*:?\s*(.*)$/i))) ep.rate.push(r[1].trim());
else if (/auth/i.test(sent) || /^(public|anyone|admin)/i.test(sent)) ep.auth.push(sent);
else ep.notes.push({text: sent});
}
k++;
}
ep.rules.forEach(r => {
if (!r.lines) return;
// normalise continuation prefixes so the block re-writes cleanly
r.lines.forEach((x, idx) => { if (idx > 0){ const ws = L[x.i].match(/^\s*/)[0]; x.prefix = ws; } });
r.text = r.lines.map(x => L[x.i].slice(x.prefix.length)).join('\n').replace(/\s+$/, '');
r.eid = BL(r.lines);
});
ep.end = k - 1;
ep.key = 'ep-' + (ep.num || '') + '-' + ep.method + ep.path.replace(/[^\w]+/g, '-');
doc.endpoints.push(ep); push(ep); cur = null;
return k;
}
/* ----- main loop ----- */
let i = 0, firstComment = true;
while (i < L.length){
const line = L[i], t = line.trim();
if (!t){ i++; continue; }
if (BANNER_RE.test(t)){
if (i + 2 < L.length && isComment(L[i+1]) && !BANNER_RE.test(L[i+1].trim()) && BANNER_RE.test(L[i+2].trim())){
newSec(L[i+1].trim().replace(/^\/\/\s*/, ''), i + 1); i += 3; firstComment = false; continue;
}
i++; continue;
}
if (!isComment(line)){
const {code, comment, cs} = splitComment(line);
const bm = code.trim().match(/^(#?)([A-Za-z_]\w*)\s*\{$/);
if (bm && !/^\s/.test(line)){ i = parseModel(i, !!bm[1], bm[2], comment, cs); firstComment = false; continue; }
if (METHOD_RE.test(t)){ i = parseEndpoint(i, null); continue; }
// markdown-ish heading support: "## Name"
const hm = t.match(/^#{1,3}\s+(.+)$/);
if (hm){ newSec(hm[1], i); i++; continue; }
const g = {t: 'prose', lines: [{i, prefix: line.match(/^\s*/)[0]}]};
g.text = t; g.eid = BL(g.lines); push(g); i++; continue;
}
const m0 = line.match(/^(\s*\/\/)(.*)$/);
const pre = m0[1], body = m0[2], ind = visIndent(body);
let m;
// document title = first comment line of the file
if (firstComment && sec.intro && !doc.title){
firstComment = false;
const tx = RT(i, pre.length);
const vm = tx.text.match(/^(.*?)\s*[-–]\s*(v[\d.]+\w*)\s*$/i);
if (vm){ doc.title = vm[1]; doc.version = vm[2]; doc.titleEid = R(i, tx.eid ? doc.edits[tx.eid].s : pre.length, doc.edits[tx.eid].s + vm[1].length); }
else { doc.title = tx.text; doc.titleEid = tx.eid; }
i++; continue;
}
firstComment = false;
if ((m = body.match(/^\s*(\d+[a-z]?)\s*:\s*(.*)$/))){
if (i + 1 < L.length && METHOD_RE.test(L[i+1].trim())){ i = parseEndpoint(i + 1, i); continue; }
i++; continue; // a numbered divider like "01: Create Models"
}
if (/^\s*End\b/i.test(body)){ i++; continue; }
if ((m = body.match(/^\s*([\w.:-]+)\s+(GET|POST|PUT|PATCH|DELETE)\s+(\/\S*)\s+(.+)$/))){
const lim = body.indexOf(m[4], body.indexOf(m[3]) + m[3].length);
const t2 = RT(i, pre.length + lim);
const row = {t: 'rate', name: m[1], method: m[2], path: m[3], limit: t2.text, eid: t2.eid, line: i, end: i};
doc.rates.push(row); doc.rateIdx[m[1]] = row; push(row); i++; continue;
}
if (ind <= 1 && (m = body.match(/^(\s*)([A-Z]{1,2}\d{1,3})\s*[.:)]?\s+(\S.*)$/))){
const code = m[2];
const prefix = pre + body.slice(0, body.indexOf(m[3], m[1].length + code.length));
const g = gather(i, prefix, 2);
// lines starting with ">" under an item are comments from reviewers
const ci = g.lines.findIndex((x, k) => k > 0 && /^\s*>/.test(L[x.i].slice(x.prefix.length)));
if (ci > 0){
const tl = g.lines.slice(0, ci);
g.text = tl.map(x => L[x.i].slice(x.prefix.length)).join('\n').replace(/\s+$/, '');
g.eid = BL(tl);
}
const comments = (ci > 0 ? g.lines.slice(ci) : []).map(x => {
const raw = L[x.i]; const gt = raw.indexOf('>', x.prefix.length);
const after = gt >= 0 ? raw.slice(gt + 1) : raw.slice(x.prefix.length);
const mm = gt >= 0 ? after.match(/^\s*(?:(.*?)\s*)?\((\d{1,2} [A-Za-z]{3,9} \d{4})\)\s*(?:\[([^\]]+)\])?\s*:\s*/) : null;
const start = (gt >= 0 ? gt + 1 : x.prefix.length) + (mm ? mm[0].length : after.match(/^\s*/)[0].length);
const tx = RT(x.i, start);
return {line: x.i, author: mm && mm[1] ? mm[1] : '', date: mm ? mm[2] : '', status: mm && mm[3] ? mm[3] : '', text: tx.text, eid: tx.eid};
});
const tm = g.text.match(/^([^:.\n]{2,52}?)[:.](\s|$)/);
const item = {t: 'coded', code, text: g.text, eid: g.eid, line: i, end: g.end, screens: new Set(), comments,
textEnd: ci > 0 ? g.lines[ci - 1].i : g.end, contPrefix: g.lines.length > 1 ? g.lines[1].prefix : null, firstPrefix: prefix,
title: tm && tm[1].split(/\s+/).length <= 8 ? tm[1] : ''};
item.key = 'c-' + code;
if (!doc.coded[code]) doc.coded[code] = item;
push(item); i = g.end + 1; continue;
}
if (ind <= 1 && (m = body.match(/^(\s*[-*•]\s+)(.*)$/))){
const g = gather(i, pre + m[1], 2);
const item = {t: 'bullet', text: g.text, eid: g.eid, line: i, end: g.end, screens: new Set()};
push(item); i = g.end + 1; continue;
}
if (ind === 0 && (m = body.match(/^([A-Z][^:]{1,70}?):\s+(\S.*)$/)) && !/^https?$/i.test(m[1])){
const prefix = pre + body.slice(0, body.indexOf(m[2], m[1].length + 1));
const g = gather(i, prefix, 2);
const item = {t: 'labeled', label: m[1], text: g.text, eid: g.eid, line: i, end: g.end, screens: new Set()};
push(item); i = g.end + 1; continue;
}
if (ind >= 2 && (m = body.match(/^(\s+)([\w][\w ]{0,24}?)\s*:\s+(\S.*)$/))){
const prefix = pre + body.slice(0, body.indexOf(m[3], m[1].length + m[2].length));
const g = gather(i, prefix, ind + 1);
push({t: 'def', term: m[2].trim(), text: g.text, eid: g.eid, line: i, end: g.end});
i = g.end + 1; continue;
}
// prose: append to the previous prose item if it is directly above
const lastItem = sec.items[sec.items.length - 1];
const ws = body.match(/^\s*/)[0];
if (lastItem && lastItem.t === 'prose' && lastItem.end === i - 1){
lastItem.lines.push({i, prefix: pre + ws});
lastItem.end = i;
} else {
push({t: 'prose', lines: [{i, prefix: pre + ws}], line: i, end: i});
}
i++;
}
doc.items.forEach(it => {
if (it.t === 'prose'){
it.text = it.lines.map(x => L[x.i].slice(x.prefix.length)).join('\n');
it.eid = BL(it.lines);
}
});
// screen index
const allCardsWithScreens = [...doc.models, ...doc.objects, ...doc.endpoints, ...doc.items.filter(x => x.screens && (x.t === 'coded' || x.t === 'bullet' || x.t === 'labeled'))];
allCardsWithScreens.forEach(c => (c.screens || new Set()).forEach(s => { (doc.screens[s] = doc.screens[s] || new Set()).add(c); }));
doc.sections.forEach(s => screensIn(s.refs || '').forEach(x => { s.screenList = s.screenList || []; s.screenList.push(x); }));
if (!doc.title) doc.title = 'Untitled spec';
return doc;
}
/* =====================================================================
SOURCE EDITS (write inline edits back into the spec text)
===================================================================== */
// Wrapped source lines read as one flowing paragraph; real structure (deeper indent, bullets, a line ending in ':') keeps its line break.
function blockDisplay(def, lines){
const L = lines || S.doc.lines;
const ts = def.items.map(x => (L[x.i] || '').slice(x.prefix.length).replace(/\s+$/, ''));
let out = ts[0] || '';
for (let k = 1; k < ts.length; k++){
const t = ts[k], prev = ts[k-1];
const structural = /^\s/.test(t) || /^\s/.test(prev) || /:$/.test(prev) || /^[-*•]\s/.test(t) || /^[A-Za-z][\w ]{0,24}:\s/.test(t) && !/[,(]$/.test(prev) && /[.;)]$/.test(prev);
out += structural ? '\n' + t : ' ' + t.trim();
}
return out;
}
function applyEditToSource(doc, eid, newText){
const def = doc.edits[eid]; if (!def) return null;
const L = doc.lines.slice();
if (def.kind === 'range'){
const line = L[def.i];
L[def.i] = line.slice(0, def.s) + newText.replace(/\s*\n\s*/g, ' ').trim() + line.slice(def.e);
} else {
const items = def.items;
const norm = s => s.replace(/\s+/g, ' ').trim();
const shown = blockDisplay(def, L);
if (norm(newText) === norm(shown) && newText.split('\n').length === shown.split('\n').length) return L.join('\n');
const first = items[0].prefix;
const lead0 = (first.match(/^\s*\/\//) || first.match(/^\s*/))[0];
const contPrefix = items.length > 1 ? items[1].prefix
: (/^\s*\/\//.test(first) ? lead0 + ' '.repeat(Math.max(2, first.length - lead0.length)) : first.match(/^\s*/)[0] + ' ');
const W = Math.max(118, Math.min(140, Math.max(...items.map(x => L[x.i].replace(/\t/g, ' ').length))));
const out = [];
newText.replace(/\s+$/, '').split('\n').forEach(par => {
const lead = par.match(/^\s*/)[0];
const words = par.trim().split(/\s+/).filter(Boolean);
let line = '';
const pre = () => (out.length === 0 ? first : contPrefix) + (out.length === 0 ? '' : lead);
if (!words.length){ out.push(pre().replace(/\s+$/, '')); return; }
words.forEach(w => {
const p = pre();
if (line && (p + line + ' ' + w).replace(/\t/g, ' ').length > W){ out.push((p + line).replace(/\s+$/, '')); line = w; }
else line = line ? line + ' ' + w : w;
});
if (line) out.push((pre() + line).replace(/\s+$/, ''));
});
L.splice(items[0].i, items.length, ...out);
}
return L.join('\n');
}
/* =====================================================================
STATE
===================================================================== */
const S = {
source: SEED, original: SEED, doc: null, view: 'plain', skim: false, focus: false, focusIdx: 0,
q: '', compare: [], cmpOpen: false, cmpMax: false, reviewed: {}, decisions: {}, collapsed: {}, undo: [], fs: 16,
changed: new Set()
};
const hashStr = s => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return (h >>> 0).toString(36); };
const specKey = () => 'specviz:spec:' + hashStr((S.doc && S.doc.title) || '');
function saveLocal(){
store.set('specviz:last', {source: S.source, original: S.original, view: S.view, fs: S.fs, fieldLayout: S.fieldLayout, modelLayout: S.modelLayout, skin: S.skin, author: S.author, flash: S.flash});
store.set(specKey(), {reviewed: S.reviewed, decisions: S.decisions, compare: S.compare});
}
function loadSpecPrefs(){
const p = store.get(specKey()) || {};
S.reviewed = p.reviewed || {}; S.decisions = p.decisions || {}; S.compare = p.compare || [];
}
function computeChanged(){
const counts = new Map();
S.original.replace(/\r\n?/g, '\n').split('\n').forEach(l => counts.set(l, (counts.get(l) || 0) + 1));
const ch = new Set();
S.doc.lines.forEach((l, i) => { const c = counts.get(l) || 0; if (c > 0) counts.set(l, c - 1); else if (l.trim()) ch.add(i); });
S.changed = ch;
}
/* =====================================================================
VIEW MODEL: sections -> nav entries -> cards
===================================================================== */
const KIND = {
model: {label: 'Data', color: 'var(--honey)'}, object: {label: 'App shape', color: 'var(--apricot)'},
endpoint: {label: 'Action', color: 'var(--olive)'}, rule: {label: 'Rule', color: 'var(--rust)'},
decision: {label: 'Decision', color: 'var(--marigold)'}, question: {label: 'Open question', color: 'var(--rose)'},
note: {label: 'Note', color: 'var(--cinnamon)'}, prose: {label: 'Background', color: 'var(--honey)'},
rate: {label: 'Limits', color: 'var(--cinnamon)'}, bullet: {label: 'List', color: 'var(--cinnamon)'},
screens: {label: 'Screens', color: 'var(--marigold)'}
};
const ENTRY_KIND = {endpoint: 'Actions', rule: 'Rules', decision: 'Decisions', question: 'Open questions', note: 'Notes', prose: 'Background', rate: 'Limits', bullet: 'List', model: 'Data', object: 'App shapes', screens: 'Screens'};
function buildView(doc){
const entries = []; let modelsDone = false, objectsDone = false;
const cardIndex = {};
const add = (entry) => { entry.cards.forEach(c => { c.entry = entry; cardIndex[c.key] = c; }); entries.push(entry); };
for (const s of doc.sections){
const cards = [];
const prose = s.items.filter(x => (x.t === 'prose' || x.t === 'def') && !(s.intro));
if (prose.length) cards.push({key: 'k-' + s.key + '-bg', kind: 'prose', title: s.name, items: prose, from: prose[0].line, to: prose[prose.length-1].end});
let qn = 0, nn = 0;
const rates = [];
for (const x of s.items){
if (x.t === 'endpoint') cards.push({key: x.key, kind: 'endpoint', x, from: x.line, to: x.end});
else if (x.t === 'coded') cards.push({key: x.key, kind: s.decision || /^V\d/.test(x.code) && s.decision ? 'decision' : (s.question ? 'question' : 'rule'), x, from: x.line, to: x.end});
else if (x.t === 'bullet') cards.push({key: 'q-' + s.key + '-' + (qn++), kind: s.question ? 'question' : 'bullet', x, from: x.line, to: x.end});
else if (x.t === 'labeled' && !s.intro) cards.push({key: 'n-' + s.key + '-' + (nn++), kind: 'note', x, from: x.line, to: x.end});
else if (x.t === 'rate') rates.push(x);
}
if (rates.length) cards.push({key: 'rate-' + s.key, kind: 'rate', rows: rates, from: rates[0].line, to: rates[rates.length-1].end});
if (cards.length){
const counts = {}; cards.forEach(c => counts[c.kind] = (counts[c.kind] || 0) + 1);
const main = Object.entries(counts).filter(([k]) => k !== 'prose' && k !== 'note').sort((a, b) => b[1] - a[1])[0];
const kind = main ? main[0] : (counts.prose ? 'prose' : 'note');
add({key: s.key, label: s.intro ? 'Contents' : s.name, kind, refs: s.refs, screenList: s.screenList || [], cards});
}
if (!modelsDone && s.items.some(x => x.t === 'model')){
modelsDone = true;
add({key: 'models', label: 'Data models', kind: 'model', cards: doc.models.map(m => ({key: m.key, kind: 'model', x: m, from: m.line, to: m.end}))});
}
if (!objectsDone && s.items.some(x => x.t === 'object')){
objectsDone = true;
add({key: 'objects', label: 'What the app receives', kind: 'object', cards: doc.objects.map(m => ({key: m.key, kind: 'object', x: m, from: m.line, to: m.end}))});
}
}
const scr = Object.keys(doc.screens);
if (scr.length) add({key: 'screens', label: 'Screen map', kind: 'screens', cards: [{key: 'screens-map', kind: 'screens', from: -1, to: -1}]});
const fieldIndex = {};
const addF = (list, key, label, where) => (list || []).forEach(f => { if (f.spread || !f.name) return; const arr = fieldIndex[f.name] = fieldIndex[f.name] || []; if (!arr.some(a => a.key === key)) arr.push({key, label, where}); });
doc.models.forEach(m => addF(m.fields, m.key, humanShape(m.name), 'Data model'));
doc.objects.forEach(o => addF(o.fields, o.key, humanShape(o.name) + ' (app)', 'What the app receives'));
doc.endpoints.forEach(e => { addF(e.accepts && e.accepts.fields, e.key, e.title, 'Sent to this action'); addF(e.returns && e.returns.fields, e.key, e.title, 'Comes back from this action'); });
return {entries, cardIndex, flat: entries.flatMap(e => e.cards), fieldIndex};
}
/* =====================================================================
RENDER HELPERS
===================================================================== */
let V = null; // current view model
const HUE = ['18', '32', '44', '340', '8', '58', '24', '350', '38', '70'];
const hueOf = s => HUE[parseInt(hashStr(s), 36) % HUE.length];
const isCode = s => /^[A-Z]{1,2}\d{1,3}$/.test(s);
function rich(text){
const doc = S.doc; let out = '', last = 0, m;
const re = /\[DEFAULT\s+([A-Z]{1,2}\d+)[^\]]{0,80}\]|`([^`]+)`|(#?\b[A-Z][A-Za-z0-9]+)\b/g;
while ((m = re.exec(text))){
out += esc(text.slice(last, m.index)); last = re.lastIndex;
const s = m[0];
if (m[1]){ out += `${esc(s)} `; continue; }
if (m[2] != null){ out += `${esc(s)}`; continue; }
const tok = m[3]; const bare = tok.replace(/^#/, '');
if (isCode(bare) && !tok.startsWith('#') && /^\.[a-z]/i.test(text.slice(re.lastIndex, re.lastIndex + 2))){ out += esc(tok); continue; }
if (isCode(bare) && !tok.startsWith('#')){
if (doc.coded[bare]) out += `${esc(tok)} `;
else if (doc.screens[bare]) out += `${esc(tok)} `;
else if (/^[A-Z]\d+$/.test(bare) && !/^W\d+$/.test(bare)) out += `${esc(tok)} `;
else out += esc(tok);
continue;
}
if (doc.objIdx[bare]) out += `${esc(tok)} `;
else if (doc.modelIdx[bare]) out += `${esc(tok)} `;
else out += esc(tok);
}
return out + esc(text.slice(last));
}
// an editable piece of text. Decorations never change the text itself, so editing round-trips.
function ed(eid, text, cls = '', ml = false, tag = 'span'){
const def = eid && S.doc.edits[eid];
if (def && def.kind === 'block') text = blockDisplay(def);
if (!eid) return `<${tag} class="${cls}">${rich(text || '')}${tag}>`;
return `<${tag} class="ed ${cls}" data-eid="${eid}" ${ml ? 'data-ml="1"' : ''} contenteditable="true" spellcheck="true" role="textbox" aria-label="Edit text">${rich(text || '')}${tag}>`;
}
function humanize(name){
let n = String(name || '').replace(/^_+/, '');
let q = '';
if (/^(is|has|can|should)_/.test(n)){ q = '?'; n = n.replace(/^(is|has|can|should)_/, m => m === 'has_' ? 'has_' : ''); }
n = n.replace(/_at$/, '').replace(/_id$/, ' ID').replace(/_ids$/, ' IDs').replace(/^id$/, 'ID');
n = n.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/_/g, ' ').trim().toLowerCase().replace(/\bid(s?)\b/g, 'ID$1').replace(/\btv\b/g, 'TV').replace(/\b2fa\b/g, '2FA');
return n.charAt(0).toUpperCase() + n.slice(1) + q;
}
function humanShape(name){
return String(name).replace(/^#/, '').replace(/Object$/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^(.)(.*)$/, (_, a, b) => a + b.toLowerCase());
}
function typeInfo(f){
if (f.children) return {label: (f.list ? 'List of groups' : 'Group') + ' · ' + f.children.filter(c => !c.spread).length, cls: 't-group'};
const b = (f.base || '').toLowerCase();
const list = f.list || f.listType;
let label, cls;
if (f.enumVals){ label = list ? 'Pick any' : 'Choice'; cls = 't-choice'; }
else if (b.startsWith('#') || S.doc.objIdx[f.base] || S.doc.modelIdx[f.base]){ label = (list ? 'List of ' : '') + humanShape(f.base.replace(/^#/, '')); cls = 't-shape'; }
else if (b === 'string' || b === 'text') { label = /(^|_)(id|ids)$/.test(f.name || '') ? 'ID' : (/email/.test(f.name || '') ? 'Email' : (/image|url|link/.test(f.name || '') ? 'Link' : 'Text')); cls = 't-text'; }
else if (b === 'number' || b === 'int' || b === 'integer' || b === 'float'){
if (/_at$/.test(f.name || '') || /\bms\b/.test(f.comment || '')){ label = 'Date & time'; cls = 't-date'; }
else if (/^(date|month|year)$/.test(f.name || '')){ label = 'Date part'; cls = 't-date'; }
else { label = f.mods && f.mods.isInteger ? 'Whole number' : 'Number'; cls = 't-num'; }
}
else if (b === 'boolean' || b === 'bool'){ label = 'Yes / No'; cls = 't-bool'; }
else if (!b){ label = 'Value'; cls = 't-text'; }
else { label = f.base; cls = 't-text'; }
if (list && !f.enumVals && cls !== 't-shape') label = 'List of ' + label.toLowerCase().replace(/^id$/, 'IDs');
return {label, cls};
}
function typePill(f){
if (f.children) return `${f.list ? 'List of groups' : 'Group'} `;
const b = (f.base || '').toLowerCase();
const list = f.list || f.listType;
let label, cls;
if (f.enumVals){ label = list ? 'Pick any' : 'One of'; cls = 't-choice'; }
else if (b.startsWith('#') || S.doc.objIdx[f.base] || S.doc.modelIdx[f.base]){
const nm = f.base.replace(/^#/, '');
const key = S.doc.objIdx[nm] ? 'o-' + nm : (S.doc.modelIdx[nm] ? 'm-' + nm : '');
return `${list ? 'List of ' : ''}${key ? `${esc(humanShape(nm))} ` : esc(humanShape(nm))} `;
}
else if (b === 'string' || b === 'text') { label = /(^|_)(id|ids)$/.test(f.name || '') ? 'ID' : (/email/.test(f.name || '') ? 'Email' : (/image|url|link/.test(f.name || '') ? 'Link' : 'Text')); cls = 't-text'; }
else if (b === 'number' || b === 'int' || b === 'integer' || b === 'float'){
if (/_at$/.test(f.name || '') || /\bms\b/.test(f.comment || '')){ label = 'Date & time'; cls = 't-date'; }
else if (/^(date|month|year)$/.test(f.name || '')){ label = 'Calendar number'; cls = 't-date'; }
else { label = f.mods && f.mods.isInteger ? 'Whole number' : 'Number'; cls = 't-num'; }
}
else if (b === 'boolean' || b === 'bool'){ label = 'Yes / No'; cls = 't-bool'; }
else if (!b){ label = 'Value'; cls = 't-text'; }
else { label = f.base; cls = 't-text'; }
if (list && !f.enumVals) label = 'List of ' + label.toLowerCase().replace(/^id$/, 'IDs').replace(/^text$/, 'text');
return `${esc(label)} `;
}
function badges(f, ctx){
const out = []; const md = f.mods || {};
if (ctx && ctx.accepts){ out.push(f.opt ? `Optional ` : `Required `); }
else if (f.opt) out.push(`Optional `);
if (md.unique) out.push(`Unique `);
if (md.uniqueWith) out.push(`One per ${esc(humanize(md.uniqueWith).toLowerCase())} `);
if (md.default != null && md.default !== true) out.push(`Starts as: ${esc(md.default)} `);
const rng = (a, b, u) => a != null && b != null ? `${a}–${b}${u}` : a != null ? `at least ${a}${u}` : `up to ${b}${u}`;
if (md.min != null || md.max != null) out.push(`${esc(rng(md.min, md.max, ''))} `);
if (md.minLength != null || md.maxLength != null) out.push(`${esc(rng(md.minLength, md.maxLength, ' characters'))} `);
const c = f.comment || '';
if (/IMMUTABLE|never changed|can't change|cannot change/i.test(c)) out.push(`Can't change later `);
const om = c.match(/^([a-z][\w ]{1,24}?) only\b/i); if (om) out.push(`${esc(om[1])} only `);
(c.match(/\[DEFAULT\s+[A-Z]{1,2}\d+[^\]]{0,80}\]/g) || []).forEach(d => { const code = d.match(/([A-Z]{1,2}\d+)/)[1]; out.push(`Needs decision ${code} `); });
const tech = [];
['indexed', 'sparse', 'trim', 'lowercase', 'isInteger'].forEach(k => { if (md[k]) tech.push(k); });
if (tech.length) out.push(`${esc(tech.join(' · '))} `);
return out.join('');
}
function renderEnum(f){
if (!f.enumVals) return '';
const docs = {}; f.enumDocs.forEach(d => docs[d.value] = d);
if (f.enumDocs.length){
return `${f.enumVals.map(v => `
${esc(v)} ${docs[v] ? ed(docs[v].eid, docs[v].text) : 'No description '}
`).join('')}
`;
}
return `${f.enumVals.map(v => `${esc(v)} `).join('')}
`;
}
function renderFields(fields, ctx = {}){
if (!fields || !fields.length) return `Nothing listed.
`;
return `${fields.map(f => renderField(f, ctx)).join('')}
`;
}
function renderField(f, ctx){
if (f.spread){
const nm = f.spread.replace(/^#/, '');
const key = S.doc.objIdx[nm] ? 'o-' + nm : (S.doc.modelIdx[nm] ? 'm-' + nm : null);
if (key) return `Everything in ${esc(humanShape(nm))} , plus:
`;
return `Includes the shared ${esc(nm)} fields (...${esc(nm)}) (ID, created, updated and so on)
`;
}
const rawName = `${f.nameEid ? ed(f.nameEid, f.name, 'mono') : esc(f.name)}${f.list ? '[]' : ''}${f.opt ? '?' : ''}`;
const typeRaw = f.type ? (f.typeEid ? ed(f.typeEid, f.type, 'mono') : esc(f.type)) : '';
return `
${esc(humanize(f.name))}
${rawName}
${typePill(f)}
${typeRaw}
${badges(f, ctx)}
${f.comment != null ? `
${ed(f.commentEid, f.comment)}
` : ''}
${f.extra.map(x => ``).join('')}
${renderEnum(f)}
${f.children ? `
${renderFields(f.children, {accepts: ctx.accepts})}
` : ''}
`;
}
const VERB = {GET: 'Look up', POST: 'Create', PATCH: 'Change', PUT: 'Replace', DELETE: 'Remove', HEAD: 'Check', OPTIONS: 'Options'};
function whoText(a){
let m;
if (/^no auth/i.test(a)) return 'Anyone, no sign-in';
if (/^optional user auth/i.test(a)) return 'Anyone (sign-in optional)';
if ((m = a.match(/^user auth\s*(?::\s*(.*))?$/i))) return m[1] ? 'Signed in: ' + m[1] : 'Any signed-in person';
return a;
}
const grip = ` Drag `;
function scrChips(list, max = 8){
const u = [...new Set(list)].sort(scrSort);
const shown = u.slice(0, max), rest = u.slice(max);
return 'Screens ' + shown.map(s => `${s} `).join(' ')
+ (rest.length ? ` +${rest.length} more ` : '') + ' ';
}
function scrSort(a, b){
const ka = a.startsWith('W') ? 1000 + parseInt(a.slice(1)) : parseFloat(a) + (a.endsWith('b') ? .5 : 0);
const kb = b.startsWith('W') ? 1000 + parseInt(b.slice(1)) : parseFloat(b) + (b.endsWith('b') ? .5 : 0);
return ka - kb;
}
function isEdited(card){
if (card.from == null || card.from < 0) return false;
for (const i of S.changed) if (i >= card.from && i <= card.to) return true;
return false;
}
/* ----- card bodies ----- */
function cardHead(card, title, sub, extraActs = ''){
const inCmp = S.compare.includes(card.key);
const rev = !!S.reviewed[card.key];
return ``;
}
/* ---------- calm view ---------- */
const ICON = {
cmp: ' ',
check: ' '
};
const gripIcon = ` `;
// strip design-screen numbers, spec codes in brackets and [DEFAULT] markers so the sentence reads plainly
function calmText(s){
if (!s) return '';
let x = String(s);
if (leadingScreens(x).length) x = x.replace(/^\s*(?:W\d{1,2}|\d{2}b?)(?:\s*[,/]\s*(?:W\d{1,2}|\d{2}b?))*\s*(?:"[^"]*")?[.,:;]?\s*/, '');
x = x.replace(/\[DEFAULT\s+[A-Z]{1,2}\d+[^\]]{0,80}\]/g, '');
for (let k = 0; k < 3; k++) x = x.replace(/\s*\(([^()]*)\)/g, (m, inner) => (screensIn(inner).length || /^\s*(?:[A-Z]{1,2}\d+)(?:\s*[,/]\s*[A-Z]{1,2}\d+)*\s*$/.test(inner) || /^\s*design\b/i.test(inner)) ? '' : m);
x = x.replace(/\bIMMUTABLE\b/g, "Can't be changed").replace(/^ms\b[,.]?\s*/i, '');
x = x.replace(/[ \t]{2,}/g, ' ').replace(/\s+([.,;:])/g, '$1').replace(/^[\s.,;:/-]+/, '').replace(/[\s,;:]+$/, '').trim();
return x ? x.charAt(0).toUpperCase() + x.slice(1) : '';
}
function whoShort(a){
if (!a) return '';
if (/^no auth/i.test(a)) return 'Anyone';
if (/^optional user auth/i.test(a)) return 'Anyone';
const m = a.match(/^user auth\s*(?::\s*(.*))?$/i);
if (!m) return a;
if (!m[1]) return 'Signed in';
let w = m[1].split(',')[0].trim();
if (/^host$/i.test(w)) return 'Host only';
if (/^member$/i.test(w)) return 'Members';
if (/^R\d+$/.test(w)) return 'Some members';
return w.charAt(0).toUpperCase() + w.slice(1);
}
function fieldButtons(list, owner, part, prefix = ''){
const fs = (list || []).filter(f => !f.spread && f.name);
if (!fs.length) return '';
return `${fs.map(f => {
const path = (prefix ? prefix + '.' : '') + f.name;
const ti = typeInfo(f);
const dec = /\[DEFAULT/.test(f.comment || '') || f.extra.some(e => /\[DEFAULT/.test(e.text));
const fp = owner + '|' + part + '|' + path;
const on = S.sheet && S.sheet.fp === fp ? ' on' : '';
return `${esc(humanize(f.name))}${part === 'accepts' && !f.opt ? '* ' : ''} ${dec ? ' ' : ''}${esc(ti.label)} `;
}).join('')}
`;
}
function shapeButton(name, list){
const key = S.doc.objIdx[name] ? 'o-' + name : (S.doc.modelIdx[name] ? 'm-' + name : null);
if (!key) return `${esc(humanShape(name))} `;
const target = S.doc.objIdx[name] || S.doc.modelIdx[name];
const n = target.fields.filter(f => !f.spread).length;
return `${list ? 'A list of ' : ''}${esc(humanShape(name))} ${n} fields `;
}
function calmHead(card, inner){
const inCmp = S.compare.includes(card.key), rev = !!S.reviewed[card.key];
return ``;
}
function calmCardHTML(card, opt){
const x = card.x, k = card.kind;
const id = `${opt.inCompare ? 'cmp-' : ''}c-${esc(card.key)}`;
const base = `card calm k-${k}${S.reviewed[card.key] ? ' reviewed' : ''}`;
if (k === 'model' || k === 'object'){
const fs = x.fields.filter(f => !f.spread);
const desc = calmText(x.note);
const head = calmHead(card, `
${esc(humanShape(x.name))}${fs.length} fields `);
return `${head}${desc ? `
${rich(desc)}
` : ''}${fieldButtons(x.fields, card.key, 'fields')}
`;
}
// endpoint: one line, opens one at a time
const open = !!(opt.focus || opt.inCompare || S.calmOpen === card.key);
const who = whoShort(x.auth[0] || '');
const row = `${VERB[x.method] || x.method} ${esc(x.title)} ${who ? `${esc(who)} ` : ''}${open ? '▾' : '▸'} `;
let body = '';
if (open){
const parts = [];
const send = x.accepts ? fieldButtons(x.accepts.fields, card.key, 'accepts') : '';
const anyReq = x.accepts && x.accepts.fields.some(f => !f.spread && !f.opt);
parts.push(`You send ${anyReq ? '* must be filled in ' : ''}
${send || '
Nothing to fill in.
'}
`);
if (x.returns){
const r = x.returns; let rh = '';
if (r.ref) rh = shapeButton(r.ref, r.list);
else if (r.fields){
const spreads = r.fields.filter(f => f.spread && (S.doc.objIdx[f.spread.replace('#', '')] || S.doc.modelIdx[f.spread.replace('#', '')]));
rh = (spreads.length ? `${spreads.map(s => shapeButton(s.spread.replace('#', ''), false)).join('')}plus:
` : '') + fieldButtons(r.fields, card.key, 'returns');
if (r.list) rh = `A list, each with:
` + rh;
} else rh = `${rich(calmText(r.text))}
`;
parts.push(``);
}
const rules = x.rules.filter(r => r.kind !== 'sub');
if (rules.length){
parts.push(`Rules to know (${rules.length}) ${x.rules.map(r => r.kind === 'sub' ? `${esc(r.text)} ` : `${rich(calmText(blockDisplay(S.doc.edits[r.eid]).replace(/\n/g, ' ')))} `).join('')} `);
}
body = `${parts.join('')}
`;
}
return `${calmHead(card, row)}${body} `;
}
/* ---------- field details panel ---------- */
function resolveFP(fp){
const [owner, part, path = ''] = fp.split('|');
const c = V.cardIndex[owner]; if (!c || !c.x) return null;
const list = part === 'fields' ? c.x.fields : part === 'accepts' ? (c.x.accepts || {}).fields : (c.x.returns || {}).fields;
if (!list) return null;
const names = path ? path.split('.') : [];
let parent = list, field = null; const chain = [];
for (let i = 0; i < names.length; i++){
field = (parent || []).find(f => !f.spread && f.name === names[i]);
if (!field) return null;
chain.push(field);
if (i < names.length - 1) parent = field.children;
}
return {owner, part, names, card: c, field, parentList: names.length ? parent : null, rootList: list, chain};
}
function ownerLabel(r){
const x = r.card.x;
if (r.card.kind === 'endpoint') return x.title + (r.part === 'accepts' ? ': you send' : ': you get back');
return humanShape(x.name);
}
function factsFor(f, part){
const out = []; const md = f.mods || {}; const c = (f.comment || '') + ' ' + f.extra.map(e => e.text).join(' ');
if (part === 'accepts') out.push(f.opt ? 'Optional. You can leave it out.' : 'Required. It has to be filled in.');
else if (f.opt) out.push('Optional. It may be missing.');
if (f.list || f.listType) out.push('Can hold more than one.');
if (md.unique) out.push('No two records can share the same value.');
if (md.uniqueWith) out.push('Only one per ' + humanize(md.uniqueWith).toLowerCase() + '.');
if (md.default != null && md.default !== true) out.push('Starts as “' + md.default + '”.');
if (md.min != null && md.max != null) out.push(`Must be between ${md.min} and ${md.max}.`);
else if (md.min != null) out.push(`Must be at least ${md.min}.`);
else if (md.max != null) out.push(`Can be at most ${md.max}.`);
if (md.minLength != null || md.maxLength != null){
if (md.minLength != null && md.maxLength != null) out.push(md.minLength === md.maxLength ? `Exactly ${md.minLength} characters.` : `${md.minLength} to ${md.maxLength} characters.`);
else if (md.maxLength != null) out.push(`Up to ${md.maxLength} characters.`);
else out.push(`At least ${md.minLength} characters.`);
}
if (md.isInteger) out.push('Whole numbers only.');
if (md.lowercase && md.trim) out.push('Saved in lowercase, with extra spaces removed.');
else if (md.lowercase) out.push('Saved in lowercase.');
else if (md.trim) out.push('Extra spaces are removed.');
if (/IMMUTABLE|never changed|can't change|cannot change/i.test(c)) out.push("Can't be changed once it's created.");
const om = (f.comment || '').match(/^([a-z][\w ]{1,24}?) only\b/i); if (om) out.push('Only applies to: ' + om[1] + '.');
return out;
}
function sheetHTML(r){
const crumbs = [`${esc(ownerLabel(r))} `];
r.chain.slice(0, -1).forEach((f, i) => crumbs.push(`› ${esc(humanize(f.name))} `));
const top = `${r.field ? crumbs.join('') : '' + esc(r.card.kind === 'endpoint' ? 'Action' : r.card.kind === 'object' ? 'What the app receives' : 'Data model') + ' '}
× `;
if (!r.field){
const x = r.card.x; const fs = r.rootList.filter(f => !f.spread);
const desc = r.card.kind === 'endpoint' ? '' : calmText(x.note);
return top + `
${esc(r.card.kind === 'endpoint' ? x.title : humanShape(x.name))} ${fs.length} fields
${desc ? `${rich(desc)}
` : ''}
Fields, click one to see more
${fieldButtons(r.rootList, r.owner, r.part, '')}
Go to the full card
`;
}
const f = r.field; const ti = typeInfo(f);
const allText = [f.comment || '', ...f.extra.map(e => e.text)].join(' ');
let desc = [calmText(f.comment || ''), ...f.extra.map(e => calmText(e.text))].filter(Boolean).join(' ');
// an action's field often has no note of its own: borrow the meaning from the data model that stores it
let borrowed = null, enumDocs = f.enumDocs;
if (!desc || (f.enumVals && !f.enumDocs.length)){
for (const m of [...S.doc.models, ...S.doc.objects]){
if (m.key === r.owner) continue;
const g = m.fields.find(x => x.name === f.name && !x.spread && (calmText(x.comment || '') || x.enumDocs.length));
if (g){ borrowed = {m, g}; break; }
}
if (borrowed){
if (!desc) desc = calmText(borrowed.g.comment || '');
if (f.enumVals && !f.enumDocs.length) enumDocs = borrowed.g.enumDocs;
}
}
let h = top + `
${esc(humanize(f.name))} ${esc(ti.label)} ${esc(f.name)} `;
h += desc ? `${rich(desc)}
` : (enumDocs.length || f.children ? '' : `The spec doesn't describe this one.
`);
if (borrowed) h += `Meaning taken from ${esc(humanShape(borrowed.m.name))}
`;
if (f.enumVals){
const docs = {}; enumDocs.forEach(d => docs[d.value] = d);
h += `The choices
${f.enumVals.map(v => `
${esc(v)} ${docs[v] ? rich(calmText(docs[v].text)) : ''}
`).join('')}
`;
}
const facts = factsFor(f, r.part);
if (facts.length) h += `Good to know
${facts.map(x => `${esc(x)} `).join('')} `;
const decs = [...new Set((allText.match(/\[DEFAULT\s+([A-Z]{1,2}\d+)/g) || []).map(d => d.split(/\s+/)[1]))];
decs.forEach(code => {
const it = S.doc.coded[code]; if (!it) return;
const st = {ok: 'You confirmed this.', change: 'You marked this to change.', unsure: 'You marked this as not sure.'}[S.decisions[code]] || 'Not decided yet.';
h += `Needs a decision (${esc(code)})
${rich(it.text.replace(/\s+/g, ' '))}
${st} Go to the decision
`;
});
const rules = [...new Set((allText.match(/\bR\d+\b/g) || []))].filter(c => S.doc.coded[c]);
rules.forEach(code => { const it = S.doc.coded[code]; h += `Related rule ${esc(code)}${it.title ? ': ' + esc(it.title) : ''}
${rich(it.text.replace(/\s+/g, ' ').slice(0, 220))}${it.text.length > 220 ? '…' : ''}
Read the whole rule
`; });
if (f.children){
h += `Inside this group
${fieldButtons(f.children, r.owner, r.part, r.names.join('.'))}
`;
} else {
const nm = (f.base || '').replace(/^#/, '');
if (S.doc.objIdx[nm] || S.doc.modelIdx[nm]) h += `What's inside
${shapeButton(nm, f.list || f.listType)}
`;
}
const also = (V.fieldIndex[f.name] || []).filter(a => a.key !== r.owner).slice(0, 6);
if (also.length) h += `Also in: ${also.map(a => `${esc(a.label)} `).join('')}
`;
if (f.commentEid || f.extra.length){
h += `Edit the note in the spec ${f.commentEid ? ed(f.commentEid, f.comment, '', false, 'div') : ''}${f.extra.map(e => ed(e.eid, e.text, '', false, 'div')).join('')}
`;
}
const sib = (r.parentList || []).filter(x => !x.spread && x.name);
const idx = sib.indexOf(f);
if (sib.length > 1) h += `↑ Previous ${idx + 1} of ${sib.length} · use ↑ ↓ keys = sib.length - 1 ? 'disabled' : ''}>Next ↓
`;
return h;
}
function renderSheet(anchor){
const sh = $('#sheet');
const r = S.sheet && resolveFP(S.sheet.fp);
if (!r){ closeSheet(); return; }
const keepOpen = $$('details[open]', sh).length > 0;
sh.innerHTML = sheetHTML(r);
if (keepOpen){ const d = $('details.peek', sh); if (d) d.open = true; }
sh.hidden = false;
$$('.fbtn.on').forEach(b => b.classList.remove('on'));
$$('.fbtn[data-fp="' + CSS.escape(S.sheet.fp) + '"]').forEach(b => { if (!sh.contains(b)) b.classList.add('on'); });
if (window.innerWidth <= 700){ sh.style.left = ''; sh.style.top = ''; return; }
if (anchor){
const a = anchor.getBoundingClientRect(); const w = sh.offsetWidth, h = sh.offsetHeight;
let x = a.right + 12;
if (x + w > window.innerWidth - 12) x = a.left - w - 12;
let y = Math.max(76, Math.min(a.top - 12, window.innerHeight - h - 12));
if (x < 12){
x = window.innerWidth - w - 12;
if (x < a.left + 120){ x = Math.max(12, Math.min(window.innerWidth - w - 12, a.left)); y = a.bottom + 8; if (y + h > window.innerHeight - 12) y = Math.max(76, a.top - h - 8); }
}
sh.style.left = x + 'px'; sh.style.top = y + 'px';
} else {
const top = parseFloat(sh.style.top) || 80;
const h = sh.offsetHeight;
if (top + h > window.innerHeight - 12) sh.style.top = Math.max(76, window.innerHeight - h - 12) + 'px';
}
}
function openSheet(fp, anchor){
const inSheet = anchor && $('#sheet').contains(anchor);
S.sheet = {fp};
renderSheet(inSheet ? null : anchor);
}
function closeSheet(){ S.sheet = null; const sh = $('#sheet'); sh.hidden = true; $$('.fbtn.on').forEach(b => b.classList.remove('on')); }
function sheetStep(d){
const r = S.sheet && resolveFP(S.sheet.fp); if (!r || !r.field) return;
const sib = (r.parentList || []).filter(x => !x.spread && x.name);
const i = sib.indexOf(r.field) + d; if (i < 0 || i >= sib.length) return;
S.sheet.fp = r.owner + '|' + r.part + '|' + r.names.slice(0, -1).concat(sib[i].name).join('.');
renderSheet();
}
function setView(v){ pageFlash(); S.view = v; closeSheet(); saveLocal(); renderAll(); }
function cardHTML(card, opt = {}){
if (S.view === 'calm' && (card.kind === 'model' || card.kind === 'object' || card.kind === 'endpoint')) return calmCardHTML(card, opt);
const k = card.kind; let head = '', body = '', summary = '', cls = 'k-' + k;
const x = card.x;
if (k === 'model' || k === 'object'){
const nf = (x.fields || []).filter(f => !f.spread).length;
const nEnum = (x.fields || []).filter(f => f.enumVals).length;
head = cardHead(card,
`${esc(humanShape(x.name))} ${k === 'object' ? '#' : ''}${esc(x.name)} `,
`${KIND[k].label} ${x.note != null ? `${ed(x.noteEid, x.note)} ` : ''}${esc(x.name)} ${x.screens.size ? scrChips([...x.screens]) : ''}`);
summary = `${nf} fields${nEnum ? ` · ${nEnum} with set choices` : ''}${x.indexes.length ? ` · ${x.indexes.length} lookups` : ''}`;
body = renderFields(x.fields);
const notes = x.notes.filter(n => n.text);
if (notes.length) body += `Notes
${notes.map(n => `
${n.eid ? ed(n.eid, n.text) : esc(n.text)}
`).join('')}
`;
if (x.indexes.length) body += `Fast lookups (indexes)
${x.indexes.map(ix => `${esc(ix.spec)} ${rich(ix.why)} `).join('')}
`;
}
else if (k === 'endpoint'){
cls += ' m-' + x.method;
const who = x.auth.length ? x.auth.map(a => `${rich(whoText(a))} `).join('') : '';
const lim = x.rate.map(r => `Limited: ${esc(S.doc.rateIdx[r] ? S.doc.rateIdx[r].limit.replace(/\s*\[DEFAULT.*$/, '') : r)} `).join('');
head = cardHead(card,
`${x.num ? `${esc(x.num)} ` : ''}${ed(x.titleEid, x.title)} `,
`${VERB[x.method] || x.method} ${esc(x.method)} ${esc(x.path)} ${who}${lim}${x.screens.size ? scrChips([...x.screens]) : ''}`);
const nIn = x.accepts ? x.accepts.fields.length : 0;
summary = [x.auth.length ? whoText(x.auth[0]) : '', nIn ? `${nIn} things to send` : 'nothing to send', x.rules.filter(r => r.kind !== 'sub').length + ' rules',
x.returns ? 'gives back ' + (x.returns.ref ? humanShape(x.returns.ref) : x.returns.fields ? x.returns.fields.length + ' fields' : 'a result') : ''].filter(Boolean).join(' · ');
const parts = [];
if (x.notes.length) parts.push(`${x.notes.map(n => `
${rich(n.text)}
`).join('')}
`);
if (x.accepts) parts.push(`What you send
${renderFields(x.accepts.fields, {accepts: true})}
`);
if (x.rules.length) parts.push(`Rules
${x.rules.map(r => r.kind === 'sub' ? `${esc(r.text)} ` : `${ed(r.eid, r.text, 'pre', true)} `).join('')} `);
if (x.returns){
const r = x.returns; let rh = '';
if (r.ref){
const key = S.doc.objIdx[r.ref] ? 'o-' + r.ref : (S.doc.modelIdx[r.ref] ? 'm-' + r.ref : '');
const target = S.doc.objIdx[r.ref] || S.doc.modelIdx[r.ref];
rh = `${r.list ? 'A list of' : 'One'} ${key ? `${esc(humanShape(r.ref))} ` : `${esc(r.ref)} `}
`;
if (target) rh += `Show what's inside ${renderFields(target.fields)} `;
} else if (r.fields){ rh = (r.list ? `A list, each with:
` : '') + renderFields(r.fields); }
else rh = `${rich(r.text)}
`;
parts.push(``);
}
body = parts.join('');
}
else if (k === 'rule' || k === 'decision' || k === 'question'){
const title = x.title ? `${esc(x.title)} ` : '';
head = cardHead(card,
`${esc(x.code || ('#' + (Number(card.key.split('-').pop()) + 1)))} ${x.title ? esc(x.title) : (k === 'decision' ? 'Default to confirm' : k === 'question' ? 'Open question' : 'Rule')} `,
`${KIND[k].label} ${x.comments && x.comments.length ? `${x.comments.length} comment${x.comments.length > 1 ? 's' : ''} ` : ''}${x.screens && x.screens.size ? scrChips([...x.screens]) : ''}`);
const txt = x.title && x.text.startsWith(x.title) ? ed(x.eid, x.text, 'rule-text', true, 'div').replace(rich(x.title), title) : ed(x.eid, x.text, 'rule-text', true, 'div');
body = txt;
summary = esc(x.text.replace(/\s+/g, ' ').slice(0, 140)) + (x.text.length > 140 ? '…' : '');
if (k === 'decision'){
const v = S.decisions[x.code] || '';
body += `
Confirm
Change it
Not sure
`;
const uses = findUses(x.code);
if (uses.length) body += `Used in: ${uses.map(u => `${esc(u.label)} `).join(', ')}
`;
}
if (k === 'decision' || (x.comments && x.comments.length)) body += commentsHTML(x, k === 'decision');
if (x.comments && x.comments.length) summary += ` · ${x.comments.length} comment${x.comments.length > 1 ? 's' : ''}`;
}
else if (k === 'bullet'){
head = cardHead(card, `${esc(x.text.split('\n')[0].slice(0, 70))}${x.text.length > 70 ? '…' : ''} `, `${esc(card.entry ? card.entry.label : 'List')} `);
body = ed(x.eid, x.text, 'rule-text', true, 'div');
summary = esc(x.text.slice(0, 140));
}
else if (k === 'question'){ /* handled above */ }
else if (k === 'note'){
head = cardHead(card, `${esc(x.label)} `, `${esc(card.entry ? card.entry.label : 'Note')} ${x.screens.size ? scrChips([...x.screens]) : ''}`);
body = ed(x.eid, x.text, 'rule-text', true, 'div');
summary = esc(x.text.replace(/\s+/g, ' ').slice(0, 140)) + '…';
}
else if (k === 'prose'){
head = cardHead(card, `${esc(card.title)} `, `Background The big idea, in the spec's own words `);
const paras = []; let defs = [];
const flush = () => { if (defs.length){ paras.push(`${defs.map(d => `
${esc(d.term)}
${ed(d.eid, d.text, 'prose-p', true, 'div')}
`).join('')}
`); defs = []; } };
card.items.forEach(it => { if (it.t === 'def') defs.push(it); else { flush(); paras.push(ed(it.eid, it.text, 'prose-p lead', true, 'div')); } });
flush();
body = paras.join('');
summary = card.items.length + ' parts';
}
else if (k === 'rate'){
head = cardHead(card, `How often people can do things `, `Limits Stops spam and abuse `);
body = `Action Where Limit ${card.rows.map(r => {
const ep = S.doc.endpoints.find(e => e.method === r.method && e.path === r.path);
return `${esc(r.name)} ${ep ? `${esc(ep.title)}
` : ''}${VERB[r.method]} ${esc(r.path)} ${ed(r.eid, r.limit)} `; }).join('')}
`;
summary = card.rows.length + ' limits';
}
else if (k === 'screens'){
head = cardHead(card, `Every design screen and where the spec mentions it `, `For designers Click a name to jump to it `);
const ids = Object.keys(S.doc.screens).sort(scrSort);
body = `${ids.map(id => `
${esc(id)}
${[...S.doc.screens[id]].map(c => `${esc(cardLabel(c))} `).join('')} `).join('')}
`;
summary = ids.length + ' screens';
}
const collapsed = (S.skim && !opt.inCompare && !opt.focus) ? S.collapsed[card.key] !== false : !!S.collapsed[card.key];
return `${head}${summary}
${body}
`;
}
const STATUS_LABEL = {ok: 'Confirmed', change: 'Change it', unsure: 'Not sure'};
function commentsHTML(x, composer){
const list = (x.comments || []).map(c => `
${esc(c.author || 'Comment')} ${c.date ? `${esc(c.date)} ` : ''}${c.status ? `${esc(c.status)} ` : ''}Remove
${ed(c.eid, c.text, '', false, 'div')}
`).join('');
const st = STATUS_LABEL[S.decisions[x.code]];
const form = composer ? `` : '';
if (!list && !form) return '';
return `Comments${x.comments && x.comments.length ? ' (' + x.comments.length + ')' : ''}
${list}${form}
`;
}
function addComment(code){
const it = S.doc.coded[code]; if (!it) return;
const ta = document.getElementById('cmt-' + code);
const txt = (ta ? ta.value : '').replace(/\s*\n\s*/g, ' ').trim();
if (!txt){ toast('Write something first.'); if (ta) ta.focus(); return; }
const author = (S.author || '').replace(/[()\[\]:>]/g, '').trim();
const date = new Date().toLocaleDateString('en-GB', {day: 'numeric', month: 'short', year: 'numeric'}).replace(/\./g, '');
const st = STATUS_LABEL[S.decisions[code]];
const lead = (it.firstPrefix.match(/^\s*\/\//) || [''])[0];
const pre = it.contPrefix || (lead ? lead + ' '.repeat(Math.max(2, it.firstPrefix.length - lead.length)) : ' ');
const line = `${pre}> ${author ? author + ' ' : ''}(${date})${st ? ' [' + st + ']' : ''}: ${txt}`;
const L = S.doc.lines.slice(); L.splice(it.end + 1, 0, line);
S.undo.push(S.source); S.source = L.join('\n'); S.doc = parse(S.source);
if (S.drafts) delete S.drafts[code];
renderAll(); saveLocal();
toast('Comment added to the spec', {label: 'Undo', fn: undo});
}
function removeComment(lineNo){
const L = S.doc.lines.slice();
if (!/^\s*\/\/\s*>/.test(L[lineNo] || '')) return;
L.splice(lineNo, 1);
S.undo.push(S.source); S.source = L.join('\n'); S.doc = parse(S.source);
renderAll(); saveLocal();
toast('Comment removed', {label: 'Undo', fn: undo});
}
function cardLabel(c){
if (c.t === 'endpoint') return (c.num ? c.num + ' · ' : '') + c.title;
if (c.t === 'model' || c.t === 'object') return humanShape(c.name);
if (c.t === 'coded') return c.code + (c.title ? ' · ' + c.title : '');
if (c.t === 'labeled') return c.label;
if (c.t === 'bullet') return c.text.slice(0, 40) + '…';
return c.key || '';
}
function findUses(code){
const re = new RegExp('\\b' + code + '\\b');
const out = [];
for (const c of V.flat){
if (c.kind === 'decision' && c.x && c.x.code === code) continue;
if (c.kind === 'screens') continue;
const from = c.from, to = c.to; if (from == null || from < 0) continue;
for (let i = from; i <= to; i++){ if (re.test(S.doc.lines[i])){ out.push({key: c.key, label: c.x ? cardLabel(c.x) : c.title || c.key}); break; } }
}
return out;
}
/* =====================================================================
PAGE RENDER
===================================================================== */
function sectionCounts(e){
const total = e.cards.length; const done = e.cards.filter(c => S.reviewed[c.key]).length;
return {total, done};
}
function renderNav(){
const groups = [{label: 'Start', entries: [{key: 'overview', label: 'Overview', kind: 'prose', cards: []}]}];
let curG = null;
for (const e of V.entries){
const g = ENTRY_KIND[e.kind] && ['model', 'object', 'prose'].includes(e.kind) ? 'The pieces' : e.kind === 'endpoint' ? 'What people can do' : e.kind === 'screens' ? 'Design' : 'Rules & loose ends';
if (!curG || curG.label !== g){ curG = {label: g, entries: []}; groups.push(curG); }
curG.entries.push(e);
}
const html = groups.map(g => `${esc(g.label)}
` + g.entries.map(e => {
const c = sectionCounts(e);
const edited = e.cards.some(isEdited);
return `${esc(e.label)} ${edited ? ' ' : ''}${c.total ? `${c.done ? c.done + '/' : ''}${c.total} ` : ''} `;
}).join('')).join('');
$('#side').innerHTML = html + `Tips Too busy? Pick Calm at the top. Click any text with a soft highlight to edit it. Grab Drag on a card to put it in the compare tray./ search · F focus · ← → next card in focus
`;
markActiveNav();
}
function overviewHTML(){
const d = S.doc;
const meta = d.sections[0].items.filter(x => x.t === 'labeled' || x.t === 'prose' || x.t === 'def');
const decisions = V.flat.filter(c => c.kind === 'decision');
const decDone = decisions.filter(c => S.decisions[c.x.code]).length;
const questions = V.flat.filter(c => c.kind === 'question');
const rules = V.flat.filter(c => c.kind === 'rule');
const tile = (n, label, color, nav) => `${n} ${label} `;
const firstOf = kind => (V.entries.find(e => e.kind === kind) || {}).key;
const total = V.flat.length, done = V.flat.filter(c => S.reviewed[c.key]).length;
const metaHTML = meta.map(m => {
if (m.t === 'labeled' && /out of scope|not in scope|later|excluded/i.test(m.label)){
const items = splitTop(m.text.replace(/\n\s*/g, ' ')).map(s => s.replace(/\.$/, ''));
return `${esc(m.label)} ${items.map(s => `${rich(s)} `).join('')} Edit this text ${ed(m.eid, m.text, 'pre', true, 'div')}
`;
}
if (m.t === 'labeled') return `${esc(m.label)} ${ed(m.eid, m.text, 'pre', true)} `;
if (m.t === 'def') return `${esc(m.term)} ${ed(m.eid, m.text, 'pre', true)} `;
return `Note ${ed(m.eid, m.text, 'pre', true)} `;
}).join('');
const steps = [];
const bg = V.entries.find(e => e.kind === 'prose'); if (bg) steps.push({nav: bg.key, t: 'Get the big idea', s: bg.label});
if (firstOf('model')) steps.push({nav: 'models', t: 'Meet the pieces', s: d.models.length + ' things the system stores'});
if (firstOf('endpoint')) steps.push({nav: firstOf('endpoint'), t: 'See what people can do', s: d.endpoints.length + ' actions'});
if (firstOf('decision')) steps.push({nav: firstOf('decision'), t: 'Make the calls', s: `${decisions.length - decDone} decisions waiting`});
if (firstOf('question')) steps.push({nav: firstOf('question'), t: 'Look at loose ends', s: questions.length + ' open questions'});
return `
Overview
${ed(d.titleEid, d.title)}${d.version ? `${esc(d.version)} ` : ''}
${d.warnings.length ? `${d.warnings.map(esc).join(' ')}
` : ''}
${d.models.length ? tile(d.models.length, 'Data models', 'var(--honey)', 'models') : ''}
${d.objects.length ? tile(d.objects.length, 'App shapes', 'var(--apricot)', 'objects') : ''}
${d.endpoints.length ? tile(d.endpoints.length, 'Actions', 'var(--olive)', firstOf('endpoint')) : ''}
${rules.length ? tile(rules.length, 'Rules', 'var(--rust)', firstOf('rule')) : ''}
${decisions.length ? tile(`${decDone}/${decisions.length} `, 'Decisions made', 'var(--marigold)', firstOf('decision')) : ''}
${questions.length ? tile(questions.length, 'Open questions', 'var(--rose)', firstOf('question')) : ''}
${Object.keys(d.screens).length ? tile(Object.keys(d.screens).length, 'Screens mentioned', 'var(--marigold)', 'screens') : ''}
Your review ${done} of ${total} cards
${S.view !== 'calm' && (d.models.length || d.endpoints.length) ? `Too much at once? Calm view shows each data model and action as a short list of fields. Click a field to see what it means.Switch to Calm
` : ''}
${steps.length ? `A good order to read it
${steps.map((s, i) => `${i + 1} ${esc(s.t)} ${esc(s.s)} `).join('')}
` : ''}
${metaHTML ? `${metaHTML} ` : ''}
`;
}
function matches(card){
if (!S.q) return true;
const q = S.q.toLowerCase();
if (card.from != null && card.from >= 0){
for (let i = card.from; i <= card.to; i++) if ((S.doc.lines[i] || '').toLowerCase().includes(q)) return true;
}
const lbl = (card.x ? cardLabel(card.x) : card.title || '').toLowerCase();
return lbl.includes(q) || humanizeSearch(card).includes(q);
}
function humanizeSearch(card){
if (!card.x) return '';
if (card.x.fields) return card.x.fields.map(f => humanize(f.name || '')).join(' ').toLowerCase();
return '';
}
function renderMain(){
const main = $('#main');
const keepScroll = main.scrollTop;
document.body.classList.toggle('focus', S.focus);
if (S.focus){
const flat = V.flat.filter(matches);
if (!flat.length){ main.innerHTML = `No cards match your search.
`; return; }
S.focusIdx = Math.max(0, Math.min(S.focusIdx, flat.length - 1));
const c = flat[S.focusIdx];
const pct = Math.round((S.focusIdx + 1) / flat.length * 100);
main.innerHTML = `
${esc(c.entry.label)} ${S.focusIdx + 1} of ${flat.length}
${cardHTML(c, {focus: true})}
← Previous
${S.reviewed[c.key] ? 'Next card →' : 'Mark reviewed, next →'}
= flat.length - 1 ? 'disabled' : ''}>Skip →
`;
main.scrollTop = 0;
return;
}
let hits = 0;
const secs = V.entries.map(e => {
const cards = e.cards.filter(matches); hits += cards.length;
if (!cards.length) return S.q ? '' : '';
const kc = KIND[e.kind] || KIND.note;
const c = sectionCounts(e);
return `
${esc(ENTRY_KIND[e.kind] || 'Section')}
${esc(e.label)}
${e.screenList && e.screenList.length ? scrChips(e.screenList) : ''}
${c.done}/${c.total} reviewed
${e.kind === 'decision' ? decisionSummary(e) : ''}
${cards.map(card => cardHTML(card)).join('')}
`;
}).join('');
$('#hits').textContent = S.q ? hits + (hits === 1 ? ' match' : ' matches') : '';
main.innerHTML = `${S.q ? '' : overviewHTML()}${secs || `
Nothing matches “${esc(S.q)}”. Try a shorter word.
`}
`;
main.scrollTop = keepScroll;
}
function decisionSummary(e){
const ds = e.cards.filter(c => c.kind === 'decision');
const cnt = {ok: 0, change: 0, unsure: 0};
ds.forEach(c => { const v = S.decisions[c.x.code]; if (v) cnt[v]++; });
const waiting = ds.length - cnt.ok - cnt.change - cnt.unsure;
return `${cnt.ok} confirmed${cnt.change} to change${cnt.unsure} not sure${waiting} waitingCopy summary
`;
}
function renderCompare(){
const cmp = $('#cmp');
document.body.classList.toggle('cmp-open', S.cmpOpen);
document.body.classList.toggle('cmp-max', S.cmpOpen && S.cmpMax);
$('#bCompare').setAttribute('aria-pressed', S.cmpOpen);
$('#cmpN').textContent = S.compare.length;
if (!S.cmpOpen){ cmp.innerHTML = ''; return; }
const keep = $('.cmp-cols', cmp) ? $('.cmp-cols', cmp).scrollLeft : 0;
const cols = S.compare.map((k, idx) => {
const c = V.cardIndex[k];
if (!c) return '';
return ` ${cardHTML(c, {inCompare: true})}
`;
}).join('');
cmp.innerHTML = `
Compare Side by side. Drag cards in, drag them to reorder.
${S.compare.length > 1 ? ` Highlight shared fields ` : ''}
${S.cmpMax ? 'Narrow' : 'Wide'}
${S.compare.length ? 'Clear ' : ''}
Close
${cols}
${S.compare.length ? 'Drop another card here' : 'Drag a card here by its Drag handle, or press Compare on any card.'}
`;
$('.cmp-cols', cmp).scrollLeft = keep;
if (S.diff) markShared();
}
function markShared(){
const cols = $$('#cmp .cmp-col');
const sets = cols.map(col => new Set($$('.f-human', col).map(e => e.textContent.trim())));
if (sets.length < 2) return;
cols.forEach((col, ci) => $$('.f-human', col).forEach(el => {
const t = el.textContent.trim();
if (sets.some((s, j) => j !== ci && s.has(t))) { el.classList.add('df'); const raw = el.parentElement.querySelector('.f-raw'); if (raw) raw.classList.add('df'); }
}));
}
function renderAll(){
S.doc = S.doc || parse(S.source);
V = buildView(S.doc);
S.compare = S.compare.filter(k => V.cardIndex[k]);
computeChanged();
document.body.classList.toggle('plain', S.view !== 'tech');
document.body.classList.toggle('calm', S.view === 'calm');
document.body.classList.toggle('tech', S.view === 'tech');
$('#vCalm').setAttribute('aria-pressed', S.view === 'calm');
document.body.classList.toggle('flist', S.fieldLayout === 'list');
document.body.classList.toggle('skin-paper', S.skin === 'paper');
$('#skWarm').setAttribute('aria-pressed', S.skin !== 'paper');
$('#skPaper').setAttribute('aria-pressed', S.skin === 'paper');
$('#skFlash').setAttribute('aria-pressed', S.flash !== false);
const tc = $('#themeColor'); if (tc) tc.setAttribute('content', getComputedStyle(document.body).getPropertyValue('--paper').trim() || '#FBF3E8');
$('#flGrid').setAttribute('aria-pressed', S.fieldLayout !== 'list');
$('#flList').setAttribute('aria-pressed', S.fieldLayout === 'list');
document.body.classList.toggle('mflow', S.modelLayout === 'flow');
$('#mlStack').setAttribute('aria-pressed', S.modelLayout !== 'flow');
$('#mlFlow').setAttribute('aria-pressed', S.modelLayout === 'flow');
$('#vPlain').setAttribute('aria-pressed', S.view === 'plain');
$('#vTech').setAttribute('aria-pressed', S.view === 'tech');
$('#bSkim').setAttribute('aria-pressed', S.skim);
$('#bFocus').setAttribute('aria-pressed', S.focus);
document.documentElement.style.setProperty('--fs', S.fs + 'px');
$('#docTitle').textContent = S.doc.title + (S.doc.version ? ' · ' + S.doc.version : '');
document.title = 'Spec Visualizer';
renderNav(); renderMain(); renderCompare(); updateProgress();
if (S.sheet) renderSheet();
}
function updateProgress(){
const total = V.flat.length, done = V.flat.filter(c => S.reviewed[c.key]).length;
$('#progBar').style.width = (total ? done / total * 100 : 0) + '%';
$('#progBar').style.setProperty('--p', (total ? done / total * 100 : 0) + '%');
}
function load(src, opts = {}){
S.source = src; S.doc = parse(src);
if (opts.asOriginal){ S.original = src; S.undo = []; }
loadSpecPrefs();
S.focusIdx = 0;
renderAll(); saveLocal();
}
/* =====================================================================
INTERACTIONS
===================================================================== */
let toastT = null;
function toast(msg, action){
const t = $('#toast');
t.innerHTML = `${msg} ${action ? `${action.label} ` : ''}`;
t.hidden = false;
if (action) $('#toastAct').onclick = () => { action.fn(); t.hidden = true; };
clearTimeout(toastT); toastT = setTimeout(() => t.hidden = true, action ? 6000 : 2600);
}
function commitEdit(eid, newText){
const next = applyEditToSource(S.doc, eid, newText);
if (next == null || next === S.source) return;
S.undo.push(S.source);
S.source = next; S.doc = parse(next);
renderAll(); saveLocal();
toast('Saved into the spec text', {label: 'Undo', fn: undo});
}
function undo(){
if (!S.undo.length){ toast('Nothing to undo'); return; }
S.source = S.undo.pop(); S.doc = parse(S.source); renderAll(); saveLocal(); toast('Undone');
}
function goTo(key){
const c = V.cardIndex[key];
if (!c) return;
hidePop();
if (S.focus){ const flat = V.flat.filter(matches); const i = flat.indexOf(c); if (i >= 0){ S.focusIdx = i; renderMain(); } return; }
if (S.collapsed[key] || S.skim){ S.collapsed[key] = false; renderMain(); }
const el = document.getElementById('c-' + key);
if (!el){ S.q = ''; $('#q').value = ''; renderMain(); return goTo(key); }
el.scrollIntoView({behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'start'});
el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash');
}
function pageFlash(){
if (S.skin !== 'paper' || S.flash === false) return;
const f = $('#flash'); f.classList.remove('go'); void f.offsetWidth; f.classList.add('go');
clearTimeout(pageFlash.t); pageFlash.t = setTimeout(() => f.classList.remove('go'), 400);
}
function goNav(key){
pageFlash();
if (S.focus){
if (key === 'overview'){ S.focus = false; renderAll(); $('#main').scrollTop = 0; return; }
const e = V.entries.find(x => x.key === key); if (e && e.cards[0]) goTo(e.cards[0].key); return;
}
const el = key === 'overview' ? $('#sec-overview') : document.getElementById('sec-' + key);
if (!el){ if (S.q){ S.q = ''; $('#q').value = ''; renderMain(); return goNav(key); } return; }
el.scrollIntoView({behavior: 'smooth', block: 'start'});
}
function markActiveNav(){
const main = $('#main');
let active = 'overview';
if (S.focus){ const flat = V.flat.filter(matches); const c = flat[S.focusIdx]; if (c) active = c.entry.key; }
else {
const top = main.getBoundingClientRect().top + 80;
for (const s of $$('.sec[data-sec]', main)){ if (s.getBoundingClientRect().top <= top) active = s.dataset.sec; }
}
$$('.nav-a').forEach(a => a.classList.toggle('on', a.dataset.nav === active));
}
function toggleCompare(key){
const i = S.compare.indexOf(key);
if (i >= 0) S.compare.splice(i, 1);
else {
if (S.compare.length >= 4){ toast('The tray holds 4 cards. Remove one first.'); return; }
S.compare.push(key); S.cmpOpen = true;
}
saveLocal(); renderAll();
}
/* popover for references */
let popT = null, popFor = null;
function refTarget(ref){
if (!ref) return null;
if (ref.startsWith('code:')){ const it = S.doc.coded[ref.slice(5)]; return it ? V.cardIndex[it.key] : null; }
if (ref.startsWith('rate:')){ const r = S.doc.rateIdx[ref.slice(5)]; return r ? {rate: r, key: (V.flat.find(c => c.kind === 'rate') || {}).key} : null; }
return V.cardIndex[ref] || null;
}
function showPop(el){
const ref = el.dataset.ref; const t = refTarget(ref); if (!t) return;
const pop = $('#pop');
let h = '';
if (t.rate){ h = `Limit: ${esc(t.rate.name)} ${rich(t.rate.limit)}
`; }
else if (t.kind === 'model' || t.kind === 'object'){
h = `${esc(humanShape(t.x.name))} ${t.x.note ? `${rich(t.x.note)}
` : ''}${t.x.fields.slice(0, 14).map(f => f.spread ? `
+ ${esc(f.spread)}
` : `
${esc(humanize(f.name))} ${esc(f.name)}${typePill(f)} ${esc(f.type || '')}
`).join('')}${t.x.fields.length > 14 ? `
and ${t.x.fields.length - 14} more
` : ''}
`;
} else if (t.x && t.x.t === 'coded'){ h = `${esc(t.x.code)}${t.x.title ? ' · ' + esc(t.x.title) : ''} ${rich(t.x.text)}
`; }
else if (t.kind === 'endpoint'){ h = `${esc(t.x.title)} ${VERB[t.x.method]} ${esc(t.x.path)}
`; }
else return;
const key = t.key;
h += key ? `Go to it ${t.rate ? '' : `${S.compare.includes(key) ? 'In tray' : 'Add to compare'} `}
` : '';
pop.innerHTML = h; pop.hidden = false;
const r = el.getBoundingClientRect();
const pw = pop.offsetWidth, ph = pop.offsetHeight;
let x = Math.min(window.innerWidth - pw - 12, Math.max(12, r.left));
let y = r.bottom + 8; if (y + ph > window.innerHeight - 12) y = Math.max(12, r.top - ph - 8);
pop.style.left = x + 'px'; pop.style.top = y + 'px';
popFor = el;
}
function hidePop(){ $('#pop').hidden = true; popFor = null; }
function bind(){
const main = $('#main');
main.addEventListener('scroll', () => { if (!S.focus) requestAnimationFrame(markActiveNav); }, {passive: true});
document.addEventListener('click', e => {
const t = e.target;
const fpb = t.closest('[data-fp]');
if (fpb){ hidePop(); if (S.sheet && S.sheet.fp === fpb.dataset.fp && !$('#sheet').contains(fpb)) closeSheet(); else openSheet(fpb.dataset.fp, fpb); return; }
const shb = t.closest('[data-sheet]');
if (shb){ if (shb.dataset.sheet === 'close') closeSheet(); else sheetStep(Number(shb.dataset.sheet)); return; }
const vw = t.closest('[data-view]'); if (vw){ setView(vw.dataset.view); return; }
if (S.sheet && !t.closest('#sheet') && !t.closest('#pop')) closeSheet();
const nav = t.closest('[data-nav]');
if (nav && nav.dataset.nav){ goNav(nav.dataset.nav); return; }
const go = t.closest('[data-go]'); if (go){ if (go.closest('#sheet')) closeSheet(); goTo(go.dataset.go); return; }
const scr = t.closest('button.scr, span.scr[data-scr]');
if (scr && !t.closest('.ed:focus')){ const id = scr.dataset.scr; goTo('screens-map'); setTimeout(() => { const s = document.getElementById('screen-' + id); if (s){ s.scrollIntoView({block: 'center'}); s.style.outline = '3px solid var(--marigold)'; setTimeout(() => s.style.outline = '', 1600); } }, S.focus ? 50 : 350); return; }
const cadd = t.closest('[data-cadd]'); if (cadd){ addComment(cadd.dataset.cadd); return; }
const cdel = t.closest('[data-cdel]'); if (cdel){ removeComment(Number(cdel.dataset.cdel)); return; }
const dec = t.closest('[data-dec]');
if (dec){ const c = dec.dataset.dec, v = dec.dataset.v; S.decisions[c] = S.decisions[c] === v ? '' : v; saveLocal(); renderAll(); return; }
const act = t.closest('[data-act]');
if (act){
const a = act.dataset.act; const card = act.closest('.card'); const key = card ? card.dataset.key : act.dataset.key;
if (a === 'compare') toggleCompare(key);
else if (a === 'pop-compare'){ toggleCompare(key); hidePop(); }
else if (a === 'review'){ S.reviewed[key] = !S.reviewed[key]; saveLocal(); renderAll(); }
else if (a === 'collapse'){
const now = card.classList.contains('collapsed');
S.collapsed[key] = !now ? true : false; renderAll();
}
else if (a === 'copy-decisions') copyDecisions();
else if (a === 'calm-toggle'){
if (card.dataset.cmp || S.focus) return;
S.calmOpen = S.calmOpen === key ? null : key; closeSheet(); renderMain();
const el = document.getElementById('c-' + key);
if (el && S.calmOpen){ const r = el.getBoundingClientRect(), m = $('#main').getBoundingClientRect(); if (r.top < m.top || r.top > m.bottom - 120) el.scrollIntoView({block: 'start', behavior: 'smooth'}); }
}
return;
}
const lim = t.closest('.lim[data-ref]'); if (lim && lim.dataset.ref){ const rt = refTarget(lim.dataset.ref); if (rt && rt.key){ goTo(rt.key); } return; }
const ref = t.closest('[data-ref]');
if (ref){
const editing = ref.closest('.ed') && document.activeElement === ref.closest('.ed');
if (!editing){ if (popFor === ref && !$('#pop').hidden) hidePop(); else showPop(ref); }
return;
}
const fb = t.closest('[data-focus]');
if (fb){ focusStep(fb.dataset.focus); return; }
if (!t.closest('#pop')) hidePop();
if (t.id === 'cmpClose'){ S.cmpOpen = false; S.cmpMax = false; renderAll(); }
else if (t.id === 'cmpMax'){ S.cmpMax = !S.cmpMax; renderCompare(); }
else if (t.id === 'cmpClear'){ S.compare = []; saveLocal(); renderAll(); }
});
document.addEventListener('input', e => {
if (e.target.dataset.cdraft){ S.drafts = S.drafts || {}; S.drafts[e.target.dataset.cdraft] = e.target.value; }
if (e.target.dataset.cname){ S.author = e.target.value; $$('[data-cname]').forEach(x => { if (x !== e.target) x.value = S.author; }); saveLocal(); }
});
document.addEventListener('keydown', e => {
if (e.target.dataset && e.target.dataset.cdraft && e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ e.preventDefault(); addComment(e.target.dataset.cdraft); }
});
document.addEventListener('change', e => { if (e.target.id === 'cmpDiff'){ S.diff = e.target.checked; renderCompare(); } });
document.addEventListener('mouseover', e => {
const ref = e.target.closest('.ref[data-ref], .dflt[data-ref], .badge.dec[data-ref]');
if (ref){ clearTimeout(popT); popT = setTimeout(() => showPop(ref), 380); }
});
document.addEventListener('mouseout', e => {
const ref = e.target.closest('.ref[data-ref], .dflt[data-ref], .badge.dec[data-ref]');
if (ref){ clearTimeout(popT); popT = setTimeout(() => { if (!$('#pop').matches(':hover')) hidePop(); }, 350); }
});
$('#pop').addEventListener('mouseleave', () => { popT = setTimeout(hidePop, 300); });
$('#pop').addEventListener('mouseenter', () => clearTimeout(popT));
// inline editing
let editOrig = null;
document.addEventListener('focusin', e => { const el = e.target.closest('.ed'); if (el){ editOrig = el.innerText; document.body.classList.add('editing'); hidePop(); } });
document.addEventListener('focusout', e => {
const el = e.target.closest && e.target.closest('.ed');
if (!el) return;
document.body.classList.remove('editing');
const now = el.innerText.replace(/ /g, ' ').replace(/\n+$/, '');
const was = (editOrig || '').replace(/ /g, ' ').replace(/\n+$/, '');
if (now !== was) setTimeout(() => commitEdit(el.dataset.eid, now), 0);
});
document.addEventListener('keydown', e => {
const el = e.target.closest && e.target.closest('.ed');
if (el){
if (e.key === 'Escape'){ el.innerText = editOrig || ''; el.blur(); e.preventDefault(); }
else if (e.key === 'Enter' && (!el.dataset.ml || e.metaKey || e.ctrlKey)){ e.preventDefault(); el.blur(); }
return;
}
if (e.target.matches('input, textarea, select') || $('#dlg').open) return;
if (S.sheet && (e.key === 'ArrowDown' || e.key === 'j')){ e.preventDefault(); sheetStep(1); return; }
if (S.sheet && (e.key === 'ArrowUp' || e.key === 'k')){ e.preventDefault(); sheetStep(-1); return; }
if (S.sheet && e.key === 'Escape'){ closeSheet(); return; }
if (e.key === '/'){ e.preventDefault(); $('#q').focus(); }
else if (e.key === 'f' || e.key === 'F'){ S.focus = !S.focus; renderAll(); }
else if (S.focus && (e.key === 'ArrowRight' || e.key === 'j')) focusStep('1');
else if (S.focus && (e.key === 'ArrowLeft' || e.key === 'k')) focusStep('-1');
else if ((e.metaKey || e.ctrlKey) && e.key === 'z'){ e.preventDefault(); undo(); }
else if (e.key === 'Escape'){ hidePop(); }
});
document.addEventListener('paste', e => {
const el = e.target.closest && e.target.closest('.ed'); if (!el) return;
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, el.dataset.ml ? text : text.replace(/\s*\n\s*/g, ' '));
});
// top bar
$('#vCalm').onclick = () => setView('calm');
$('#skWarm').onclick = () => { S.skin = 'warm'; saveLocal(); renderAll(); };
$('#skPaper').onclick = () => { S.skin = 'paper'; saveLocal(); renderAll(); pageFlash(); };
$('#skFlash').onclick = () => { S.flash = S.flash === false; saveLocal(); renderAll(); pageFlash(); };
$('#flGrid').onclick = () => { S.fieldLayout = 'grid'; saveLocal(); renderAll(); };
$('#flList').onclick = () => { S.fieldLayout = 'list'; saveLocal(); renderAll(); };
$('#mlStack').onclick = () => { S.modelLayout = 'stack'; saveLocal(); renderAll(); };
$('#mlFlow').onclick = () => { S.modelLayout = 'flow'; saveLocal(); renderAll(); };
$('#vPlain').onclick = () => { S.view = 'plain'; saveLocal(); renderAll(); };
$('#vTech').onclick = () => { S.view = 'tech'; saveLocal(); renderAll(); };
$('#bSkim').onclick = () => { S.skim = !S.skim; S.collapsed = {}; renderAll(); };
$('#bFocus').onclick = () => { S.focus = !S.focus; renderAll(); };
$('#bCompare').onclick = () => { S.cmpOpen = !S.cmpOpen; if (!S.cmpOpen) S.cmpMax = false; renderAll(); };
$('#fMinus').onclick = () => { S.fs = Math.max(13, S.fs - 1); saveLocal(); renderAll(); };
$('#fPlus').onclick = () => { S.fs = Math.min(22, S.fs + 1); saveLocal(); renderAll(); };
let qT = null;
$('#q').addEventListener('input', e => { clearTimeout(qT); qT = setTimeout(() => { S.q = e.target.value.trim(); S.focusIdx = 0; renderMain(); markActiveNav(); }, 140); });
$('#q').addEventListener('keydown', e => { if (e.key === 'Escape'){ e.target.value = ''; S.q = ''; renderMain(); e.target.blur(); } });
// spec dialog
const dlg = $('#dlg');
$('#bSpec').onclick = () => { $('#srcBox').value = S.source; dlg.showModal(); };
$('#dlgCancel').onclick = () => dlg.close();
$('#dlgClear').onclick = () => { $('#srcBox').value = ''; $('#srcBox').focus(); };
$('#dlgOrig').onclick = () => { $('#srcBox').value = SEED; };
$('#dlgCopy').onclick = () => copyText($('#srcBox').value, 'Spec text copied');
$('#dlgApply').onclick = () => {
const v = $('#srcBox').value;
if (!v.trim()){ toast('The box is empty. Paste a spec first.'); return; }
dlg.close();
const same = v === S.source;
if (!same){
const sameDoc = parse(v).title === S.doc.title;
if (sameDoc) { S.undo.push(S.source); load(v); toast('Spec updated', {label: 'Undo', fn: undo}); }
else { load(v, {asOriginal: true}); toast('New spec rendered'); }
}
};
$('#fileIn').onchange = e => { const f = e.target.files[0]; if (f) f.text().then(t => { $('#srcBox').value = t; }); };
// drag and drop: cards into compare, files onto the page
let drag = null;
document.addEventListener('dragstart', e => {
const g = e.target.closest && e.target.closest('.grip');
if (!g){ return; }
const card = g.closest('.card');
drag = {key: card.dataset.key, fromCmp: !!card.dataset.cmp};
e.dataTransfer.effectAllowed = 'copyMove';
try { e.dataTransfer.setData('text/plain', card.dataset.key); } catch(_){}
try { e.dataTransfer.setDragImage(card, 30, 20); } catch(_){}
document.body.classList.add('dragging');
if (!S.cmpOpen){ S.cmpOpen = true; drag.autoOpened = true; setTimeout(renderCompare, 0); }
});
document.addEventListener('dragend', () => {
document.body.classList.remove('dragging');
$$('.ins-before').forEach(x => x.classList.remove('ins-before'));
const cc = $('.cmp-cols'); if (cc) cc.classList.remove('over');
if (drag && drag.autoOpened && !S.compare.length){ S.cmpOpen = false; renderCompare(); }
drag = null;
});
const insertIndex = (x) => {
const cols = $$('#cmp .cmp-col'); let idx = cols.length;
for (let i = 0; i < cols.length; i++){ const r = cols[i].getBoundingClientRect(); if (x < r.left + r.width / 2){ idx = i; break; } }
return idx;
};
document.addEventListener('dragover', e => {
const types = e.dataTransfer ? [...e.dataTransfer.types] : [];
if (!drag && types.includes('Files')){ e.preventDefault(); $('#veil').hidden = false; return; }
if (!drag) return;
const inCmp = e.target.closest && e.target.closest('#cmp');
if (inCmp){
e.preventDefault(); e.dataTransfer.dropEffect = drag.fromCmp ? 'move' : 'copy';
const cc = $('.cmp-cols'); if (cc) cc.classList.add('over');
const idx = insertIndex(e.clientX);
$$('#cmp .cmp-col').forEach((c, i) => c.classList.toggle('ins-before', i === idx));
} else if (drag.fromCmp && e.target.closest && e.target.closest('#main')){
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
}
});
document.addEventListener('dragleave', e => {
if (e.target === document.documentElement || !e.relatedTarget) $('#veil').hidden = true;
});
document.addEventListener('drop', e => {
$('#veil').hidden = true;
const types = e.dataTransfer ? [...e.dataTransfer.types] : [];
if (!drag && types.includes('Files')){
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) f.text().then(t => { if (t.trim()){ load(t, {asOriginal: true}); toast('Rendered ' + esc(f.name)); } });
return;
}
if (!drag) return;
e.preventDefault();
const inCmp = e.target.closest && e.target.closest('#cmp');
if (inCmp){
let idx = insertIndex(e.clientX);
const cur = S.compare.indexOf(drag.key);
if (cur >= 0){ S.compare.splice(cur, 1); if (cur < idx) idx--; }
else if (S.compare.length >= 4){ toast('The tray holds 4 cards. Remove one first.'); return; }
S.compare.splice(idx, 0, drag.key);
drag.autoOpened = false;
} else if (drag.fromCmp){
S.compare = S.compare.filter(k => k !== drag.key);
toast('Removed from the tray');
}
saveLocal(); renderAll();
});
}
function focusStep(v){
pageFlash();
const flat = V.flat.filter(matches);
if (v === 'review'){ const c = flat[S.focusIdx]; if (c) S.reviewed[c.key] = true; saveLocal(); if (S.focusIdx < flat.length - 1) S.focusIdx++; else toast('That was the last card. Nice work.'); }
else S.focusIdx = Math.max(0, Math.min(flat.length - 1, S.focusIdx + Number(v)));
renderMain(); renderNav(); updateProgress();
}
function copyText(text, msg){
const done = () => toast(msg);
try {
navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, msg));
} catch(e){ fallbackCopy(text, msg); }
}
function fallbackCopy(text, msg){
const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
let ok = false; try { ok = document.execCommand('copy'); } catch(e){}
ta.remove();
if (ok) toast(msg); else { $('#srcBox').value = text; $('#dlg').showModal(); $('#srcBox').select(); toast('Copy is blocked here. The text is selected, press Ctrl+C.'); }
}
function copyDecisions(){
const lbl = {ok: 'Confirmed', change: 'Change it', unsure: 'Not sure', '': 'Waiting'};
const lines = V.flat.filter(c => c.kind === 'decision').map(c => `- ${c.x.code} [${lbl[S.decisions[c.x.code] || '']}] ${c.x.text.replace(/\s+/g, ' ')}` + (c.x.comments || []).map(m => `\n > ${m.author ? m.author + ' ' : ''}${m.date ? '(' + m.date + ')' : ''}${m.status ? ' [' + m.status + ']' : ''}: ${m.text}`).join(''));
copyText(`Decisions for ${S.doc.title}${S.doc.version ? ' ' + S.doc.version : ''}\n` + lines.join('\n'), 'Decision summary copied');
}
/* =====================================================================
BOOT
===================================================================== */
const last = store.get('specviz:last');
if (last){ S.view = last.view || 'plain'; S.fs = last.fs || 16; S.fieldLayout = last.fieldLayout || 'grid'; S.modelLayout = last.modelLayout || 'stack'; S.skin = last.skin || 'warm'; S.flash = last.flash !== false; S.author = last.author || ''; }
bind();
if (last && last.source && last.source !== SEED){
S.original = last.original || SEED;
load(last.source);
toast(last.original === SEED ? 'Your earlier edits are back' : 'Showing the spec you loaded last time', {label: 'Start fresh', fn: () => { load(SEED, {asOriginal: true}); }});
} else {
load(SEED, {asOriginal: true});
}
window.__specviz = {parse, S, applyEditToSource, commitEdit};
})();