Repository navigation
Expand file tree
/
Copy pathcard.js
More file actions
486 lines (479 loc) · 29 KB
/
Copy pathcard.js
File metadata and controls
486 lines (479 loc) · 29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
// The card: one entity of a data block, rendered into a body and a foot. Two pages draw it —
// the stage beside its drawing, and a ledger under one of its rows — and the day it lived
// inside stage.js a second page could only copy it, which it did, and drifted in seven places
// within a day. So it is a file of its own, synced whole, loaded before whichever script
// calls it, and it knows no page: what it cannot read off the entity it takes from `opts`.
// The one exception is where a page serves the model's images, read off the page's own data
// link, since four callers — two of them fences inlined in three sites — would otherwise each
// have to learn where a site keeps its images.
//
// rbCard.render(entity, bodyEl, footEl, { data, lang, link, note, images })
// data the parsed block: entities for resolving a reference, commit and repo for the foot
// lang "en" or "de" — the caller reads <html lang>; this file never does
// link (id) → Element: what a resolved reference becomes. The stage hands back a link
// that focuses the node; the timeline hands back one that opens the model page on it.
// note optional; one line appended after the tagline as p.empty — the stage's page count
// on the root
// images optional; the base the page serves the model's images at. Left out, it is what
// the page's data link declares in `data-images`, and no picture where it declares none
// rbCard.fmtPeriod(stamp, lang), rbCard.fmtDate(value, lang)
// how a date reads, in which language. Moved here because a ledger's stamps and a stage's
// have to read the same, and one copy is the only way that stays true.
(function(){
var STR = {
view: { en:"View this file on GitHub", de:"Diese Datei auf GitHub ansehen" },
now: { en:"present", de:"heute" },
skills: { en:"Skills", de:"Fähigkeiten" },
other: { en:"Other", de:"Weitere" }
};
// A date is drawn as prose, not as the ISO the model stores, so the months travel with the
// script the way every other word here does. The German list carries the period where
// German abbreviates and none where it does not, the list WRITING.md sets; the English
// stays three letters without a period, which is that language's rule. The three lengths are core's three precisions:
// a year, a month, a day — written at the precision the model holds and never padded up.
var MONTHS = {
en: ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
de: ["Jan.","Febr.","März","Apr.","Mai","Juni","Juli","Aug.","Sept.","Okt.","Nov.","Dez."]
};
function fmtDate(v, lang){
var m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(v || "");
if (!m) return v || "";
if (!m[2]) return m[1];
var mon = MONTHS[lang][+m[2] - 1];
if (!m[3]) return mon + " " + m[1];
// "May 4, 2012" and "4. Mai 2012". The page is en-US, so the English day follows the
// month and a comma sets off the year; the German ordinal carries its point and comes
// first.
if (lang === "de") return (+m[3]) + ". " + mon + " " + m[1];
return mon + " " + (+m[3]) + ", " + m[1];
}
// Three shapes, and the model says which by what it holds. No end means still running. An
// end equal to its start is a one-off — a talk, a certification — and printing it twice
// would say a day lasted from itself to itself.
function fmtPeriod(st, lang){
if (!st || !st.start) return "";
var dash = lang === "de" ? " – " : "–";
if (!st.end) return fmtDate(st.start, lang) + dash + STR.now[lang];
if (st.end === st.start) return fmtDate(st.start, lang);
// A range between two dates, or from one date to now: English closes the en-dash,
// German spaces it, and an open end is no exception — "now" stands where a date would.
return fmtDate(st.start, lang) + dash + fmtDate(st.end, lang);
}
function h(tag, text, cls){ var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function clear(el){ while (el.firstChild) el.removeChild(el.firstChild); }
// A link out of the model. `url` on an entry and every URL in a References table point at
// the web rather than at another node, so they are ordinary hrefs, in the same tab as
// everything else here: nothing on these sites opens a new one. The scheme is dropped from
// what is shown and kept in what is followed — a card column is narrow and `https://` is
// eight characters of no information.
var URL_RE = /^https?:\/\/\S+$/;
function extLink(url){
var a = h("a", url.replace(/^https?:\/\//, "").replace(/\/$/, ""), "ext");
a.href = url;
return a;
}
// The entity a name on a card names. The parser resolved every declared reference by its type
// and, for an owned type, within its owner, and hands the result over as the edges from an
// entity and the ids in their attributes; those are followed first. A name no edge of the
// entity carries links only where exactly one entity holds it, so a card never picks one of
// several: two types may share a name, and two owners of an owned type may too.
function resolveFrom(data, e, text){
var edges = data.edges || [];
for (var i = 0; e && i < edges.length; i++) {
var x = edges[i];
if (x.from !== e.id) continue;
var to = entityOf(data, x.to);
if (to && to.name === text) return x.to;
for (var k in (x.attrs || {})) {
var v = x.attrs[k], ent = typeof v === "string" ? entityOf(data, v) : null;
if (ent && ent.name === text) return v;
}
}
return resolve(data, text);
}
function resolve(data, text){
var id = null;
for (var i = 0; i < data.entities.length; i++) if (data.entities[i].name === text) { if (id) return null; id = data.entities[i].id; }
return id;
}
// Three span-level marks reach a card from the model's fixed shape. Inline code becomes
// code.mono, and bold becomes b, because a list such as a surface's What it shows writes every
// item as a bold name and a sentence. A Markdown link becomes what it points at: an external
// link for a web address, and otherwise the entity its text names through `ref`, since a
// relative path such as phases/shape.md is a file in the model, which has no address here, and
// the node that file became does. Code is split out first, so asterisks and brackets inside
// backticks stay characters. A bare URL inside a sentence becomes a link. Appended as nodes,
// never as innerHTML: these strings come out of the data block, and the day a name contains a
// "<" an innerHTML assignment would start parsing it as markup.
var MD_LINK = /\[([^\]]+)\]\(([^)\s]+)\)/;
function inline(el, text, ref){
String(text).split(/`([^`]+)`/).forEach(function(part, i){
if (!part) return;
if (i % 2) { el.appendChild(h("code", part, "mono")); return; }
var m;
while ((m = MD_LINK.exec(part))) {
if (m.index) inline(el, part.slice(0, m.index), ref);
if (/^https?:\/\//.test(m[2])) { var x = extLink(m[2]); x.textContent = m[1]; el.appendChild(x); }
else el.appendChild(ref ? ref(m[1]) : document.createTextNode(m[1]));
part = part.slice(m.index + m[0].length);
}
if (!part) return;
part.split(/\*\*([^*]+)\*\*/).forEach(function(run, k){
if (!run) return;
var into = el;
if (k % 2) { into = h("b", null); el.appendChild(into); }
run.split(/(https?:\/\/[^\s)\]]+)/).forEach(function(bit, j){
if (!bit) return;
into.appendChild(j % 2 ? extLink(bit.replace(/[.,;:]+$/, "")) : document.createTextNode(bit));
});
});
});
return el;
}
function para(text, cls, ref){ return inline(h("p", null, cls), text, ref); }
// Where the page serves the model's images, or null where it serves none. A site copies each
// image beside its model.json as `<entity address>.<extension>` and says so on the link that names
// its data — `data-images="../images/"` — so a page that has not taken the copy step draws no
// broken picture: it draws the card it always drew. With more than one `link[data-stage]` the
// first is read, as `data()` reads it.
function imagesBase(){
var link = document.querySelector("link[data-stage]");
return link && link.getAttribute ? link.getAttribute("data-images") : null;
}
// The size is set on the element so nothing shifts when the file arrives, and the name is
// the text a reader without the picture gets.
// Pictures are published where their entity sits, under its address, so the name is built
// from that; a model written before entities carried an address has its path as its id.
function encId(id){ return id.split("/").map(encodeURIComponent).join("/"); }
function where(e){ return e.address != null ? e.address : e.id; }
function avatar(e, base){
var name = e.fields && e.fields.image;
if (!base || typeof name !== "string" || !/\.(jpe?g|png)$/.test(name)) return null;
var img = h("img", null, "avatar");
img.setAttribute("src", base.replace(/\/?$/, "/") + encId(where(e)) + "." + name.split(".").pop());
img.setAttribute("alt", e.name);
img.setAttribute("width", "64"); img.setAttribute("height", "64");
img.setAttribute("loading", "lazy"); img.setAttribute("decoding", "async");
// A picture that never arrives — a 404 — would otherwise sit as a broken-image glyph with
// its alt text spilling out of the circle, beside the same name in the h3, and no check in
// this package sees it. The name already stands beside it, so a picture that did not arrive
// says nothing and is taken away; the row keeps the name where it was.
img.addEventListener("error", function(){ if (img.parentNode) img.parentNode.removeChild(img); });
return img;
}
function render(e, bodyEl, footEl, opts){
var data = opts.data, lang = opts.lang === "de" ? "de" : "en", link = opts.link;
function refId(id){
var a = link(id), ent = entityOf(data, id);
if (ent && a.setAttribute) describe(a, ent.type, ent.name, ent.tagline || "");
return a;
}
function ref(name){
var id = resolveFrom(data, e, name);
if (!id || !link) return document.createTextNode(name);
return refId(id);
}
clear(bodyEl); clear(footEl);
// Who claims this entity's skills at a level, and which levels there are — read once per
// card, before the chips that use them are drawn.
var owner = levelOwner(data, e), lvls = levelsOf(data, owner);
// The type alone. The path used to follow it, and it was the longest line on the card
// for the least information: the foot names the file, and on the model page the path
// line above the drawing already says where you are.
bodyEl.appendChild(h("div", e.type, "eyebrow"));
// A picture of what the card names, where the entity carries one and the page serves it:
// the name sits beside it in one row, and without it the name stands alone as it always did.
var pic = avatar(e, opts.images != null ? opts.images : imagesBase());
if (pic) {
var head = h("div", null, "chead");
head.appendChild(pic); head.appendChild(h("h3", e.name));
bodyEl.appendChild(head);
} else bodyEl.appendChild(h("h3", e.name));
if (e.tagline) bodyEl.appendChild(h("p", e.tagline, "tag"));
if (opts.note) bodyEl.appendChild(h("p", opts.note, "empty"));
// Two fields are not drawn in the list. `source` says where the page is mastered, and
// in an instance that masters itself it reads the same on every page — machinery, not a
// fact a reader came for. `skills` is a list of references out of the file, and it is
// the card's last section, grouped, rather than a field row: see below.
// `image` is a third: a file name under the picture it names says nothing.
var keys = Object.keys(e.fields).filter(function(k){ return k !== "source" && k !== "skills" && k !== "image"; });
if (keys.length) {
var dl = h("dl");
keys.forEach(function(k){
dl.appendChild(h("dt", k));
var dd = h("dd"), v = e.fields[k];
// A list is drawn as a list, one entry per line, each link with an edge a pointer
// can find; comma-joined, three skills read as one run-on sentence.
if (Array.isArray(v)) {
var ul = h("ul", null, "items");
v.forEach(function(name){ var li = h("li"); li.appendChild(ref(name)); ul.appendChild(li); });
dd.appendChild(ul);
}
else {
// A single value the parser drew an edge for is a reference, and links where the edge
// lands, as each entry of a list does; one it drew nothing for is a fact and stays
// text. Following the edge rather than looking the name up means the link lands on
// the entity the parser resolved, of the declared type, and a value such as a place
// never becomes a link because some entity happens to share its name.
var edge = link && (data.edges || []).find(function(x){ return x.from === e.id && x.via === k; });
if (edge && entityOf(data, edge.to)) dd.appendChild(refId(edge.to));
else if (URL_RE.test(v)) dd.appendChild(extLink(v));
else dd.textContent = v;
}
dl.appendChild(dd);
});
bodyEl.appendChild(dl);
}
// Every table the section holds, in the order the file wrote them; a caption is quoted
// from the file and is mono, because it is data, not the page's prose.
e.sections.forEach(function(s){
bodyEl.appendChild(h("h4", s.heading));
(s.tables || []).forEach(function(tab){
if (tab.caption) bodyEl.appendChild(para(tab.caption, "caption mono"));
var tbl = h("table"), thead = h("thead"), hr = h("tr");
tab.columns.forEach(function(c){ hr.appendChild(h("th", c)); });
thead.appendChild(hr); tbl.appendChild(thead);
var tb = h("tbody");
tab.rows.forEach(function(row){
var tr = h("tr");
row.forEach(function(cell){
var td = h("td"), id = resolveFrom(data, e, cell);
if (id && link) { var cellA = link(id), cellE = entityOf(data, id); if (cellE && cellA.setAttribute) describe(cellA, cellE.type, cellE.name, cellE.tagline || ""); td.appendChild(cellA); }
else if (URL_RE.test(cell)) td.appendChild(extLink(cell));
else inline(td, cell);
tr.appendChild(td);
});
tb.appendChild(tr);
});
tbl.appendChild(tb); bodyEl.appendChild(tbl);
});
// A block is one of four things in the file, and is drawn as that. A line opening with
// "###" is a subheading inside the section — a phase's Activities splits into Code and
// Prose that way — and the rest of its block, if any, is read again as a block. A block
// whose lines each open with "- " is a list, and one whose lines open with a number and a
// period is a numbered list; either is drawn with the marker stripped and a hanging
// indent, a continuation line joined to its item. Anything else is a paragraph.
if (s.text) s.text.split(/\n\n+/).forEach(function block(par){
var sub = /^#{3,6}\s+(.+)(?:\n([\s\S]*))?$/.exec(par);
if (sub) {
bodyEl.appendChild(inline(h("h5", null, "sub"), sub[1], ref));
if (sub[2] && sub[2].trim()) block(sub[2]);
} else if (/^-\s/.test(par)) {
var ul = h("ul", null, "prose");
par.split(/\n(?=-\s)/).forEach(function(item){
ul.appendChild(inline(h("li"), item.replace(/^-\s+/, "").replace(/\n\s*/g, " "), ref));
});
bodyEl.appendChild(ul);
} else if (/^\d+\.\s/.test(par)) {
var ol = h("ol", null, "prose");
par.split(/\n(?=\d+\.\s)/).forEach(function(item){
ol.appendChild(inline(h("li"), item.replace(/^\d+\.\s+/, "").replace(/\n\s*/g, " "), ref));
});
bodyEl.appendChild(ol);
} else bodyEl.appendChild(para(par.replace(/\n/g, " "), null, ref));
});
});
// Skills last, and grouped. Each skill file names a `group`, and the card reads it off
// the entity a name resolves to. A group is a closed disclosure — its name and its count
// on the line, its skills as chips when opened — so a role claiming forty-five skills is
// ten lines rather than forty-five. Alphabetical at both levels, the groups and the chips
// inside them: one rule a reader can predict, and one that knows no name from any page,
// where the file's own order would put a different group first on every card. A skill whose file
// names no group, or that resolves to no file, is filed under "Other", last; a model
// whose skills carry no groups at all gets the flat list under the heading.
var skills = e.fields.skills;
if (Array.isArray(skills) && skills.length) {
bodyEl.appendChild(h("h4", STR.skills[lang]));
var groups = [], byGroup = {}, any = false;
skills.forEach(function(name){
var id = resolveFrom(data, e, name), ent = id && data.entities.filter(function(x){ return x.id === id; })[0];
var g = ent && ent.fields && ent.fields.group;
if (g) any = true; else g = null;
var key = g || "\u0000other";
if (!byGroup[key]) { byGroup[key] = []; groups.push(key); }
byGroup[key].push(name);
});
if (!any) {
var flat = h("ul", null, "items");
skills.forEach(function(name){ var li = h("li"); li.appendChild(ref(name)); flat.appendChild(li); });
bodyEl.appendChild(flat);
} else {
var grps = h("div", null, "grps");
groups.filter(function(k){ return k !== "\u0000other"; }).sort(function(x, y){ return x.localeCompare(y, "en"); })
.concat(byGroup["\u0000other"] ? ["\u0000other"] : []).forEach(function(key){
var d = h("details", null, "grp"), s = h("summary", key === "\u0000other" ? STR.other[lang] : key);
s.appendChild(h("span", String(byGroup[key].length), "n"));
var chips = h("div", null, "chips");
byGroup[key].slice().sort(function(x, y){ return x.localeCompare(y, "en"); })
.forEach(function(name){ chips.appendChild(withLevel(ref(name), name)); });
d.appendChild(s); d.appendChild(chips); grps.appendChild(d);
});
bodyEl.appendChild(grps);
}
}
// A chip carries the level the owner claims the skill at, as marks: one small square per
// level the model defines, filled up to the claimed one. The square is the figure's mark
// for a page, and the count is the model's, so the scale is the model's and not this
// file's. The owner is the nearest entity above this one on disk that holds a table with
// a Level column — a profile, in the vocabulary that has one — and a skill it does not
// claim, or a model with no levels, draws a plain chip. The hover and the label say the
// level's name; the hover adds its one line, because four filled of four needs no key.
function withLevel(a, name){
if (!a.setAttribute || !lvls.length || !owner) return a;
var claimed = claimedLevel(owner, name), at = -1;
lvls.forEach(function(l, i){ if (l.name === claimed) at = i; });
if (at < 0) return a;
var marks = h("span", null, "lv"); marks.setAttribute("aria-hidden", "true");
lvls.forEach(function(l, i){ marks.appendChild(h("i", null, i <= at ? "on" : "")); });
a.appendChild(marks);
a.setAttribute("aria-label", name + ", " + claimed);
describe(a, lvls[at].type, lvls[at].name, lvls[at].tagline || "");
return a;
}
// Mono, so it is data: the file and the commit it is pinned at, which is what the link
// resolves to. The phrasing a reader needs is on the label, not in the row.
var a = h("a", e.path.slice(e.path.lastIndexOf("/") + 1) + " @ " + data.commit.slice(0, 7));
a.href = "https://github.com/" + (data.repo || "companygraph/meta-model") + "/blob/" + data.commit + "/" + e.path;
a.setAttribute("aria-label", STR.view[lang]);
footEl.appendChild(a);
}
function entityOf(data, id){ for (var i = 0; i < data.entities.length; i++) if (data.entities[i].id === id) return data.entities[i]; return null; }
// The levels a model defines, lowest first: every entity of the type the owner's Level
// column names, in the order of the rank their files carry. Read off the claim rather than
// off a type name, so this file learns no word from any vocabulary; a model whose claims
// resolve to nothing draws no marks anywhere.
function levelsOf(data, owner){
var tab = owner && levelTable(owner), li = tab ? tab.columns.indexOf("Level") : -1, type = null;
if (tab) for (var i = 0; i < tab.rows.length && !type; i++) { var id = resolveFrom(data, owner, tab.rows[i][li]), ent = id && entityOf(data, id); if (ent) type = ent.type; }
if (!type) return [];
return data.entities.filter(function(x){ return x.type === type; })
.sort(function(x, y){ return (+(x.fields && x.fields.rank) || 0) - (+(y.fields && y.fields.rank) || 0); });
}
function levelTable(ent){
var found = null;
(ent.sections || []).forEach(function(sec){ (sec.tables || []).forEach(function(tab){ if (!found && tab.columns.indexOf("Level") >= 0) found = tab; }); });
return found;
}
// The entity that claims levels for this one: itself, or the nearest entity above it on
// disk whose card holds a table with a Level column. Above it on disk is a matter of
// addresses, which is where a page sits; an id says only which entity it is.
function levelOwner(data, e){
if (levelTable(e)) return e;
var best = null;
data.entities.forEach(function(x){
if (where(e).indexOf(where(x) + "/") === 0 && levelTable(x) && (!best || where(x).length > where(best).length)) best = x;
});
return best;
}
function claimedLevel(owner, name){
var tab = levelTable(owner), si = tab.columns.indexOf("Skill"), li = tab.columns.indexOf("Level");
if (si < 0) si = 0;
for (var i = 0; i < tab.rows.length; i++) if (tab.rows[i][si] === name) return tab.rows[i][li];
return null;
}
// ── the tooltip ─────────────────────────────────────────────────────────────────────
// A hover on a thing in the model shows that thing's card in miniature: its type in the
// eyebrow, its name, its one line. One element for the whole page, moved to whatever is
// hovered or focused, fixed to the viewport so a card that scrolls inside itself cannot
// clip it, and pointer-events none so it never catches the pointer that summoned it. It
// carries role=tooltip and the target points at it with aria-describedby, so a keyboard
// and a screen reader get what a pointer gets. No element keeps a title beside it: a
// browser box under a designed one is two answers to one question. Anything on any page
// may use it — the stage's transport does — by setting the three data-tip attributes.
//
// A target can change what it describes while the pointer rests on it: a click on Next
// moves the trail, and the button under the pointer now leads somewhere else. No mouseover
// follows, because the pointer never left, so describe repaints a tooltip that is showing
// for its element, and undescribe hides one whose element has nothing left to say.
function describe(el, kind, name, text){
el.setAttribute("data-tip-kind", kind || ""); el.setAttribute("data-tip-name", name || ""); el.setAttribute("data-tip", text || "");
el.removeAttribute("title");
if (held === el) { fillTip(el); if (tip.classList.contains("show")) placeTip(el); }
}
function undescribe(el){
el.removeAttribute("data-tip-kind"); el.removeAttribute("data-tip-name"); el.removeAttribute("data-tip");
if (held === el) hideTip();
}
var tip = null, tipK, tipN, tipD, held = null, tipTimer = 0;
function tipEl(){
if (tip) return tip;
tip = h("div", null, "tip"); tip.id = "tip"; tip.setAttribute("role", "tooltip"); tip.setAttribute("aria-hidden", "true");
tipK = h("span", null, "k"); tipN = h("span", null, "n"); tipD = h("span", null, "d");
tip.appendChild(tipK); tip.appendChild(tipN); tip.appendChild(tipD);
document.body.appendChild(tip);
return tip;
}
function placeTip(el){
var t = tipEl(), r = el.getBoundingClientRect(); t.style.left = "0px"; t.style.top = "0px";
var w = t.offsetWidth, hgt = t.offsetHeight, gap = 8;
var x = Math.round(r.left + r.width / 2 - w / 2); x = Math.max(8, Math.min(x, window.innerWidth - w - 8));
var y = Math.round(r.top - hgt - gap); if (y < 8) y = Math.round(r.bottom + gap);
t.style.left = x + "px"; t.style.top = y + "px";
}
// An open <dialog> is in the browser's top layer, above everything that is not, and no
// z-index climbs into it; a tooltip left in the body sits under the expanded stage. So the
// tooltip moves into whichever dialog holds its target and back out to the body when the
// target is on the page — position:fixed reads the same from either parent.
function fillTip(el){
tipK.textContent = el.getAttribute("data-tip-kind") || ""; tipN.textContent = el.getAttribute("data-tip-name") || ""; tipD.textContent = el.getAttribute("data-tip") || "";
}
function showTip(el){
if (held === el) return;
hideTip(); held = el; tipEl();
var home = (el.closest && el.closest("dialog[open]")) || document.body;
if (tip.parentNode !== home) home.appendChild(tip);
fillTip(el);
el.setAttribute("aria-describedby", "tip");
tipTimer = setTimeout(function(){ tip.setAttribute("aria-hidden", "false"); placeTip(el); tip.classList.add("show"); }, 120);
}
function hideTip(){
clearTimeout(tipTimer);
if (tip) { tip.classList.remove("show"); tip.setAttribute("aria-hidden", "true"); }
if (held) held.removeAttribute("aria-describedby");
held = null;
}
function tipTarget(node){ return node && node.closest ? node.closest("[data-tip-name]") : null; }
document.addEventListener("mouseover", function(ev){ var el = tipTarget(ev.target); if (el) showTip(el); else if (held && !held.contains(ev.target)) hideTip(); });
document.addEventListener("mouseout", function(ev){ if (held && ev.relatedTarget && !held.contains(ev.relatedTarget) && !tipTarget(ev.relatedTarget)) hideTip(); });
// Focus shows a note only when the browser would draw a focus ring: a keyboard arriving, not
// a script handing focus back. A dialog focuses its first control as it opens, and closing
// it returns focus to the button that opened it; after a click neither is a question the
// reader asked, and a note there would pop up under the pointer on every open and close.
function keyed(el){ try { return el.matches(":focus-visible"); } catch (e) { return true; } }
document.addEventListener("focusin", function(ev){ var el = tipTarget(ev.target); if (el && keyed(el)) showTip(el); else if (!el) hideTip(); });
document.addEventListener("focusout", function(ev){ if (held && ev.target === held) hideTip(); });
document.addEventListener("keydown", function(ev){ if (ev.key === "Escape") hideTip(); });
document.addEventListener("scroll", function(){ if (held && tip && tip.classList.contains("show")) placeTip(held); }, true);
window.addEventListener("resize", function(){ if (held) placeTip(held); });
// ── the data a page names ───────────────────────────────────────────────────────────
// One copy of "find the block this page names, fetch it, hand it over". It lived in
// stage.js and was copied into blust.ch's timeline, which is how the card itself drifted in
// seven places a week earlier; a third page wanting the model is what made one copy worth
// the release. Callers name themselves, so the message says which script wanted the data
// rather than leaving the reader to guess.
//
// The throw is deliberate and the log is deliberate, and they are not the same case. A page
// that names no data is a mistake in the page, found by whoever forgot the link, and an
// uncaught exception is what every site's suite reports through its pageerror listener. A
// fetch that fails is a network or a deploy, and a throw inside a promise chain would become
// an unhandled rejection, which nothing here listens for.
//
// The callback runs from a timeout rather than from the chain, so a throw inside the drawing
// stays an uncaught exception too.
function data(who, cb){
var link = document.querySelector("link[data-stage]");
if (!link) {
throw new Error(who + ": this page names no data. Add <link rel=\"preload\" as=\"fetch\" " +
"href=\"…\" data-stage crossorigin> and rebuild the page.");
}
fetch(link.href).then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
}).then(function (parsed) {
setTimeout(function () { cb(parsed); }, 0);
}, function (err) {
console.error(who + ": could not read " + link.href + " — " + err.message);
});
}
window.rbCard = { render: render, fmtPeriod: fmtPeriod, fmtDate: fmtDate, describe: describe, undescribe: undescribe, data: data };
})();