lowkey-doc 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowkey-doc",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Render a folder of Markdown into one self-contained documentation dashboard, with flowcharts and ER diagrams that stay readable when they get dense.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: write-doc
3
- description: Write a new high-level product document for this project's documentation folder. Use when asked to document a feature, flow, or area for a non-technical reader — "write a doc about X", "document the checkout flow", "add a docs page for onboarding".
3
+ description: Write a new high-level product document for this project's documentation folder, or change an existing one. Use when asked to document a feature, flow, or area for a non-technical reader — "write a doc about X", "document the checkout flow", "add a docs page for onboarding", "update the onboarding doc".
4
4
  ---
5
5
 
6
6
  # Writing a document
@@ -86,6 +86,28 @@ For an `erDiagram`, the entity is drawn as a table with the keys pinned to its r
86
86
  ## Appendix — implementation direction (only if a decision must be recorded)
87
87
  ```
88
88
 
89
+ ## Changing what someone has reviewed
90
+
91
+ Readers mark sections **Reviewed** on the dashboard, and the marks are kept in `docs.reviews.json`
92
+ beside `docs.config.json`. A mark means a person read those words and agreed with them, so it must
93
+ not outlive them. Before editing a document, look for its file name in `docs.reviews.json`:
94
+
95
+ - **Every section whose words you change loses its mark: delete its line.** A section is keyed by
96
+ its heading's GitHub-style anchor (`## 2. Inviting somebody in` → `"2-inviting-somebody-in"`),
97
+ and `""` is the document's own words: the blockquote under the title and anything before the
98
+ first section.
99
+ - **A mark covers only the words directly under its heading**, up to the next heading of any level.
100
+ Changing a sub-section removes the sub-section's line, not its chapter's; the chapter and the
101
+ document show as open on their own until the changed part is reviewed again.
102
+ - **Renaming a heading or moving a section** changes its anchor: delete its old line as well.
103
+ - Re-wrapping lines or changing only formatting leaves the words as they were: keep the mark.
104
+ - **Never add or change a mark yourself.** Only a person reviews.
105
+ - **Say which reviewed sections you changed** at the end of your reply, so the reviewer knows what
106
+ to read again.
107
+
108
+ The dashboard would show a changed section as open anyway, since each mark carries a fingerprint
109
+ of its words; deleting the line keeps the file honest in git as well.
110
+
89
111
  ## Filing
90
112
 
91
113
  - Name: `<n>. TITLE-IN-CAPS.md` in the documents folder, `<n>` being the next free number.
package/src/build.mjs CHANGED
@@ -6,13 +6,15 @@ import { execFile } from 'node:child_process';
6
6
  import { listDocs, assignIds, buildDoc } from './collect.mjs';
7
7
  import { vendorMermaid, vendorElk, vendorFont, writeNotices } from './vendor.mjs';
8
8
  import { renderPage } from './page.mjs';
9
+ import { readReviews } from './reviews.mjs';
9
10
  import { DEFAULTS, CLI, VERSION } from './config.mjs';
10
11
 
