entropy-machines 0.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/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- package/package.json +38 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- ===================== entropy-machines dialogue doc =====================
|
|
3
|
+
A PRD/plan/report the owner ANSWERS. bin/serve renders it, rewires the save
|
|
4
|
+
button to POST /__save so answers are written back into THIS file on disk,
|
|
5
|
+
and live-reloads the open tab when an agent writes a reply.
|
|
6
|
+
|
|
7
|
+
CONTRACT — bin/serve and the agents both depend on these:
|
|
8
|
+
* every question lives in a div with class `response` and a unique,
|
|
9
|
+
stable `data-resp` attribute, containing a label, a div with class
|
|
10
|
+
`discuss`, and one textarea.
|
|
11
|
+
* data-resp keys are unique per doc and STABLE — they are the identity an
|
|
12
|
+
agent uses to read and write a specific answer. Renaming one orphans it.
|
|
13
|
+
* a script element of type application/json with id `responses-data`,
|
|
14
|
+
near the end of the body, is where saved answers are mirrored.
|
|
15
|
+
* pages are section elements with class `page` and id `p0`, `p1`, ...,
|
|
16
|
+
each with a matching nav anchor carrying the same id in `data-page`.
|
|
17
|
+
|
|
18
|
+
A QUESTION WITHOUT A .response BOX IS INVISIBLE. Only a data-resp is saved,
|
|
19
|
+
counted, or reported as outstanding — a question written as plain prose
|
|
20
|
+
renders with nowhere to answer it and the doc reports itself as answered.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
NOTE: this comment deliberately describes the markup in prose instead of
|
|
24
|
+
showing it. It used to contain literal examples, and every regex-based tool
|
|
25
|
+
that reads these docs -- the answer counter, the session-start nag, ad-hoc
|
|
26
|
+
checks -- matched the DOCUMENTATION as though it were a real question. Each
|
|
27
|
+
doc then reported one phantom unanswered question that nobody could answer,
|
|
28
|
+
so "fully answered" was unreachable. The readers strip comments now too, but
|
|
29
|
+
the fix that matters is not putting a live example in a comment.
|
|
30
|
+
|
|
31
|
+
Visual standard (VS Code hc-black / hc-light):
|
|
32
|
+
1. Separate with BORDERS, never background tints.
|
|
33
|
+
2. Focus/hover is the focus hue as a ring, never a fill.
|
|
34
|
+
3. State is never encoded in opacity — weight, case or colour+word.
|
|
35
|
+
4. Four type steps only (14/17/21/30); mono gets its own step.
|
|
36
|
+
========================================================================= -->
|
|
37
|
+
<html lang="en"><head>
|
|
38
|
+
<meta charset="utf-8">
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
40
|
+
<title>{{TITLE}}</title>
|
|
41
|
+
<style>
|
|
42
|
+
/* High-contrast stylesheet. Palette is VS Code's hc-black / hc-light from
|
|
43
|
+
src/vs/platform/theme/common/colors/baseColors.ts — not hand-picked, so it
|
|
44
|
+
carries their AAA work rather than an invented scheme.
|
|
45
|
+
|
|
46
|
+
Three rules this file exists to hold:
|
|
47
|
+
1. Separate with BORDERS, never background tints. Backgrounds stay pure
|
|
48
|
+
#000 / #FFF. No grey panel layers, no tinted cards, no filled badges.
|
|
49
|
+
2. Focus / active / hover is the focus hue as an inset ring, never a
|
|
50
|
+
background change. :focus-visible is global and never suppressed.
|
|
51
|
+
3. State is never encoded in opacity. No faded "done", no opacity:.6 for
|
|
52
|
+
secondary text — weight, uppercase, or an underline instead. */
|
|
53
|
+
|
|
54
|
+
:root{
|
|
55
|
+
--bg:#FFFFFF; --fg:#292929;
|
|
56
|
+
--border:#0F4A85; /* contrastBorder */
|
|
57
|
+
--focus:#006BBD; /* focusBorder */
|
|
58
|
+
--link:#0F4A85;
|
|
59
|
+
--negative:#A81C0B; --positive:#0A5C21; --caution:#7A4A00; --accent:#6B21A8;
|
|
60
|
+
|
|
61
|
+
/* Four steps. Nothing below 14px, no half-pixels, no em ratios that compute
|
|
62
|
+
to fractions. Monospace gets its OWN step rather than a .9em of body,
|
|
63
|
+
because mono reads larger than sans at equal px.
|
|
64
|
+
|
|
65
|
+
REVISED 2026-08-06 after the first doc shipped and read as flat. The old
|
|
66
|
+
scale was 12/14/16/20: body BELOW the browser's 16px default, and h2 only
|
|
67
|
+
1.14x body, so a page of prose + inline code came out as one grey size
|
|
68
|
+
with noise in it. The steps are now ~1.25x apart with a bigger jump to
|
|
69
|
+
the title, which is what makes a heading read as a heading. */
|
|
70
|
+
--fs-sm:14px; /* meta, ids, mono, chips, eyebrow labels, badges */
|
|
71
|
+
--fs-base:17px; /* body, list rows, controls */
|
|
72
|
+
--fs-head:21px; /* section headings */
|
|
73
|
+
--fs-title:30px; /* page / panel title */
|
|
74
|
+
|
|
75
|
+
/* Prose measure. Tables and code want the full column; running text does
|
|
76
|
+
not — 17px across a 1040px main is ~105 characters, well past the point
|
|
77
|
+
where the eye loses the next line. */
|
|
78
|
+
--measure:70ch;
|
|
79
|
+
|
|
80
|
+
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
81
|
+
--mono:"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
|
|
82
|
+
--radius:0;
|
|
83
|
+
}
|
|
84
|
+
@media (prefers-color-scheme:dark){
|
|
85
|
+
:root{
|
|
86
|
+
--bg:#000000; --fg:#FFFFFF;
|
|
87
|
+
--border:#6FC3DF; --focus:#F38518; --link:#21A6FF;
|
|
88
|
+
--negative:#F48771; --positive:#23D18B; --caution:#F5F543; --accent:#D670D6;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/* The toggle stamps data-theme on the root; it must win over the media query
|
|
92
|
+
in BOTH directions, so each theme is restated rather than only the dark one. */
|
|
93
|
+
:root[data-theme="light"]{
|
|
94
|
+
--bg:#FFFFFF; --fg:#292929; --border:#0F4A85; --focus:#006BBD; --link:#0F4A85;
|
|
95
|
+
--negative:#A81C0B; --positive:#0A5C21; --caution:#7A4A00; --accent:#6B21A8;
|
|
96
|
+
}
|
|
97
|
+
:root[data-theme="dark"]{
|
|
98
|
+
--bg:#000000; --fg:#FFFFFF; --border:#6FC3DF; --focus:#F38518; --link:#21A6FF;
|
|
99
|
+
--negative:#F48771; --positive:#23D18B; --caution:#F5F543; --accent:#D670D6;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
*{box-sizing:border-box}
|
|
103
|
+
html{font-size:16px;}
|
|
104
|
+
body{font-family:var(--font); font-size:var(--fs-base); line-height:1.65;
|
|
105
|
+
color:var(--fg); background:var(--bg); margin:0; display:flex; min-height:100vh;}
|
|
106
|
+
|
|
107
|
+
/* Rule 2, globally. Never suppressed anywhere below. */
|
|
108
|
+
:focus-visible{outline:2px solid var(--focus); outline-offset:1px;}
|
|
109
|
+
|
|
110
|
+
code{font-family:var(--mono); font-size:var(--fs-sm); background:none;
|
|
111
|
+
border:1px solid var(--border); padding:0 .25em;}
|
|
112
|
+
pre{background:none; border:1px solid var(--border); padding:12px 14px; overflow-x:auto;
|
|
113
|
+
font-family:var(--mono); font-size:var(--fs-sm); line-height:1.55;}
|
|
114
|
+
pre code{border:none; padding:0;}
|
|
115
|
+
|
|
116
|
+
nav{width:296px; flex:none; border-right:1px solid var(--border); background:var(--bg);
|
|
117
|
+
padding:22px 14px; position:sticky; top:0; height:100vh; overflow-y:auto;}
|
|
118
|
+
nav .brand{display:flex; align-items:center; gap:8px; font-weight:700;
|
|
119
|
+
font-size:var(--fs-base); margin-bottom:16px; padding:0 6px;}
|
|
120
|
+
nav .brand .logo{width:18px; height:18px; border:1px solid var(--border); background:none;}
|
|
121
|
+
nav a{display:block; padding:6px 10px; color:var(--fg); text-decoration:none;
|
|
122
|
+
font-size:var(--fs-base); margin-bottom:2px; border:1px solid transparent;}
|
|
123
|
+
nav a:hover{box-shadow:inset 0 0 0 2px var(--focus);}
|
|
124
|
+
/* Current page: a border and weight, not a fill. */
|
|
125
|
+
nav a.cur{border-color:var(--border); font-weight:700;}
|
|
126
|
+
nav .grp{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
|
|
127
|
+
letter-spacing:.06em; color:var(--fg); margin:16px 6px 6px;}
|
|
128
|
+
nav .foot{font-size:var(--fs-sm); color:var(--fg); margin-top:20px; padding:0 6px;}
|
|
129
|
+
|
|
130
|
+
/* serve.py injects #__navmark-css late in <body>, so it wins on source order
|
|
131
|
+
at equal specificity. These selectors carry one extra type each to beat it.
|
|
132
|
+
Its defaults are off-standard three ways: .72rem (11.52px, a fifth size),
|
|
133
|
+
a hardcoded #fdf6e3 fill, and pill radii — a filled badge is a background
|
|
134
|
+
tint, which rule 1 forbids. It also references --good/--warn-deep/--muted/
|
|
135
|
+
--code-bg, which this palette does not define, so its colours fall back to
|
|
136
|
+
inherit. Do not delete its block from the file: it is re-injected on serve. */
|
|
137
|
+
nav a span.navmark{font-size:var(--fs-sm); font-weight:700; line-height:1.4;
|
|
138
|
+
background:none; border-radius:0; padding:0 .35em; margin-left:.4em;
|
|
139
|
+
border:1px solid currentColor; color:var(--caution);}
|
|
140
|
+
nav a span.navmark.done{color:var(--positive); border-color:var(--positive);}
|
|
141
|
+
nav a span.navmark.todo{color:var(--caution); border-color:var(--caution); background:none;}
|
|
142
|
+
nav div.navsummary{font-size:var(--fs-sm); font-weight:700; color:var(--fg);
|
|
143
|
+
background:none; border:1px solid var(--border); border-radius:0;
|
|
144
|
+
margin:12px 6px 4px; padding:5px 9px;}
|
|
145
|
+
nav div.navsummary .all-done{color:var(--positive);}
|
|
146
|
+
|
|
147
|
+
main{flex:1; min-width:0; padding:38px 42px 120px; max-width:1060px;}
|
|
148
|
+
.page{display:none;} .page.cur{display:block;}
|
|
149
|
+
|
|
150
|
+
h1{font-size:var(--fs-title); font-weight:700; letter-spacing:-.015em;
|
|
151
|
+
line-height:1.2; margin:2px 0 8px;}
|
|
152
|
+
h2{font-size:var(--fs-head); font-weight:700; line-height:1.3; margin:34px 0 8px;}
|
|
153
|
+
/* Same step as h2, distinguished by case rather than a fifth size. */
|
|
154
|
+
h3{font-size:var(--fs-head); font-weight:700; text-transform:uppercase;
|
|
155
|
+
letter-spacing:.03em; line-height:1.3; margin:24px 0 6px;}
|
|
156
|
+
h1 code,h2 code,h3 code{font-size:var(--fs-head);}
|
|
157
|
+
.sub{font-size:var(--fs-base); margin:0 0 20px;}
|
|
158
|
+
p{margin:11px 0;}
|
|
159
|
+
main a{color:var(--link); text-decoration:underline;}
|
|
160
|
+
|
|
161
|
+
/* Measure. Tables, code and the response boxes keep the full column; running
|
|
162
|
+
text is capped so the eye finds the next line. Applied to the block, not
|
|
163
|
+
to `main`, so a wide table is never squeezed to prose width. */
|
|
164
|
+
.page > p, .page > ul, .page > ol, .page > .sub, .callout, blockquote{
|
|
165
|
+
max-width:var(--measure);}
|
|
166
|
+
|
|
167
|
+
/* Rule 1: every variant is the same pure background plus a border. The kind
|
|
168
|
+
is carried by the tag's colour and the left border, not by a wash. */
|
|
169
|
+
.callout{padding:12px 16px; margin:16px 0; border:1px solid var(--border); background:none;}
|
|
170
|
+
.callout.key{border-left:3px solid var(--accent);}
|
|
171
|
+
.callout.good{border-left:3px solid var(--positive);}
|
|
172
|
+
.callout.warn{border-left:3px solid var(--caution);}
|
|
173
|
+
.callout.bad{border-left:3px solid var(--negative);}
|
|
174
|
+
.callout .tag{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
|
|
175
|
+
letter-spacing:.05em; display:block; margin-bottom:4px;}
|
|
176
|
+
.callout.key .tag{color:var(--accent);} .callout.good .tag{color:var(--positive);}
|
|
177
|
+
.callout.warn .tag{color:var(--caution);} .callout.bad .tag{color:var(--negative);}
|
|
178
|
+
|
|
179
|
+
.tbl-wrap{overflow-x:auto; margin:18px 0;}
|
|
180
|
+
table{border-collapse:collapse; width:100%; font-size:var(--fs-base);}
|
|
181
|
+
th,td{padding:10px 12px; text-align:left; border:1px solid var(--border); vertical-align:top;}
|
|
182
|
+
thead th{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
|
|
183
|
+
letter-spacing:.04em; color:var(--fg); white-space:nowrap;}
|
|
184
|
+
td code{font-size:var(--fs-sm);}
|
|
185
|
+
|
|
186
|
+
ul,ol{margin:10px 0; padding-left:24px;} li{margin:7px 0;}
|
|
187
|
+
.foot{font-size:var(--fs-sm);}
|
|
188
|
+
|
|
189
|
+
.rec{border:1px solid var(--border); border-left:3px solid var(--positive);
|
|
190
|
+
padding:8px 13px; margin:13px 0;}
|
|
191
|
+
.rec .lbl{font-size:var(--fs-sm); font-weight:700; text-transform:uppercase;
|
|
192
|
+
letter-spacing:.05em; color:var(--positive); display:block; margin-bottom:3px;}
|
|
193
|
+
|
|
194
|
+
/* Chips are outlined, never filled — a filled badge is a background tint. */
|
|
195
|
+
.pill{display:inline-block; font-size:var(--fs-sm); font-weight:700; padding:1px 8px;
|
|
196
|
+
border:1px solid var(--border); background:none; color:var(--fg); vertical-align:middle;}
|
|
197
|
+
.pill.dec{color:var(--caution); border-color:var(--caution);}
|
|
198
|
+
.pill.red{color:var(--negative); border-color:var(--negative);}
|
|
199
|
+
.pill.grn{color:var(--positive); border-color:var(--positive);}
|
|
200
|
+
|
|
201
|
+
blockquote{border-left:3px solid var(--border); margin:10px 0; padding-left:13px;
|
|
202
|
+
font-size:var(--fs-base);}
|
|
203
|
+
|
|
204
|
+
.sev{font-weight:700;}
|
|
205
|
+
.sev.r{color:var(--negative);} .sev.o{color:var(--caution);} .sev.y{color:var(--caution);}
|
|
206
|
+
.verdict{font-weight:700; white-space:nowrap;}
|
|
207
|
+
.verdict.clean{color:var(--positive);} .verdict.tangled{color:var(--caution);}
|
|
208
|
+
.verdict.bound{color:var(--negative);}
|
|
209
|
+
|
|
210
|
+
/* ---- response boxes: page-level ----
|
|
211
|
+
min-height:52px and overflow:hidden are load-bearing for the skill's
|
|
212
|
+
auto-grow (it measures scrollHeight); do not "tidy" them away. */
|
|
213
|
+
.response{margin:16px 0 6px; border:1px solid var(--border);
|
|
214
|
+
border-left:3px solid var(--caution); background:none; padding:9px 13px;}
|
|
215
|
+
.response label{display:block; font-size:var(--fs-sm); font-weight:700;
|
|
216
|
+
text-transform:uppercase; letter-spacing:.05em; color:var(--caution); margin-bottom:5px;}
|
|
217
|
+
.response .discuss{margin:0 0 10px; padding:9px 12px; border:1px solid var(--border);
|
|
218
|
+
border-left:2px solid var(--accent); background:none;
|
|
219
|
+
font-size:var(--fs-base); line-height:1.55; color:var(--fg);}
|
|
220
|
+
.response .discuss code{font-size:var(--fs-sm);}
|
|
221
|
+
.response textarea{width:100%; min-height:52px; resize:vertical; font-family:var(--font);
|
|
222
|
+
font-size:var(--fs-base); color:var(--fg); background:var(--bg);
|
|
223
|
+
border:1px solid var(--border); padding:7px 10px; line-height:1.5; overflow:hidden;}
|
|
224
|
+
.response textarea:focus{outline:none; box-shadow:inset 0 0 0 2px var(--focus);}
|
|
225
|
+
/* Answered state: colour + weight on the label, never a fade or a fill. */
|
|
226
|
+
.response.filled{border-left-color:var(--positive);}
|
|
227
|
+
.response.filled label{color:var(--positive);}
|
|
228
|
+
|
|
229
|
+
/* ---- response boxes: row-level (compact) ----
|
|
230
|
+
1.9em height is the skill's explicit override of the 52px above; keep it. */
|
|
231
|
+
tr.row-note td{padding:3px 9px 9px; border:1px solid var(--border);}
|
|
232
|
+
tr.row-note .response.mini{margin:0; border:none; background:none; padding:0;}
|
|
233
|
+
tr.row-note .response.mini textarea{display:block; width:100%; min-height:0; height:1.9em;
|
|
234
|
+
resize:none; overflow:hidden; font-family:var(--font); font-size:var(--fs-base);
|
|
235
|
+
line-height:1.4; color:var(--fg); background:var(--bg);
|
|
236
|
+
border:1px solid var(--border); padding:4px 9px;}
|
|
237
|
+
tr.row-note .response.mini textarea:focus{color:var(--fg);
|
|
238
|
+
box-shadow:inset 0 0 0 2px var(--focus); border-color:var(--focus);}
|
|
239
|
+
tr.row-note .response.mini.filled textarea{border-color:var(--positive);}
|
|
240
|
+
|
|
241
|
+
.savebar{position:fixed; bottom:14px; right:19px; display:flex; align-items:center;
|
|
242
|
+
gap:11px; z-index:50; background:var(--bg); border:1px solid var(--border);
|
|
243
|
+
padding:7px 9px 7px 16px;}
|
|
244
|
+
.savebar .stat{font-size:var(--fs-sm); color:var(--fg);}
|
|
245
|
+
.savebar button{font-family:var(--font); font-size:var(--fs-base); font-weight:700;
|
|
246
|
+
cursor:pointer; border:1px solid var(--border); background:none; color:var(--fg);
|
|
247
|
+
padding:6px 14px;}
|
|
248
|
+
.savebar button:hover{box-shadow:inset 0 0 0 2px var(--focus);}
|
|
249
|
+
.savebar button.ghost{border-color:var(--border); color:var(--fg); padding:6px 10px;}
|
|
250
|
+
</style>
|
|
251
|
+
</head><body>
|
|
252
|
+
|
|
253
|
+
<nav>
|
|
254
|
+
<div class="brand"><span class="logo"></span> {{DOC_SHORT_NAME}}</div>
|
|
255
|
+
<div class="grp">{{Section name}}</div>
|
|
256
|
+
<a href="#p0" data-page="p0" class="cur">0 · {{page title}}</a>
|
|
257
|
+
<div class="foot">{{How to use this doc, and what happens after it is answered.}}</div>
|
|
258
|
+
</nav>
|
|
259
|
+
|
|
260
|
+
<main>
|
|
261
|
+
|
|
262
|
+
<section class="page cur" id="p0">
|
|
263
|
+
<h1>{{Page heading}}</h1>
|
|
264
|
+
<p class="sub">{{one-line subtitle}}</p>
|
|
265
|
+
|
|
266
|
+
<p>{{Opening context.}}</p>
|
|
267
|
+
|
|
268
|
+
<div class="callout key"><span class="tag">{{TAG}}</span>
|
|
269
|
+
{{The single most important claim on this page.}}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<!-- The .discuss is not filler. State what is being decided, name the real
|
|
273
|
+
options and their costs, and end on a concrete question. If you cannot
|
|
274
|
+
write a real one, the box has not earned its place on the page. -->
|
|
275
|
+
<div class="response" data-resp="p0-question">
|
|
276
|
+
<label>{{Short label}}</label>
|
|
277
|
+
<div class="discuss">{{What is at stake, the options with one line of cost
|
|
278
|
+
each, which you would take and why, ending in a question.}}</div>
|
|
279
|
+
<textarea placeholder="Type your answer…"></textarea>
|
|
280
|
+
</div>
|
|
281
|
+
</section>
|
|
282
|
+
|
|
283
|
+
</main>
|
|
284
|
+
|
|
285
|
+
</main>
|
|
286
|
+
|
|
287
|
+
<div class="savebar">
|
|
288
|
+
<span class="stat" id="saveStat">Unsaved changes…</span>
|
|
289
|
+
<button class="ghost" id="clearBtn" title="Clear all responses">✕</button>
|
|
290
|
+
<button id="saveBtn">💾 Save</button>
|
|
291
|
+
</div>
|
|
292
|
+
<script type="application/json" id="responses-data">{}</script>
|
|
293
|
+
<script>
|
|
294
|
+
(function(){
|
|
295
|
+
var links=[].slice.call(document.querySelectorAll('nav a[data-page]'));
|
|
296
|
+
function show(id){
|
|
297
|
+
document.querySelectorAll('.page').forEach(function(p){ p.classList.toggle('cur', p.id===id); });
|
|
298
|
+
links.forEach(function(l){ l.classList.toggle('cur', l.dataset.page===id); });
|
|
299
|
+
try{ history.replaceState(null,'','#'+id); }catch(e){}
|
|
300
|
+
window.scrollTo(0,0);
|
|
301
|
+
document.querySelectorAll('#'+id+' .response textarea').forEach(function(t){ autoGrow(t); });
|
|
302
|
+
}
|
|
303
|
+
links.forEach(function(l){ l.addEventListener('click', function(ev){ ev.preventDefault(); show(l.dataset.page); }); });
|
|
304
|
+
if (location.hash && document.getElementById(location.hash.slice(1))) show(location.hash.slice(1));
|
|
305
|
+
// Required: without this, a deep link (#p2) does nothing when the tab is ALREADY
|
|
306
|
+
// open — the URL changes but the reader stays on page 0. show() uses
|
|
307
|
+
// history.replaceState, which does not fire hashchange, so there's no loop.
|
|
308
|
+
window.addEventListener('hashchange', function(){ var h=location.hash.slice(1); if(h && document.getElementById(h)) show(h); });
|
|
309
|
+
var KEY='{{DOC_KEY}}'; // CUSTOMIZE: unique per doc so localStorage never collides across docs
|
|
310
|
+
var boxes=[].slice.call(document.querySelectorAll('.response'));
|
|
311
|
+
var stat=document.getElementById('saveStat');
|
|
312
|
+
var dataEl=document.getElementById('responses-data');
|
|
313
|
+
var fileHandle=null;
|
|
314
|
+
function ta(b){return b.querySelector('textarea');}
|
|
315
|
+
function key(b){return b.getAttribute('data-resp');}
|
|
316
|
+
function markFilled(b){ if(ta(b).value.trim()) b.classList.add('filled'); else b.classList.remove('filled'); }
|
|
317
|
+
function autoGrow(el){ el.style.height='auto'; el.style.height=el.scrollHeight+'px'; }
|
|
318
|
+
function load(){
|
|
319
|
+
var saved={};
|
|
320
|
+
try{ saved=JSON.parse(dataEl.textContent||'{}')||{}; }catch(e){}
|
|
321
|
+
try{ var ls=JSON.parse(localStorage.getItem(KEY)||'{}'); Object.keys(ls).forEach(function(k){ if(ls[k]&&ls[k].trim()) saved[k]=ls[k]; }); }catch(e){}
|
|
322
|
+
boxes.forEach(function(b){ var v=saved[key(b)]; if(typeof v==='string') ta(b).value=v; markFilled(b); autoGrow(ta(b)); });
|
|
323
|
+
}
|
|
324
|
+
function collect(){ var o={}; boxes.forEach(function(b){ o[key(b)]=ta(b).value; }); return o; }
|
|
325
|
+
function autosave(){ try{ localStorage.setItem(KEY,JSON.stringify(collect())); }catch(e){} }
|
|
326
|
+
document.getElementById('saveBtn').addEventListener('click',async function(){
|
|
327
|
+
dataEl.textContent=JSON.stringify(collect(),null,2);
|
|
328
|
+
boxes.forEach(function(b){ ta(b).textContent=ta(b).value; });
|
|
329
|
+
var html='<!doctype html>\n'+document.documentElement.outerHTML;
|
|
330
|
+
if(window.showSaveFilePicker){
|
|
331
|
+
try{
|
|
332
|
+
if(!fileHandle) fileHandle=await window.showSaveFilePicker({suggestedName:'{{DOC_KEY}}.html',types:[{description:'HTML',accept:{'text/html':['.html']}}]});
|
|
333
|
+
var w=await fileHandle.createWritable(); await w.write(html); await w.close();
|
|
334
|
+
stat.textContent='Saved to file'; return;
|
|
335
|
+
}catch(err){ if(err&&err.name==='AbortError'){stat.textContent='Save cancelled';return;} }
|
|
336
|
+
}
|
|
337
|
+
var blob=new Blob([html],{type:'text/html'}); var a=document.createElement('a');
|
|
338
|
+
a.href=URL.createObjectURL(blob); a.download='{{DOC_KEY}}.html';
|
|
339
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
340
|
+
stat.textContent='Downloaded — overwrite the original';
|
|
341
|
+
});
|
|
342
|
+
document.getElementById('clearBtn').addEventListener('click',function(){
|
|
343
|
+
if(!confirm('Clear all responses?'))return;
|
|
344
|
+
boxes.forEach(function(b){ ta(b).value=''; markFilled(b); autoGrow(ta(b)); });
|
|
345
|
+
try{ localStorage.removeItem(KEY); }catch(e){}
|
|
346
|
+
dataEl.textContent='{}'; stat.textContent='Cleared';
|
|
347
|
+
});
|
|
348
|
+
boxes.forEach(function(b){ ta(b).addEventListener('input',function(){ markFilled(b); autoGrow(ta(b)); autosave(); stat.textContent='Unsaved changes…'; }); });
|
|
349
|
+
load();
|
|
350
|
+
window.addEventListener('resize',function(){ boxes.forEach(function(b){ autoGrow(ta(b)); }); });
|
|
351
|
+
})();
|
|
352
|
+
</script>
|
|
353
|
+
<script id="__disk-save-patch" data-ds="2">
|
|
354
|
+
(function(){
|
|
355
|
+
var btn=document.getElementById('saveBtn');
|
|
356
|
+
var stat=document.getElementById('saveStat');
|
|
357
|
+
if(!btn) return;
|
|
358
|
+
var b=btn.cloneNode(true); btn.parentNode.replaceChild(b, btn);
|
|
359
|
+
function collect(){ var o={}; document.querySelectorAll('.response[data-resp]').forEach(function(el){ var t=el.querySelector('textarea'); if(t) o[el.getAttribute('data-resp')]=t.value; }); return o; }
|
|
360
|
+
var file=(location.pathname.split('/').pop()||'').split('?')[0];
|
|
361
|
+
b.addEventListener('click', async function(){
|
|
362
|
+
var data=collect();
|
|
363
|
+
if(stat) stat.textContent='Saving to disk…';
|
|
364
|
+
try{
|
|
365
|
+
var res=await fetch('/__save?file='+encodeURIComponent(file),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
|
|
366
|
+
if(res.ok){
|
|
367
|
+
if(stat) stat.textContent='Saved to disk ✓';
|
|
368
|
+
// Re-baseline the page's idea of what is on disk. Without this,
|
|
369
|
+
// responses-data still holds what the doc was SERVED with, so anything
|
|
370
|
+
// comparing the boxes against it reads every saved answer as an unsaved
|
|
371
|
+
// edit for the rest of the session — which made the live-reload patch
|
|
372
|
+
// refuse to auto-reload and tell the reader to 💾 something they had
|
|
373
|
+
// just saved.
|
|
374
|
+
try{
|
|
375
|
+
var el=document.getElementById('responses-data');
|
|
376
|
+
if(el) el.textContent=JSON.stringify(data,null,2);
|
|
377
|
+
}catch(_){}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if(stat) stat.textContent='Save failed ('+res.status+')';
|
|
381
|
+
}catch(e){
|
|
382
|
+
if(stat) stat.textContent='Server offline — downloaded a copy (start serve.py to save in place)';
|
|
383
|
+
try{
|
|
384
|
+
var dataEl=document.getElementById('responses-data');
|
|
385
|
+
if(dataEl) dataEl.textContent=JSON.stringify(data,null,2);
|
|
386
|
+
document.querySelectorAll('.response[data-resp] textarea').forEach(function(t){ t.textContent=t.value; });
|
|
387
|
+
var htmlOut='<!doctype html>\n'+document.documentElement.outerHTML;
|
|
388
|
+
var a=document.createElement('a');
|
|
389
|
+
a.href=URL.createObjectURL(new Blob([htmlOut],{type:'text/html'}));
|
|
390
|
+
a.download=file; document.body.appendChild(a); a.click(); a.remove();
|
|
391
|
+
}catch(_){}
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
})();
|
|
395
|
+
</script>
|
|
396
|
+
<style id="__todobanner-css">
|
|
397
|
+
/* Palette-aware. The first version hardcoded #fdf6e3 and pill radii, which
|
|
398
|
+
the 2026-08-07 doc standard forbids (rule 1: separate with BORDERS, never
|
|
399
|
+
background tints; no filled badges). Vars fall back for docs still on the
|
|
400
|
+
old sheet. */
|
|
401
|
+
#__todobar{ position:sticky; top:0; z-index:40; margin:0 0 1.1rem;
|
|
402
|
+
border:2px solid var(--caution,#a16207); background:var(--bg,#fff);
|
|
403
|
+
color:var(--caution,#a16207); padding:.6rem .85rem; font-size:var(--fs-base,.9rem); }
|
|
404
|
+
#__todobar[hidden]{ display:none; }
|
|
405
|
+
#__todobar .hd{ font-weight:700; text-transform:uppercase; letter-spacing:.04em;
|
|
406
|
+
font-size:var(--fs-sm,.72rem); display:block; margin-bottom:.35rem; }
|
|
407
|
+
#__todobar a{ display:inline-block; font-weight:700; color:var(--caution,#a16207);
|
|
408
|
+
background:none; border:1px solid currentColor; padding:.14rem .6rem;
|
|
409
|
+
margin:.16rem .3rem .16rem 0; text-decoration:none; }
|
|
410
|
+
#__todobar a:hover{ outline:2px solid var(--focus,#006BBD); outline-offset:1px; }
|
|
411
|
+
</style>
|
|
412
|
+
<script id="__todobanner-patch" data-tb="2">
|
|
413
|
+
// An in-page "these need you" bar. Same source of truth as the nav marks —
|
|
414
|
+
// __janusBoxes.pendingIn (see __boxstate-patch). Clicking a chip switches to
|
|
415
|
+
// that page via the existing nav link, so it works with the doc's own router.
|
|
416
|
+
//
|
|
417
|
+
// v2: a row-note and a follow-up the conversation ran past are no longer
|
|
418
|
+
// "answers needed". v1 counted every empty textarea, so a doc with per-row
|
|
419
|
+
// note slots showed a permanent demand the owner could never clear.
|
|
420
|
+
(function(){
|
|
421
|
+
var main=document.querySelector('main'); if(!main) return;
|
|
422
|
+
var bar=document.createElement('div'); bar.id='__todobar'; bar.hidden=true;
|
|
423
|
+
main.insertBefore(bar, main.firstChild);
|
|
424
|
+
function label(link){
|
|
425
|
+
// Clone and drop the navmark counter first — reading link.textContent raw
|
|
426
|
+
// concatenates "0/1" onto the title and no amount of regex untangles
|
|
427
|
+
// "0/10 · What is actually open" reliably.
|
|
428
|
+
var c=link.cloneNode(true);
|
|
429
|
+
var m=c.querySelector('.navmark'); if(m) m.remove();
|
|
430
|
+
var t=(c.textContent||'').replace(/\s+/g,' ').trim();
|
|
431
|
+
return t.replace(/^\d+\s*·\s*/,'').replace(/\s*⚑\s*$/,'').trim() || link.dataset.page;
|
|
432
|
+
}
|
|
433
|
+
function refresh(){
|
|
434
|
+
var pending=[];
|
|
435
|
+
[].slice.call(document.querySelectorAll('nav a[data-page]')).forEach(function(link){
|
|
436
|
+
var page=document.getElementById(link.dataset.page); if(!page) return;
|
|
437
|
+
if(!window.__janusBoxes) return; // helper not loaded yet — deferred pass runs
|
|
438
|
+
var n=window.__janusBoxes.pendingIn(page).length;
|
|
439
|
+
if(n) pending.push({id:link.dataset.page, n:n, text:label(link)});
|
|
440
|
+
});
|
|
441
|
+
if(!pending.length){ bar.hidden=true; bar.innerHTML=''; return; }
|
|
442
|
+
var total=pending.reduce(function(a,p){ return a+p.n; },0);
|
|
443
|
+
bar.hidden=false;
|
|
444
|
+
bar.innerHTML='<span class="hd">'+total+(total===1?' answer needed':' answers needed')+'</span>'+
|
|
445
|
+
pending.map(function(p){
|
|
446
|
+
return '<a href="#'+p.id+'" data-goto="'+p.id+'">'+p.text+(p.n>1?' ('+p.n+')':'')+'</a>';
|
|
447
|
+
}).join('');
|
|
448
|
+
}
|
|
449
|
+
bar.addEventListener('click', function(e){
|
|
450
|
+
var a=e.target.closest('a[data-goto]'); if(!a) return;
|
|
451
|
+
e.preventDefault();
|
|
452
|
+
var nav=document.querySelector('nav a[data-page="'+a.dataset.goto+'"]');
|
|
453
|
+
if(nav) nav.click();
|
|
454
|
+
// Focus the first box that actually WANTS an answer — not merely the first
|
|
455
|
+
// empty one, which on a doc with row-notes is a note slot nobody owes.
|
|
456
|
+
var page=document.getElementById(a.dataset.goto);
|
|
457
|
+
var want=(page && window.__janusBoxes) ? window.__janusBoxes.pendingIn(page) : [];
|
|
458
|
+
var target=(want[0] && want[0].querySelector('textarea')) ||
|
|
459
|
+
document.querySelector('#'+a.dataset.goto+' .response[data-resp] textarea');
|
|
460
|
+
if(target){ target.scrollIntoView({block:'center'}); target.focus(); }
|
|
461
|
+
});
|
|
462
|
+
document.querySelectorAll('.response[data-resp] textarea').forEach(function(t){
|
|
463
|
+
t.addEventListener('input', refresh);
|
|
464
|
+
});
|
|
465
|
+
document.addEventListener('click', function(e){
|
|
466
|
+
var id=e.target && e.target.id;
|
|
467
|
+
if(id==='saveBtn'||id==='clearBtn') setTimeout(refresh,60);
|
|
468
|
+
});
|
|
469
|
+
refresh();
|
|
470
|
+
// See the same two hooks in __navmark-patch: on an already-patched doc the
|
|
471
|
+
// boxstate helper is appended AFTER this block, so the parse-time pass above
|
|
472
|
+
// finds nothing and the deferred passes do the work.
|
|
473
|
+
document.addEventListener('DOMContentLoaded', refresh);
|
|
474
|
+
setTimeout(refresh, 0);
|
|
475
|
+
})();
|
|
476
|
+
</script>
|
|
477
|
+
<style id="__navmark-css">
|
|
478
|
+
nav a[data-page]{ overflow:hidden; }
|
|
479
|
+
nav a .navmark{ float:right; font-size:var(--fs-sm,12px); font-weight:700; line-height:1.5; margin-left:.4rem; }
|
|
480
|
+
nav a .navmark.done{ color:var(--good); }
|
|
481
|
+
nav a .navmark.todo{ color:var(--warn-deep); background:#fdf6e3; padding:.02rem .34rem; border-radius:99px; }
|
|
482
|
+
@media (prefers-color-scheme:dark){ nav a .navmark.todo{ background:#241d10; } }
|
|
483
|
+
nav .navsummary{ font-size:var(--fs-sm,12px); font-weight:700; color:var(--muted); margin:.5rem .4rem .2rem; padding:.3rem .55rem; border-radius:6px; background:var(--code-bg); }
|
|
484
|
+
nav .navsummary .all-done{ color:var(--good); }
|
|
485
|
+
</style>
|
|
486
|
+
<script id="__navmark-patch" data-nm="2">
|
|
487
|
+
// Live per-section review state in the sidebar. Independent of the main doc
|
|
488
|
+
// script — listens to input/save/clear.
|
|
489
|
+
//
|
|
490
|
+
// v2: counts come from __janusBoxes (see __boxstate-patch), so a row-note and
|
|
491
|
+
// a follow-up the conversation ran past no longer read as an unanswered
|
|
492
|
+
// question. v1 counted every empty textarea and told the owner a finished doc
|
|
493
|
+
// still needed him.
|
|
494
|
+
(function(){
|
|
495
|
+
var links=[].slice.call(document.querySelectorAll('nav a[data-page]'));
|
|
496
|
+
function ensure(link){ var m=link.querySelector('.navmark'); if(!m){ m=document.createElement('span'); m.className='navmark'; link.insertBefore(m, link.firstChild); } return m; }
|
|
497
|
+
var summary=null;
|
|
498
|
+
var foot=document.querySelector('nav .foot');
|
|
499
|
+
if(foot){ summary=document.createElement('div'); summary.className='navsummary'; foot.parentNode.insertBefore(summary, foot); }
|
|
500
|
+
function refresh(){
|
|
501
|
+
var done=0, withBoxes=0;
|
|
502
|
+
links.forEach(function(link){
|
|
503
|
+
var page=document.getElementById(link.dataset.page);
|
|
504
|
+
if(!page) return;
|
|
505
|
+
var c=(window.__janusBoxes ? window.__janusBoxes.countsIn(page) : null);
|
|
506
|
+
if(!c){ return; } // helper not loaded yet — the deferred pass will run
|
|
507
|
+
var m=link.querySelector('.navmark');
|
|
508
|
+
if(c.total===0){ if(m) m.parentNode.removeChild(m); return; }
|
|
509
|
+
withBoxes++;
|
|
510
|
+
m=ensure(link);
|
|
511
|
+
// A page with nothing PENDING is done even when a tail follow-up sits
|
|
512
|
+
// empty — that box is answered-past, not owed.
|
|
513
|
+
if(c.pending===0){ m.textContent='✓'; m.className='navmark done'; done++; }
|
|
514
|
+
else { m.textContent=c.filled+'/'+c.total; m.className='navmark todo'; }
|
|
515
|
+
});
|
|
516
|
+
if(summary){
|
|
517
|
+
if(!withBoxes){ summary.textContent=''; }
|
|
518
|
+
else if(done===withBoxes){ summary.innerHTML='<span class="all-done">✓ all '+withBoxes+' sections answered</span>'; }
|
|
519
|
+
else { summary.textContent='Answered '+done+'/'+withBoxes+' sections'; }
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
document.querySelectorAll('.response[data-resp] textarea').forEach(function(t){ t.addEventListener('input', refresh); });
|
|
523
|
+
document.addEventListener('click', function(e){ var id=e.target && e.target.id; if(id==='saveBtn'||id==='clearBtn'){ setTimeout(refresh,60); } });
|
|
524
|
+
refresh();
|
|
525
|
+
// An ALREADY-PATCHED doc gets __boxstate-patch appended after this block, so
|
|
526
|
+
// the helper does not exist during the parse-time pass above. Both hooks are
|
|
527
|
+
// cheap and idempotent.
|
|
528
|
+
document.addEventListener('DOMContentLoaded', refresh);
|
|
529
|
+
setTimeout(refresh, 0);
|
|
530
|
+
})();
|
|
531
|
+
</script>
|
|
532
|
+
<style id="__trackernav-css">
|
|
533
|
+
/* Borders, never tints (2026-08-07 doc standard). Vars fall back for docs
|
|
534
|
+
still on the old sheet. */
|
|
535
|
+
nav .tracknav{ display:flex; flex-wrap:wrap; gap:.35rem; margin:.45rem .4rem .6rem; }
|
|
536
|
+
nav .tracknav a{ font-size:var(--fs-sm,12px); font-weight:700; text-decoration:none;
|
|
537
|
+
color:var(--muted,#71717a); border:1px solid var(--border,#ddd6fe);
|
|
538
|
+
padding:.1rem .5rem; }
|
|
539
|
+
nav .tracknav a:hover{ color:var(--accent,#7c3aed); border-color:currentColor; }
|
|
540
|
+
</style>
|
|
541
|
+
<script id="__trackernav-patch">
|
|
542
|
+
// Top-of-sidebar links back to the tracker pages, mirroring the header nav
|
|
543
|
+
// those pages carry (docs ↔ issues ↔ questions ↔ plan). Inserted after the
|
|
544
|
+
// nav brand; silently does nothing on a page without one (generated pages
|
|
545
|
+
// have their own header nav).
|
|
546
|
+
(function(){
|
|
547
|
+
var brand=document.querySelector('nav .brand'); if(!brand) return;
|
|
548
|
+
var row=document.createElement('div'); row.className='tracknav';
|
|
549
|
+
row.innerHTML='<a href="DOCS.html">docs</a><a href="TRACKER.html">issues</a>'+
|
|
550
|
+
'<a href="INDEX.html">questions</a><a href="WORKSTREAMS.html">plan</a>';
|
|
551
|
+
brand.parentNode.insertBefore(row, brand.nextSibling);
|
|
552
|
+
})();
|
|
553
|
+
</script>
|
|
554
|
+
<style id="__livereload-css">
|
|
555
|
+
/* Borders, never tints (2026-08-07 doc standard). */
|
|
556
|
+
#__replybar{ position:fixed; left:50%; transform:translateX(-50%); bottom:14px; z-index:60;
|
|
557
|
+
display:flex; align-items:center; gap:.7rem; background:var(--bg,#fff);
|
|
558
|
+
border:2px solid var(--positive,#0A5C21); color:var(--positive,#0A5C21);
|
|
559
|
+
padding:.5rem .8rem; font-size:var(--fs-base,.95rem); font-weight:700; }
|
|
560
|
+
#__replybar[hidden]{ display:none; }
|
|
561
|
+
#__replybar button{ font: inherit; font-weight:700; cursor:pointer; color:inherit;
|
|
562
|
+
background:none; border:1px solid currentColor; padding:.2rem .7rem; }
|
|
563
|
+
#__replybar button:hover{ outline:2px solid var(--focus,#006BBD); outline-offset:1px; }
|
|
564
|
+
#__replybar .dismiss{ border:none; font-weight:400; }
|
|
565
|
+
</style>
|
|
566
|
+
<script id="__livereload-patch" data-lr="2">
|
|
567
|
+
// "I replied" signal. Without it the reader has no way to learn that a fold
|
|
568
|
+
// landed except being told in chat — which was the last manual link in the
|
|
569
|
+
// loop (owner, 2026-08-09: "otherwise I won't know that you've responded").
|
|
570
|
+
//
|
|
571
|
+
// Polls /__docversion for THIS file and watches the REVIEW SIGNATURE — a hash
|
|
572
|
+
// of the folded <aside class="review"> blocks — not the version.
|
|
573
|
+
//
|
|
574
|
+
// It watched the version first, and that was wrong in the most annoying
|
|
575
|
+
// possible way: a 💾 bumps the version, so saving your own answer announced
|
|
576
|
+
// "New reply — reload to read it" and the reload showed nothing new, because
|
|
577
|
+
// nothing was. Reported by the owner 2026-08-11 ("I never actually see replies
|
|
578
|
+
// upon reload"). The signature moves when a reply is added or rewritten and
|
|
579
|
+
// stays put for anything the reader does to their own boxes, which is the
|
|
580
|
+
// question the banner claims to answer.
|
|
581
|
+
//
|
|
582
|
+
// It will not reload over unsaved typing. Clean is defined as: every textarea
|
|
583
|
+
// matches the responses-data baked into the page at load. Clean -> reload on
|
|
584
|
+
// its own. Dirty -> show the bar and let the reader choose, so a half-typed
|
|
585
|
+
// answer is never destroyed by my fold landing at the wrong moment.
|
|
586
|
+
(function(){
|
|
587
|
+
var file=(location.pathname.split('/').pop()||'').split('?')[0];
|
|
588
|
+
if(!file) return;
|
|
589
|
+
var seen=null, stopped=false;
|
|
590
|
+
var bar=document.createElement('div');
|
|
591
|
+
bar.id='__replybar'; bar.hidden=true;
|
|
592
|
+
bar.innerHTML='<span id="__replytext">New reply — reload to read it</span>'+
|
|
593
|
+
'<button id="__replygo">Reload</button>'+
|
|
594
|
+
'<button class="dismiss" id="__replyx" title="Dismiss">✕</button>';
|
|
595
|
+
document.body.appendChild(bar);
|
|
596
|
+
bar.querySelector('#__replygo').addEventListener('click',function(){ location.reload(); });
|
|
597
|
+
bar.querySelector('#__replyx').addEventListener('click',function(){ bar.hidden=true; stopped=true; });
|
|
598
|
+
|
|
599
|
+
function baked(){
|
|
600
|
+
var el=document.getElementById('responses-data');
|
|
601
|
+
try{ return JSON.parse((el&&el.textContent)||'{}')||{}; }catch(e){ return {}; }
|
|
602
|
+
}
|
|
603
|
+
function dirty(){
|
|
604
|
+
var disk=baked(), out=false;
|
|
605
|
+
document.querySelectorAll('.response[data-resp]').forEach(function(el){
|
|
606
|
+
var t=el.querySelector('textarea'); if(!t) return;
|
|
607
|
+
if((t.value||'').trim() !== String(disk[el.getAttribute('data-resp')]||'').trim()) out=true;
|
|
608
|
+
});
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
function tick(){
|
|
612
|
+
if(stopped) return;
|
|
613
|
+
fetch('/__docversion?file='+encodeURIComponent(file),{cache:'no-store'})
|
|
614
|
+
.then(function(r){ return r.ok ? r.json() : null; })
|
|
615
|
+
.then(function(d){
|
|
616
|
+
if(!d || typeof d.reviews!=='string') return;
|
|
617
|
+
if(seen===null){ seen=d.reviews; return; }
|
|
618
|
+
if(d.reviews===seen) return;
|
|
619
|
+
seen=d.reviews; // announce a given reply once, not every 5s
|
|
620
|
+
// Reload only when nothing would be lost; otherwise hand the reader
|
|
621
|
+
// the choice. Either way the bar reports that a reply exists.
|
|
622
|
+
if(!dirty()){ location.reload(); return; }
|
|
623
|
+
document.getElementById('__replytext').textContent=
|
|
624
|
+
'New reply on disk — you have unsaved edits. 💾 first, then reload.';
|
|
625
|
+
bar.hidden=false;
|
|
626
|
+
})
|
|
627
|
+
.catch(function(){ /* server gone: stay quiet, the doc still works */ });
|
|
628
|
+
}
|
|
629
|
+
setInterval(tick, 5000); tick();
|
|
630
|
+
})();
|
|
631
|
+
</script>
|
|
632
|
+
<script id="__autosave-patch">
|
|
633
|
+
// Typing survives a closed tab. It did not.
|
|
634
|
+
//
|
|
635
|
+
// The dialogue template autosaves to localStorage as you type;
|
|
636
|
+
// REPORT-TEMPLATE.html never did, so a report collected answers only between
|
|
637
|
+
// the moment you typed them and the moment you pressed 💾 — close the tab
|
|
638
|
+
// first and they were gone with no trace anywhere. Injected rather than
|
|
639
|
+
// templated so every doc already written gets it too.
|
|
640
|
+
//
|
|
641
|
+
// It only ever FILLS AN EMPTY BOX. Disk is the truth for anything saved
|
|
642
|
+
// (__disk-wins-patch), and a stale browser copy overwriting a saved answer is
|
|
643
|
+
// a failure this folder has already had once — four answers on 2026-08-05 came
|
|
644
|
+
// back holding the text of four other boxes.
|
|
645
|
+
(function(){
|
|
646
|
+
var boxes=[].slice.call(document.querySelectorAll('.response[data-resp] textarea'));
|
|
647
|
+
if(!boxes.length) return;
|
|
648
|
+
var file=(location.pathname.split('/').pop()||'doc').split('?')[0];
|
|
649
|
+
// "janus-" prefix is load-bearing: __disk-wins-patch heals the stored map by
|
|
650
|
+
// looking up the first key with it.
|
|
651
|
+
var KEY='janus-'+file;
|
|
652
|
+
function keyOf(t){ return t.closest('.response').getAttribute('data-resp'); }
|
|
653
|
+
try{
|
|
654
|
+
var saved=JSON.parse(localStorage.getItem(KEY)||'{}')||{};
|
|
655
|
+
boxes.forEach(function(t){
|
|
656
|
+
var v=saved[keyOf(t)];
|
|
657
|
+
if(!t.value.trim() && typeof v==='string' && v.trim()){
|
|
658
|
+
t.value=v;
|
|
659
|
+
t.closest('.response').classList.add('filled');
|
|
660
|
+
t.style.height='auto'; t.style.height=t.scrollHeight+'px';
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}catch(e){}
|
|
664
|
+
function dump(){
|
|
665
|
+
var o={}; boxes.forEach(function(t){ o[keyOf(t)]=t.value; });
|
|
666
|
+
try{ localStorage.setItem(KEY, JSON.stringify(o)); }catch(e){}
|
|
667
|
+
}
|
|
668
|
+
boxes.forEach(function(t){ t.addEventListener('input', dump); });
|
|
669
|
+
document.addEventListener('click', function(e){
|
|
670
|
+
if(e.target && e.target.id==='clearBtn') setTimeout(dump, 60);
|
|
671
|
+
});
|
|
672
|
+
})();
|
|
673
|
+
</script>
|
|
674
|
+
<script id="__disk-wins-patch">
|
|
675
|
+
/* Disk beats a stale localStorage for answers that are already SAVED.
|
|
676
|
+
*
|
|
677
|
+
* The template's load() does the opposite: it merges localStorage OVER the
|
|
678
|
+
* on-disk responses-data, unconditionally. That cost us four answers on
|
|
679
|
+
* 2026-08-05 — a1-goal-followup, a7-tools, a8-toolset and a9-render came back
|
|
680
|
+
* holding the text of the LAST four boxes, and the next save wrote the shift
|
|
681
|
+
* to disk as truth. Recovered from history/v015; the browser's localStorage
|
|
682
|
+
* was later confirmed to still hold the corrupted values, so the tab stayed
|
|
683
|
+
* armed to redo the damage on every subsequent save.
|
|
684
|
+
*
|
|
685
|
+
* localStorage exists to protect typing that has NOT been saved yet, and that
|
|
686
|
+
* is all it should win. So: a key with a non-empty value on disk is restored
|
|
687
|
+
* from disk; a key that is empty on disk keeps whatever the browser holds.
|
|
688
|
+
* Then localStorage is rewritten from the corrected DOM, which HEALS the drift
|
|
689
|
+
* instead of masking it — otherwise the bad map survives every reload.
|
|
690
|
+
*
|
|
691
|
+
* Runs after the template's own load() because it is appended at end-of-body.
|
|
692
|
+
*/
|
|
693
|
+
(function(){
|
|
694
|
+
var dataEl=document.getElementById('responses-data');
|
|
695
|
+
if(!dataEl) return;
|
|
696
|
+
var disk={};
|
|
697
|
+
try{ disk=JSON.parse(dataEl.textContent||'{}')||{}; }catch(e){ return; }
|
|
698
|
+
var healed=[];
|
|
699
|
+
document.querySelectorAll('.response[data-resp]').forEach(function(el){
|
|
700
|
+
var t=el.querySelector('textarea'); if(!t) return;
|
|
701
|
+
var k=el.getAttribute('data-resp'), v=disk[k];
|
|
702
|
+
if(typeof v==='string' && v.trim() && t.value!==v){
|
|
703
|
+
healed.push(k+' ('+t.value.length+'->'+v.length+' chars)');
|
|
704
|
+
t.value=v;
|
|
705
|
+
}
|
|
706
|
+
if(t.value.trim()) el.classList.add('filled'); else el.classList.remove('filled');
|
|
707
|
+
t.style.height='auto'; t.style.height=t.scrollHeight+'px';
|
|
708
|
+
});
|
|
709
|
+
if(healed.length){
|
|
710
|
+
try{
|
|
711
|
+
// Which stored map belongs to THIS doc. It used to be "the first key
|
|
712
|
+
// starting with janus-", which was a guess: localStorage is per-ORIGIN,
|
|
713
|
+
// so every doc served from 127.0.0.1:8787 shares one store, and the
|
|
714
|
+
// first janus- key is usually some other document's answers — healing
|
|
715
|
+
// would then overwrite THAT doc's unsaved typing with this doc's.
|
|
716
|
+
// Ownership is now proved, not guessed: a map belongs to this doc if it
|
|
717
|
+
// holds at least one of this doc's data-resp ids. __autosave-patch's own
|
|
718
|
+
// key (janus-<file>) is always included.
|
|
719
|
+
var mine={};
|
|
720
|
+
document.querySelectorAll('.response[data-resp]').forEach(function(el){
|
|
721
|
+
var t=el.querySelector('textarea');
|
|
722
|
+
if(t) mine[el.getAttribute('data-resp')]=t.value;
|
|
723
|
+
});
|
|
724
|
+
var file=(location.pathname.split('/').pop()||'doc').split('?')[0];
|
|
725
|
+
var targets=['janus-'+file];
|
|
726
|
+
for(var i=0;i<localStorage.length;i++){
|
|
727
|
+
var k2=localStorage.key(i);
|
|
728
|
+
if(!k2 || k2.indexOf('janus-')!==0 || targets.indexOf(k2)>=0) continue;
|
|
729
|
+
try{
|
|
730
|
+
var m=JSON.parse(localStorage.getItem(k2)||'{}')||{};
|
|
731
|
+
if(Object.keys(m).some(function(kk){ return kk in mine; })) targets.push(k2);
|
|
732
|
+
}catch(_e){}
|
|
733
|
+
}
|
|
734
|
+
targets.forEach(function(k3){
|
|
735
|
+
// Only rewrite ids the map already held, plus this doc's own key in
|
|
736
|
+
// full — a shared-prefix map from another doc keeps its other entries.
|
|
737
|
+
var out;
|
|
738
|
+
if(k3==='janus-'+file){ out=mine; }
|
|
739
|
+
else {
|
|
740
|
+
try{ out=JSON.parse(localStorage.getItem(k3)||'{}')||{}; }catch(_e){ return; }
|
|
741
|
+
Object.keys(out).forEach(function(kk){ if(kk in mine) out[kk]=mine[kk]; });
|
|
742
|
+
}
|
|
743
|
+
localStorage.setItem(k3,JSON.stringify(out));
|
|
744
|
+
});
|
|
745
|
+
}catch(e){}
|
|
746
|
+
console.warn('[dialogue-doc] restored '+healed.length+
|
|
747
|
+
' answer(s) from disk over a stale localStorage: '+healed.join(', '));
|
|
748
|
+
}
|
|
749
|
+
})();
|
|
750
|
+
</script>
|
|
751
|
+
<script id="__boxstate-patch" data-bs="1">
|
|
752
|
+
// WHICH BOXES ACTUALLY WANT THE READER. One definition, used by the nav marks
|
|
753
|
+
// and the todo bar, because they used to each count "every .response[data-resp]
|
|
754
|
+
// with an empty textarea" and that count lied twice over:
|
|
755
|
+
//
|
|
756
|
+
// 1. A .mini row-note ("note on this row") is an INVITATION, never a demand.
|
|
757
|
+
// Counting them left pages reading 0/7 forever.
|
|
758
|
+
// 2. A follow-up box the conversation already ran PAST is not pending. Chains
|
|
759
|
+
// read question -> answer -> my reply + follow-up -> answer -> ...; a gap
|
|
760
|
+
// in the middle means the reader kept going, a gap at the END means they
|
|
761
|
+
// have not answered yet.
|
|
762
|
+
//
|
|
763
|
+
// Both were reported by the owner against a doc that claimed four open
|
|
764
|
+
// questions when every real question in it was answered ("uh.. everything is
|
|
765
|
+
// checked off. what is open here?", 2026-08-23). Same rule as doctracker.py's
|
|
766
|
+
// qstate/mark_superseded — if you change one, change the other.
|
|
767
|
+
(function(){
|
|
768
|
+
function chainOf(key){
|
|
769
|
+
var stem=key, m;
|
|
770
|
+
while((m=/^(.*)-followup\d*$/.exec(stem))) stem=m[1];
|
|
771
|
+
return stem;
|
|
772
|
+
}
|
|
773
|
+
function all(){
|
|
774
|
+
return [].slice.call(document.querySelectorAll('.response[data-resp]'));
|
|
775
|
+
}
|
|
776
|
+
// A box counts at all only if it is a real question box, not a row-note.
|
|
777
|
+
function countable(box){ return !box.classList.contains('mini'); }
|
|
778
|
+
function filled(box){
|
|
779
|
+
var t=box.querySelector('textarea');
|
|
780
|
+
return !!(t && t.value.trim());
|
|
781
|
+
}
|
|
782
|
+
// Answered later in the SAME chain, anywhere in the doc, in document order.
|
|
783
|
+
function supersededMap(){
|
|
784
|
+
var boxes=all(), seen={}, out={};
|
|
785
|
+
for(var i=boxes.length-1;i>=0;i--){
|
|
786
|
+
var key=boxes[i].getAttribute('data-resp'), ch=chainOf(key);
|
|
787
|
+
out[key]=!!seen[ch];
|
|
788
|
+
if(filled(boxes[i])) seen[ch]=true;
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
function pendingIn(page){
|
|
793
|
+
var sup=supersededMap();
|
|
794
|
+
return [].slice.call(page.querySelectorAll('.response[data-resp]'))
|
|
795
|
+
.filter(function(b){
|
|
796
|
+
return countable(b) && !filled(b) && !sup[b.getAttribute('data-resp')];
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function countsIn(page){
|
|
800
|
+
var boxes=[].slice.call(page.querySelectorAll('.response[data-resp]')).filter(countable);
|
|
801
|
+
return {total:boxes.length, filled:boxes.filter(filled).length,
|
|
802
|
+
pending:pendingIn(page).length};
|
|
803
|
+
}
|
|
804
|
+
window.__janusBoxes={chainOf:chainOf, pendingIn:pendingIn, countsIn:countsIn};
|
|
805
|
+
})();
|
|
806
|
+
</script>
|
|
807
|
+
</body></html>
|