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 +1 -1
- package/skills/write-doc/SKILL.md +23 -1
- package/src/build.mjs +9 -3
- package/src/client/app.mjs +175 -5
- package/src/client/css.mjs +53 -17
- package/src/client/diagrams.mjs +121 -26
- package/src/client/live.mjs +15 -1
- package/src/collect.mjs +15 -12
- package/src/config.mjs +5 -1
- package/src/markdown.mjs +25 -10
- package/src/page.mjs +6 -5
- package/src/reviews.mjs +82 -0
- package/src/sections.mjs +19 -1
- package/src/watch.mjs +82 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lowkey-doc",
|
|
3
|
-
"version": "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
|
|
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
|
-
|
|
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
|
|
package/src/client/app.mjs
CHANGED
|
@@ -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
|
|
134
|
-
|
|
135
|
-
head.appendChild(
|
|
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('
|
|
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]; }
|
package/src/client/css.mjs
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
759
|
-
.cmt-hl.on{background:rgba(
|
|
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(
|
|
765
|
-
.cmt-card.on{border-color:rgba(
|
|
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(
|
|
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
|
|
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:#
|
|
782
|
-
.cmt-layer.dots .cmt-card.hot,.cmt-layer.dots .cmt-card.on{background:#
|
|
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:#
|
|
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 ---- */
|