11
12
  export async function build(cfg, { open = true, refresh = false, quiet = false } = {}) {
12
13
  const { docs } = collectDocs(cfg);
13
14
 
14
15
  const assets = await vendorAssets(cfg, refresh);
15
- const html = pageFor(cfg, docs, assets);
16
+ const marks = readReviews(cfg);
17
+ const html = pageFor(cfg, docs, assets, marks.reviews);
16
18
 
17
19
  const outFile = path.join(cfg.outDir, 'index.html');
18
20
  fs.writeFileSync(outFile, html);
@@ -22,6 +24,7 @@ export async function build(cfg, { open = true, refresh = false, quiet = false }
22
24
  docs,
23
25
  bytes: fs.statSync(outFile).size,
24
26
  notes: { mermaid: assets.mermaid.note, elk: assets.elk.note, font: assets.font.note },
27
+ reviews: marks.error,
25
28
  };
26
29
 
27
30
  if (!quiet) print(cfg, report);
@@ -97,9 +100,11 @@ export async function vendorAssets(cfg, refresh = false) {
97
100
  return assets;
98
101
  }
99
102
 
100
- export function pageFor(cfg, docs, assets) {
103
+ // reviews: the marks read from docs.reviews.json. The watcher leaves them out
104
+ // of the page it fingerprints as its shell, so saving one never reloads a page.
105
+ export function pageFor(cfg, docs, assets, reviews = {}) {
101
106
  return renderPage({
102
- cfg, docs,
107
+ cfg, docs, reviews,
103
108
  fontCss: assets.font.css,
104
109
  mermaidTag: assets.mermaid.tag,
105
110
  elkTag: assets.elk.tag,
@@ -117,6 +122,7 @@ function print(cfg, r) {
117
122
  console.log(' mermaid: ' + r.notes.mermaid);
118
123
  console.log(' elk: ' + r.notes.elk);
119
124
  console.log(' font: ' + r.notes.font);
125
+ if (r.reviews) console.log(' reviews: not shown - ' + r.reviews);
120
126
  console.log(' -> ' + r.outFile + ' (' + (r.bytes / 1024).toFixed(0) + ' KB)');
121
127
  }
122
128
 
@@ -34,6 +34,7 @@ export const APP_JS = String.raw`
34
34
  }
35
35
 
36
36
  var CARET = '<svg class="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M6 4l4 4-4 4"/></svg>';
37
+ var CHECK = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.3"/><path d="M5.3 8.2l1.9 1.9 3.6-3.9"/></svg>';
37
38
 
38
39
  function buildNav(){
39
40
  var group = null;
@@ -47,6 +48,7 @@ export const APP_JS = String.raw`
47
48
  item.title = d.file;
48
49
  item.appendChild(el('span', 'num', d.badge));
49
50
  item.appendChild(el('span', 'item-title', d.title));
51
+ if (RV_ON) item.insertAdjacentHTML('beforeend', '<span class="rv-mark" title="Reviewed">' + CHECK + '</span>');
50
52
  if (d.sections.length) item.insertAdjacentHTML('beforeend', CARET);
51
53
  item.addEventListener('click', function(e){
52
54
  if (e.target.closest('.caret') && current === d.id){ item.classList.toggle('open'); return; }
@@ -62,6 +64,8 @@ export const APP_JS = String.raw`
62
64
  a.title = (s.num ? s.num + ' ' : '') + s.text;
63
65
  if (s.num) a.appendChild(el('span', 'sub-n', s.num));
64
66
  a.appendChild(el('span', 'sub-t', s.text));
67
+ if (RV_ON) a.insertAdjacentHTML('beforeend', '<span class="rv-mark" title="Reviewed">' + CHECK + '</span>');
68
+ a.dataset.sec = s.id;
65
69
  subs.appendChild(a);
66
70
  });
67
71
  nav.appendChild(subs);
@@ -106,6 +110,7 @@ export const APP_JS = String.raw`
106
110
  restoreFolds(main);
107
111
  if (window.__diagrams) window.__diagrams.drawAll(main);
108
112
  renderComments();
113
+ paintReviews();
109
114
  watch();
110
115
  }
111
116
  if (anchor && reveal(anchor, false)) return;
@@ -130,9 +135,15 @@ export const APP_JS = String.raw`
130
135
  var head = el('header', 'head');
131
136
  head.appendChild(el('h1', null, d.title));
132
137
  if (d.subtitle){
133
- var p = el('p');
134
- p.innerHTML = d.subtitle;
135
- head.appendChild(p);
138
+ var lede = el('div', 'lede');
139
+ lede.innerHTML = d.subtitle;
140
+ head.appendChild(lede);
141
+ }
142
+ if (RV_ON){
143
+ var rvBar = el('div', 'rv-bar');
144
+ rvBar.appendChild(el('span', 'rv-count'));
145
+ rvBar.insertAdjacentHTML('beforeend', '<button class="rv rv-doc" type="button" aria-pressed="false" aria-label="Reviewed">' + CHECK + '</button>');
146
+ head.appendChild(rvBar);
136
147
  }
137
148
  wrap.appendChild(head);
138
149
 
@@ -201,6 +212,157 @@ export const APP_JS = String.raw`
201
212
  return true;
202
213
  }
203
214
 
215
+ /* ---------------- reviews ----------------
216
+ A reader marks what they have read and agree with. Every section owns the
217
+ words directly under its heading, and the document owns its standfirst
218
+ and whatever comes before its first section; each of those is marked on
219
+ its own, with a fingerprint of the words it was given for. A section is
220
+ reviewed when its own words are and every section under it is. So marking
221
+ a chapter or the document marks everything in it, reviewing the last open
222
+ section of a chapter completes the chapter, and clearing one opens what is
223
+ above it again. Words edited after their review, and a section written
224
+ later, leave their chapter and document open until they are read too.
225
+
226
+ The marks are the project's, in docs.reviews.json: the page shows the ones
227
+ its build read, and only lowkey-doc --watch can save a change (app.saveReviews). */
228
+
229
+ var RV_ON = CFG.reviews !== false;
230
+ var reviews = DATA.reviews || {};
231
+ var rvSaving = 0;
232
+
233
+ // the document as the root of its own outline; its own words are the "" mark
234
+ function rvRoot(d){ return { id: '', sig: d.sig, kids: d.outline || [] }; }
235
+
236
+ // the nodes from the document down to one of its sections (id null: the document)
237
+ function rvTrail(d, id){
238
+ var trail = [rvRoot(d)];
239
+ if (!id) return trail;
240
+ var seek = function(list){
241
+ for (var i = 0; i < list.length; i++){
242
+ trail.push(list[i]);
243
+ if (list[i].id === id || seek(list[i].kids)) return true;
244
+ trail.pop();
245
+ }
246
+ return false;
247
+ };
248
+ return seek(trail[0].kids) ? trail : null;
249
+ }
250
+
251
+ function ownDone(marks, n){ var m = marks[n.id]; return n.sig == null || !!(m && m.sig === n.sig); }
252
+ function allDone(marks, n){ return ownDone(marks, n) && n.kids.every(function(k){ return allDone(marks, k); }); }
253
+
254
+ function isReviewed(d, id){
255
+ var trail = rvTrail(d, id);
256
+ return !!trail && allDone(reviews[d.file] || {}, trail[trail.length - 1]);
257
+ }
258
+
259
+ // A document's marks once one part of it is marked (on) or cleared: the
260
+ // part and everything in it, then upwards - marking completes each chapter
261
+ // whose sections are now all reviewed, clearing opens every one above.
262
+ function rvNext(d, id, on){
263
+ var trail = rvTrail(d, id);
264
+ if (!trail) return null;
265
+ var was = reviews[d.file] || {}, mine = {}, all = [];
266
+ var t = new Date(), today = t.getFullYear() + '-' + ('0' + (t.getMonth() + 1)).slice(-2) + '-' + ('0' + t.getDate()).slice(-2);
267
+ (function walk(n){ all.push(n); n.kids.forEach(walk); })(trail[0]);
268
+ all.forEach(function(n){ if (was[n.id] && n.sig != null) mine[n.id] = was[n.id]; });
269
+ var set = function(n){
270
+ if (n.sig == null) return;
271
+ var m = was[n.id];
272
+ mine[n.id] = m && m.sig === n.sig ? m : { sig: n.sig, at: today };
273
+ };
274
+ (function each(n){ if (on) set(n); else delete mine[n.id]; n.kids.forEach(each); })(trail[trail.length - 1]);
275
+ for (var i = trail.length - 2; i >= 0; i--){
276
+ if (!on){ delete mine[trail[i].id]; continue; }
277
+ if (!trail[i].kids.every(function(k){ return allDone(mine, k); })) break;
278
+ set(trail[i]);
279
+ }
280
+ var out = {};
281
+ all.forEach(function(n){ if (mine[n.id]) out[n.id] = mine[n.id]; });
282
+ return out;
283
+ }
284
+
285
+ function putMarks(file, marks){
286
+ if (marks && Object.keys(marks).length) reviews[file] = marks; else delete reviews[file];
287
+ }
288
+
289
+ function day(at){
290
+ var p = String(at).split('-');
291
+ var t = new Date(+p[0], +p[1] - 1, +p[2]);
292
+ return isNaN(t) ? at : t.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
293
+ }
294
+
295
+ // the latest day anything in this part was reviewed, and whether its own
296
+ // words have changed since
297
+ function rvAbout(d, id){
298
+ var trail = rvTrail(d, id), marks = reviews[d.file] || {}, last = '';
299
+ if (!trail) return {};
300
+ var node = trail[trail.length - 1], m = marks[node.id];
301
+ (function walk(n){ if (marks[n.id] && marks[n.id].at > last) last = marks[n.id].at; n.kids.forEach(walk); })(node);
302
+ return { last: last, changed: !!(m && node.sig != null && m.sig !== node.sig) ? m.at : '' };
303
+ }
304
+
305
+ function paintBtn(btn, d, id, what){
306
+ var on = isReviewed(d, id), about = rvAbout(d, id);
307
+ btn.classList.toggle('is-on', on);
308
+ btn.setAttribute('aria-pressed', on ? 'true' : 'false');
309
+ btn.title = on ? 'Reviewed' + (about.last ? ' on ' + day(about.last) : '') + '. Click to open ' + what + ' again'
310
+ : about.changed ? 'Changed since it was reviewed on ' + day(about.changed) + '. Click to review it again'
311
+ : 'Mark ' + what + ' as reviewed';
312
+ }
313
+
314
+ function paintReviews(){
315
+ if (!RV_ON) return;
316
+ nav.querySelectorAll('.item').forEach(function(item){
317
+ var d = byId[item.dataset.doc];
318
+ if (!d) return;
319
+ item.classList.toggle('is-reviewed', isReviewed(d, null));
320
+ var subs = nav.querySelector('[data-subs="' + d.id + '"]');
321
+ if (subs) subs.querySelectorAll('.sub').forEach(function(a){
322
+ a.classList.toggle('is-reviewed', isReviewed(d, a.dataset.sec));
323
+ });
324
+ });
325
+ var d = byId[current];
326
+ if (!d) return;
327
+ main.querySelectorAll('.sec').forEach(function(sec){
328
+ var btn = sec.querySelector(':scope > .sec-head > .rv');
329
+ if (btn) paintBtn(btn, d, sec.dataset.sec, 'this section');
330
+ });
331
+ var whole = main.querySelector('.rv-doc'), count = main.querySelector('.rv-count');
332
+ if (whole) paintBtn(whole, d, null, 'the whole document');
333
+ if (count){
334
+ // the sections with words of their own to review
335
+ var marks = reviews[d.file] || {}, done = 0, of = 0;
336
+ (function walk(list){
337
+ list.forEach(function(n){ if (n.sig != null){ of++; if (ownDone(marks, n)) done++; } walk(n.kids); });
338
+ })(d.outline || []);
339
+ count.textContent = done && !isReviewed(d, null) ? done + ' of ' + of + ' sections' : '';
340
+ }
341
+ }
342
+
343
+ // Shown at once, saved behind; a save that fails puts the marks back.
344
+ function toggleReview(btn){
345
+ var d = byId[current];
346
+ if (!d) return;
347
+ if (!app.saveReviews){
348
+ toast('Reviews are saved to the project by lowkey-doc --watch. This page was opened from its file, so it can only show them.');
349
+ return;
350
+ }
351
+ var sec = btn.classList.contains('rv-doc') ? null : btn.closest('.sec');
352
+ var id = sec ? sec.dataset.sec : null;
353
+ var before = reviews[d.file], next = rvNext(d, id, !isReviewed(d, id));
354
+ if (!next) return;
355
+ putMarks(d.file, next);
356
+ paintReviews();
357
+ rvSaving++;
358
+ app.saveReviews(d.file, next).then(function(){ rvSaving--; }, function(err){
359
+ rvSaving--;
360
+ putMarks(d.file, before);
361
+ paintReviews();
362
+ toast('Review not saved: ' + (err && err.message || err));
363
+ });
364
+ }
365
+
204
366
  /* ---------------- lightbox ---------------- */
205
367
 
206
368
  var box = document.getElementById('lightbox');
@@ -293,6 +455,8 @@ export const APP_JS = String.raw`
293
455
  if (reveal(href.slice(1), true)) history.replaceState(null, '', '#/' + current + '/' + href.slice(1));
294
456
  return;
295
457
  }
458
+ var rv = e.target.closest('.rv');
459
+ if (rv){ toggleReview(rv); return; }
296
460
  // anywhere on a heading folds its section - unless the click was really the
297
461
  // end of a drag that selected some of it
298
462
  var head = e.target.closest('.sec-head');
@@ -852,8 +1016,10 @@ export const APP_JS = String.raw`
852
1016
  var TOP_EDGE = 64; // below the sticky top bar
853
1017
  var hold = null;
854
1018
 
855
- function applyUpdate(next){
1019
+ function applyUpdate(next, marks){
856
1020
  var old = byId, before = docs;
1021
+ // while this page is saving a review, its own marks are the newest there are
1022
+ if (marks && !rvSaving) reviews = marks;
857
1023
  var navOf = function(list){
858
1024
  return JSON.stringify(list.map(function(d){ return [d.id, d.title, d.badge, d.group, d.file, d.sections]; }));
859
1025
  };
@@ -888,6 +1054,7 @@ export const APP_JS = String.raw`
888
1054
  return r;
889
1055
  }
890
1056
  if (navMoved || changed.indexOf(current) >= 0) patch(old[current], byId[current], r);
1057
+ else paintReviews();
891
1058
  return r;
892
1059
  }
893
1060
 
@@ -901,6 +1068,7 @@ export const APP_JS = String.raw`
901
1068
  n.classList.toggle('open', !!open[n.dataset.doc] || n.dataset.doc === current);
902
1069
  });
903
1070
  if (search && search.value) search.dispatchEvent(new Event('input'));
1071
+ paintReviews();
904
1072
  nav.scrollTop = top;
905
1073
  }
906
1074
 
@@ -920,6 +1088,7 @@ export const APP_JS = String.raw`
920
1088
  restoreFolds(main);
921
1089
  if (window.__diagrams) window.__diagrams.drawAll(main);
922
1090
  renderComments();
1091
+ paintReviews();
923
1092
  watch();
924
1093
  keepPlace(at);
925
1094
 
@@ -938,7 +1107,7 @@ export const APP_JS = String.raw`
938
1107
  var out = [];
939
1108
  var head = holder.querySelector('.head');
940
1109
  if (was.title !== d.title) out.push(head.querySelector('h1'));
941
- if (was.subtitle !== d.subtitle && head.querySelector('p')) out.push(head.querySelector('p'));
1110
+ if (was.subtitle !== d.subtitle && head.querySelector('.lede')) out.push(head.querySelector('.lede'));
942
1111
 
943
1112
  var body = holder.querySelector('.doc');
944
1113
  var t = document.createElement('template');
@@ -1172,6 +1341,7 @@ export const APP_JS = String.raw`
1172
1341
 
1173
1342
  var app = window.__lowkey_doc = {
1174
1343
  save: null,
1344
+ saveReviews: null,
1175
1345
  apply: applyUpdate,
1176
1346
  bring: bring,
1177
1347
  doc: function(id){ return byId[id]; }
@@ -9,6 +9,8 @@ html{-webkit-text-size-adjust:100%}
9
9
  --line-2:rgba(255,255,255,.16); --surface:#13161b; --surface-2:#1a1e24; --hover:rgba(255,255,255,.05);
10
10
  /* the one accent: an electric blue, used for what is lit, current or alive */
11
11
  --accent:#4da3ff; --accent-soft:rgba(77,163,255,.12); --accent-ring:rgba(77,163,255,.36);
12
+ /* reviewed: a green as light and as cool as the blue, so the two sit together */
13
+ --ok:#45cf98; --ok-soft:rgba(69,207,152,.12); --ok-ring:rgba(69,207,152,.36);
12
14
  /* a filled button is white with dark text */
13
15
  --on-ink:#0b0d10; --ink-hover:#dde2e8;
14
16
  --serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;
@@ -153,9 +155,14 @@ a{color:inherit;text-decoration:none}
153
155
  .sub-n{flex:none;font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
154
156
  .sub.current .sub-n{color:var(--muted)}
155
157
  .sub-t{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
158
+ /* a reviewed document or section carries a green check in the sidebar */
159
+ .rv-mark{display:none;flex:none;width:13px;height:13px;color:var(--ok)}
160
+ .rv-mark svg{display:block;width:100%;height:100%}
161
+ .is-reviewed>.rv-mark{display:block}
162
+ .sub .rv-mark{align-self:center;width:12px;height:12px}
156
163
 
157
164
  .collapsed .brand-name,.collapsed .search,.collapsed .nav-group,
158
- .collapsed .item-title,.collapsed .caret,.collapsed .foot-lbl{display:none}
165
+ .collapsed .item-title,.collapsed .caret,.collapsed .foot-lbl,.collapsed .rv-mark{display:none}
159
166
  .collapsed .subs,.collapsed .item.open+.subs{display:none}
160
167
  .collapsed .side-top{padding:16px 0 12px}
161
168
  .collapsed .brand-row{justify-content:center}
@@ -249,7 +256,9 @@ export const CSS_MAIN = `
249
256
  @keyframes head-lamp{to{opacity:1}}
250
257
  @media (prefers-reduced-motion:reduce){.head::before,.head::after{animation:none}}
251
258
  .head h1{margin:0;max-width:22ch;font-size:36px;line-height:1.12;font-weight:650;letter-spacing:-.034em;color:var(--ink)}
252
- .head p{margin:14px 0 0;max-width:68ch;font-size:15.5px;line-height:1.62;color:var(--muted)}
259
+ .head .lede{margin:14px 0 0;max-width:68ch;font-size:15.5px;line-height:1.62;color:var(--muted)}
260
+ .lede>*{margin:0}
261
+ .lede>*+*{margin-top:10px}
253
262
  .doc{padding:4px var(--gutter) 30px;width:100%;max-width:var(--column);margin-inline:auto}
254
263
 
255
264
  /* ---- prev / next ----
@@ -312,6 +321,32 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
312
321
  .sec-head:hover .h-anchor{opacity:1}
313
322
  .h-anchor:hover{color:var(--ink)}
314
323
 
324
+ /* ---- reviews ----
325
+ Every section, and the document in its header card, has a Reviewed button:
326
+ a grey check until the reader marks it, then green. On a heading the word
327
+ shows only on hover, so a page of unread headings is not a page of labels. */
328
+ .rv{flex:none;align-self:center;margin-left:auto;display:inline-flex;align-items:center;gap:6px;
329
+ padding:3px 4px 3px 9px;border:1px solid transparent;border-radius:999px;background:none;
330
+ color:var(--faint);font:inherit;font-size:11.5px;font-weight:var(--semi);letter-spacing:0;line-height:1;
331
+ cursor:pointer;transition:color .15s,background .15s,border-color .15s}
332
+ .rv svg{width:16px;height:16px;flex:none}
333
+ .rv svg circle{transition:fill .15s,stroke .15s}
334
+ .rv::before{content:"Reviewed";opacity:0;transition:opacity .15s}
335
+ .sec-head:hover>.rv::before,.rv:focus-visible::before,.rv.is-on::before{opacity:1}
336
+ .rv:hover{color:var(--ink);background:var(--hover)}
337
+ .rv:focus-visible{outline:2px solid var(--ok-ring);outline-offset:1px}
338
+ .rv.is-on{color:var(--ok)}
339
+ .rv.is-on svg circle{fill:var(--ok);stroke:var(--ok)}
340
+ .rv.is-on svg path{stroke:var(--on-ink)}
341
+ .rv.is-on:hover{color:var(--ok);background:var(--ok-soft)}
342
+ /* the document's own, in the corner of the header card: always labelled */
343
+ .rv-bar{position:absolute;top:18px;right:18px;display:flex;align-items:center;gap:10px}
344
+ .rv-count{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}
345
+ .rv-count:empty{display:none}
346
+ .rv-doc{margin:0;border-color:var(--line-2);color:var(--muted)}
347
+ .rv-doc::before{opacity:1}
348
+ .rv-doc.is-on{border-color:var(--ok-ring)}
349
+
315
350
  /* ---- prose ---- */
316
351
  .doc p{margin:0 0 18px}
317
352
  .doc>p:first-child{margin-top:6px}
@@ -323,9 +358,9 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
323
358
  .doc ul>li::marker{color:var(--accent)}
324
359
  .doc strong{font-weight:var(--semi);color:inherit}
325
360
  .doc em{font-style:italic}
326
- .doc a:not(.h-anchor){color:var(--ink);border-bottom:1px solid var(--line-2);padding-bottom:1px;
361
+ .doc a:not(.h-anchor),.lede a{color:var(--ink);border-bottom:1px solid var(--line-2);padding-bottom:1px;
327
362
  transition:border-color .12s,color .12s}
328
- .doc a:not(.h-anchor):hover{border-bottom-color:var(--accent);color:var(--accent)}
363
+ .doc a:not(.h-anchor):hover,.lede a:hover{border-bottom-color:var(--accent);color:var(--accent)}
329
364
  .doc hr{margin:32px 0;border:0;border-top:1px solid var(--line)}
330
365
  .doc blockquote{margin:0 0 18px;padding:2px 0 2px 17px;border-left:2px solid var(--accent-ring);color:var(--muted)}
331
366
  /* Inline code is a plate set into the sentence: a faint white fill that
@@ -334,12 +369,12 @@ h4.sec-head,h5.sec-head,h6.sec-head{margin:0 0 8px;font-size:14px;font-weight:va
334
369
  alone would vanish), and an ink a shade cooler than the prose, so a name
335
370
  reads as a name without shouting. Padding is in em, so the plate tracks
336
371
  the text it sits in, headings included. */
337
- .doc code{font-family:var(--mono);font-size:.84em;letter-spacing:-.01em;
372
+ .doc code,.lede code{font-family:var(--mono);font-size:.84em;letter-spacing:-.01em;
338
373
  background:var(--code-bg);box-shadow:inset 0 0 0 1px var(--code-ring);
339
374
  border-radius:5px;padding:.1em .38em .12em;color:var(--code-ink);white-space:nowrap;
340
375
  -webkit-box-decoration-break:clone;box-decoration-break:clone}
341
376
  .api code{background:var(--code-bg-on-white)}
342
- .doc a:not(.h-anchor) code{color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-ring)}
377
+ .doc a:not(.h-anchor) code,.lede a code{color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-ring)}
343
378
  .doc h1 code,.doc h2 code,.doc h3 code,.doc h4 code{font-size:.86em;font-weight:var(--semi)}
344
379
  /* a {placeholder} inside code: the braces fade, the name stays */
345
380
  .c-var{color:var(--code-var);font-style:italic}
@@ -491,6 +526,7 @@ mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:
491
526
  .docnav-btn.next{grid-column:1;text-align:left}
492
527
  .head{width:calc(100% - 20px);margin-top:14px;padding-block:34px 36px}
493
528
  .head h1{font-size:29px}
529
+ .rv-bar{position:static;margin-top:18px}
494
530
  }
495
531
  /* Not enough room beside the column to hang the arrow and the number in:
496
532
  bring them back into the heading row rather than let them fall off-screen. */
@@ -501,7 +537,7 @@ mark{background:rgba(77,163,255,.22);color:var(--ink);border-radius:3px;padding:
501
537
  @media print{
502
538
  .side{display:none}.layout{grid-template-columns:1fr}
503
539
  .head{border:0;border-radius:0;padding-block:20px;background:none}.head::before,.head::after{display:none}
504
- .sec.shut>.sec-body{display:block}.sec-tog,.h-anchor{display:none}
540
+ .sec.shut>.sec-body{display:block}.sec-tog,.h-anchor,.rv,.rv-bar{display:none}
505
541
  }
506
542
  `;
507
543
 
@@ -753,19 +789,19 @@ export const CSS_CMT = `
753
789
  A highlight and its card are two views of one comment, so hovering either
754
790
  lights both (.hot); clicking either pins the pair (.on). */
755
791
  .body-wrap{position:relative;display:flex;flex-direction:column;min-height:100vh}
756
- .cmt-hl{background:rgba(250,204,21,.16);box-shadow:inset 0 -1px 0 rgba(250,204,21,.6);border-radius:3px;padding:0 1px;
792
+ .cmt-hl{background:rgba(77,163,255,.16);box-shadow:inset 0 -1px 0 rgba(77,163,255,.6);border-radius:3px;padding:0 1px;
757
793
  color:inherit;cursor:pointer;transition:background .12s,box-shadow .12s}
758
- .cmt-hl.hot{background:rgba(250,204,21,.26);box-shadow:inset 0 -1px 0 rgba(250,204,21,.8)}
759
- .cmt-hl.on{background:rgba(250,204,21,.36);box-shadow:inset 0 -1px 0 #facc15}
794
+ .cmt-hl.hot{background:rgba(77,163,255,.26);box-shadow:inset 0 -1px 0 rgba(77,163,255,.8)}
795
+ .cmt-hl.on{background:rgba(77,163,255,.36);box-shadow:inset 0 -1px 0 var(--accent)}
760
796
  .cmt-layer{position:absolute;inset:0;pointer-events:none;z-index:2}
761
797
  .cmt-card{position:absolute;left:0;top:0;width:var(--cmt-w,264px);pointer-events:auto;padding:10px 12px;
762
798
  background:var(--surface);border:1px solid var(--line);border-radius:14px;cursor:pointer;
763
799
  font-size:12.5px;line-height:1.55;transition:border-color .15s ease,box-shadow .15s ease}
764
- .cmt-card.hot{border-color:rgba(250,204,21,.45);box-shadow:0 4px 16px rgba(0,0,0,.4)}
765
- .cmt-card.on{border-color:rgba(250,204,21,.7);box-shadow:0 6px 20px rgba(0,0,0,.5)}
800
+ .cmt-card.hot{border-color:rgba(77,163,255,.45);box-shadow:0 4px 16px rgba(0,0,0,.4)}
801
+ .cmt-card.on{border-color:rgba(77,163,255,.7);box-shadow:0 6px 20px rgba(0,0,0,.5)}
766
802
  .cmt-card[hidden]{display:none!important}
767
803
  .cmt-face{display:none}
768
- .cmt-quote{margin:0 0 7px;padding-left:9px;border-left:2px solid rgba(250,204,21,.6);color:var(--muted);
804
+ .cmt-quote{margin:0 0 7px;padding-left:9px;border-left:2px solid rgba(77,163,255,.6);color:var(--muted);
769
805
  font-size:11.5px;line-height:1.45;overflow:hidden;
770
806
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
771
807
  .cmt-text{white-space:pre-wrap;overflow-wrap:anywhere}
@@ -774,14 +810,14 @@ export const CSS_CMT = `
774
810
  .cmt-act{padding:0;border:0;background:none;color:var(--muted);font:inherit;font-size:11px;
775
811
  cursor:pointer;border-bottom:1px solid transparent}
776
812
  .cmt-act:hover{color:var(--ink);border-bottom-color:var(--ink)}
777
- .cmt-lost{margin:0 0 5px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:#e0b252}
813
+ .cmt-lost{margin:0 0 5px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)}
778
814
 
779
815
  /* tight gutters: the card shrinks to a numbered dot, opened as a popover */
780
816
  .cmt-layer.dots .cmt-card{width:22px;height:22px;padding:0;border-radius:50%;
781
- background:#3a3215;border-color:#a8862a;display:grid;place-items:center;overflow:hidden}
782
- .cmt-layer.dots .cmt-card.hot,.cmt-layer.dots .cmt-card.on{background:#53461a;border-color:#facc15}
817
+ background:#10284d;border-color:#2f6db3;display:grid;place-items:center;overflow:hidden}
818
+ .cmt-layer.dots .cmt-card.hot,.cmt-layer.dots .cmt-card.on{background:#16386b;border-color:var(--accent)}
783
819
  .cmt-layer.dots .cmt-full{display:none}
784
- .cmt-layer.dots .cmt-face{display:block;font-size:11px;font-weight:var(--bold);color:#f5d76e;
820
+ .cmt-layer.dots .cmt-face{display:block;font-size:11px;font-weight:var(--bold);color:#a9d2ff;
785
821
  font-variant-numeric:tabular-nums}
786
822
 
787
823
  /* ---- comments: floating bits ---- */