fenix-claude-plugin 0.1.2 → 0.2.0

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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +13 -13
  2. package/.mcp.json +11 -11
  3. package/README.md +33 -40
  4. package/artifacts/collections.html +149 -0
  5. package/artifacts/fenix-workspace.html +1285 -0
  6. package/artifacts/oa-editor.html +210 -0
  7. package/artifacts/patent-editor.html +325 -0
  8. package/artifacts/review-workspace.html +1135 -0
  9. package/commands/fenix-install.md +19 -11
  10. package/commands/fenix-setup.md +18 -13
  11. package/package.json +16 -16
  12. package/skills/fenix-application/SKILL.md +38 -11
  13. package/skills/fenix-application/VERSION +1 -1
  14. package/skills/fenix-application/references/claim1-method-drafting.md +1 -1
  15. package/skills/fenix-application/references/claims.md +4 -5
  16. package/skills/fenix-application/references/disclosure.md +116 -10
  17. package/skills/fenix-application/references/editors.md +228 -39
  18. package/skills/fenix-application/references/figure-description.md +1 -1
  19. package/skills/fenix-application/references/figure-strategy.md +166 -0
  20. package/skills/fenix-application/references/figures-guidance.md +1 -1
  21. package/skills/fenix-application/references/figures.md +60 -9
  22. package/skills/fenix-application/references/patent-review-checklist.md +3 -1
  23. package/skills/fenix-application/references/patent-review.md +6 -2
  24. package/skills/fenix-application/references/patent-safe.md +1 -1
  25. package/skills/fenix-application/references/patent.md +11 -10
  26. package/skills/fenix-application/references/spec-guidance.md +1 -1
  27. package/skills/fenix-application/references/spec.md +17 -7
  28. package/skills/fenix-application/scripts/figure_strategy_server.py +136 -0
  29. package/skills/fenix-diagram/SKILL.md +54 -0
  30. package/skills/fenix-diagram/VERSION +1 -0
  31. package/skills/fenix-diagram/references/diagram-svg-guide.md +271 -0
  32. package/skills/fenix-office-action/SKILL.md +9 -7
  33. package/skills/fenix-office-action/VERSION +1 -1
  34. package/skills/fenix-office-action/references/oa-analysis.md +14 -4
  35. package/skills/fenix-office-action/references/oa-drafting.md +144 -50
  36. package/skills/fenix-office-action/references/oa-response.md +31 -18
  37. package/skills/fenix-project/SKILL.md +4 -3
  38. package/skills/fenix-project/VERSION +1 -1
  39. package/skills/fenix-project/references/create-project.md +11 -1
  40. package/artifacts/practice-dashboard.html +0 -469
  41. package/artifacts/project-overview.html +0 -481
  42. package/artifacts/user-dashboard.html +0 -368
  43. package/skills/fenix-application/CUSTOMIZE.md +0 -1
  44. package/skills/fenix-application/assets/claims_editor.html +0 -86
  45. package/skills/fenix-application/assets/figures_editor.html +0 -86
  46. package/skills/fenix-application/assets/spec_editor.html +0 -86
  47. package/skills/fenix-office-action/CUSTOMIZE.md +0 -1
  48. package/skills/fenix-project/CUSTOMIZE.md +0 -1
@@ -0,0 +1,1285 @@
1
+ <!DOCTYPE html><script type="application/json" id="cowork-artifact-meta">
2
+ {
3
+ "name": "Fenix",
4
+ "schemaVersion": 1,
5
+ "description": "The Fenix workspace wrapper. Knows nothing about Fenix except two tool names. Pages arrive as full HTML via read {resource:\"page\"}, fill the frame, and make every call through the wrapper's bridge.",
6
+ "mcpTools": [
7
+ "mcp__21ddcdf7-4f7b-45f7-ad0e-35afe7c1c96a__read",
8
+ "mcp__21ddcdf7-4f7b-45f7-ad0e-35afe7c1c96a__write"
9
+ ],
10
+ "mcpServerNames": [
11
+ "Fenix",
12
+ "Fenix"
13
+ ]
14
+ }
15
+ </script>
16
+ <script type="application/json" id="fenix-install-manifest">
17
+ {
18
+ "server": "Fenix",
19
+ "tools": [
20
+ "read",
21
+ "write"
22
+ ],
23
+ "note": "Register every listed tool fully-qualified at install. This artifact calls all of them, and a partial grant fails at the first missing one."
24
+ }
25
+ </script>
26
+ <html lang="en">
27
+ <head>
28
+ <meta charset="utf-8">
29
+ <title>Fenix</title>
30
+ <style>
31
+ * { box-sizing: border-box; }
32
+ html, body { height: 100%; }
33
+ /* No fill on the body, and none on the iframe either: a page carries its own
34
+ background, and the status line carries its own, so every region the
35
+ workspace itself owns is transparent and shows the HOST's surface rather
36
+ than a black band of our own. Before a page loads there is nothing of ours
37
+ to see — no dark flash — and the floating nav icons sit over the host too. */
38
+ body { margin:0; background:transparent; color:hsl(220 10% 92%);
39
+ font-family:-apple-system,"Segoe UI",system-ui,sans-serif;
40
+ display:grid; grid-template-rows:1fr 24px; }
41
+
42
+ /* content — the page owns everything above the line. There is no nav STRIP
43
+ any more: the category icons float in the page's top-right corner (see
44
+ #navCats) rather than in a band of their own, so the page reaches the very
45
+ top and the workspace shows one less line of its own chrome. #main is the
46
+ positioning context for that overlay. */
47
+ #main { min-height:0; display:flex; position:relative; }
48
+ iframe { flex:1; border:0; background:transparent; }
49
+
50
+ /* THE ONE LINE — and it should be easy to forget.
51
+ It is chrome under someone else's page, so it sits at the SAME lightness as
52
+ the body rather than a shade above it: a lighter strip reads as a panel and
53
+ draws the eye all day for information that is almost always "fine". Nothing
54
+ here carries colour while things are normal. Colour is reserved for the
55
+ states that are not — a warn/bad light, an error badge, an offered move —
56
+ so that when something IS coloured, it means something. Everything else
57
+ comes up to legibility on hover, when you have gone looking for it. */
58
+ #foot { display:flex; align-items:center; gap:10px; padding:0 12px;
59
+ background:hsl(220 10% 6%); border-top:1px solid hsl(220 8% 11%);
60
+ font-size:11px; color:hsl(220 4% 34%); }
61
+ #foot .led { width:6px; height:6px; border-radius:50%; background:hsl(220 5% 24%); flex:0 0 auto; }
62
+ /* Healthy is deliberately dim: a bright green light every second of every day
63
+ trains you to ignore the light, which is the one thing it must not do. */
64
+ #foot .led.ok { background:hsl(142 22% 32%); }
65
+ #foot .led.warn { background:hsl(45 90% 60%); }
66
+ #foot .led.bad { background:hsl(0 85% 68%); }
67
+ #foot .mono { font-family:ui-monospace,Consolas,monospace; }
68
+ #ctxSeg { color:hsl(220 5% 44%); cursor:pointer; max-width:260px; white-space:nowrap;
69
+ overflow:hidden; text-overflow:ellipsis; }
70
+ #ctxSeg:hover { color:hsl(220 10% 82%); }
71
+ /* the context URL — the only thing on the line that says where this page IS.
72
+ direction:rtl truncates from the LEFT, keeping the distinguishing tail.
73
+ Grey until hovered: it is a long string and a full-time blue link in the
74
+ corner of the eye is exactly the conspicuousness this bar should not have. */
75
+ #ctxUrl { color:hsl(220 4% 34%); text-decoration:none; max-width:340px; white-space:nowrap;
76
+ overflow:hidden; text-overflow:ellipsis; direction:rtl; text-align:left; }
77
+ #ctxUrl:hover { color:hsl(205 50% 60%); text-decoration:underline; }
78
+ #ctxUrl:empty { display:none; }
79
+ /* The category icons: tool / collections / dashboards.
80
+ They FLOAT in the page's top-right corner rather than sitting in a strip of
81
+ their own — the wrapper still owns them (same three destinations from every
82
+ page), but they read as part of the page instead of a band above it.
83
+ The group NEVER changes width and the icons never move — the one you are
84
+ already in stays in place, highlighted and inert, rather than disappearing.
85
+ A group that reflows as you navigate is one you cannot aim at without
86
+ looking, which for a control used this often is most of its value.
87
+
88
+ A frosted translucent pill (blur, no solid fill) carries them: the icons
89
+ sit over arbitrary page content that may be light OR dark, and a blur keeps
90
+ them legible on either without painting a block of colour. There is NO dark
91
+ fill anywhere here — the old active state used a near-black square, which
92
+ over the now-light host surface read as exactly the black box we were told
93
+ was too much. Selection is a light translucent highlight instead. */
94
+ #navCats { position:absolute; top:8px; right:10px; z-index:6;
95
+ display:flex; align-items:center; gap:2px; padding:3px;
96
+ border-radius:9px; background:hsl(220 14% 55% / .10);
97
+ -webkit-backdrop-filter:blur(10px) saturate(1.25);
98
+ backdrop-filter:blur(10px) saturate(1.25); }
99
+ #navCats:empty { display:none; }
100
+ #navCats button { background:none; border:0; border-radius:6px;
101
+ padding:2px 6px; font-size:15px; line-height:1; cursor:pointer;
102
+ filter:grayscale(.4) opacity(.55);
103
+ transition:filter .12s ease, background .12s ease; }
104
+ #navCats button:hover:not(:disabled) { filter:none; background:hsl(220 16% 62% / .20); }
105
+ /* Active: full colour and a light highlight. Disabled too, so it cannot be
106
+ clicked and reads as "you are here" to a screen reader as well. */
107
+ #navCats button.active { filter:none; background:hsl(220 18% 66% / .26); cursor:default; }
108
+ /* Nothing to open — no matter bound, so the tool icon has no destination.
109
+ Shown rather than hidden, so the group keeps its shape. */
110
+ #navCats button.empty { cursor:not-allowed; filter:grayscale(1) opacity(.25); }
111
+ /* auto-switch toggle + the manual "go" it degrades to when switched off */
112
+ #followWrap { display:flex; align-items:center; gap:4px; cursor:pointer; user-select:none;
113
+ color:hsl(220 4% 34%); flex:0 0 auto; }
114
+ #followWrap:hover { color:hsl(220 10% 82%); }
115
+ #followWrap input { margin:0; cursor:pointer; width:11px; height:11px;
116
+ accent-color:hsl(142 22% 38%); }
117
+ /* Stays visible: this one is an OFFER, and an offer nobody notices is a bug.
118
+ Toned down from the original amber, not switched off. */
119
+ #ctxGo { background:hsl(45 70% 55% / .10); border:1px solid hsl(45 70% 55% / .35);
120
+ color:hsl(45 55% 62%); border-radius:5px; padding:1px 8px; font-size:11px;
121
+ cursor:pointer; flex:0 0 auto; }
122
+ #ctxGo:hover { background:hsl(45 80% 58% / .22); color:hsl(45 85% 72%); }
123
+ #stMsg { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
124
+ #foot .spacer { flex:1; }
125
+ #diagBtn { background:none; border:none; color:hsl(220 4% 30%); font-size:11px;
126
+ cursor:pointer; padding:0; }
127
+ #diagBtn:hover { color:hsl(220 10% 82%); }
128
+
129
+ /* diagnostics drawer (opens from the line, on demand) */
130
+ #diag { position:fixed; left:0; right:0; bottom:24px; height:0; overflow:hidden;
131
+ background:hsl(220 9% 9%); border-top:1px solid hsl(220 8% 16%);
132
+ transition:height .15s ease; display:flex; flex-direction:column; z-index:20; }
133
+ #diag.open { height:250px; }
134
+ #diag .bar { display:flex; gap:8px; align-items:center; padding:8px 12px;
135
+ border-bottom:1px solid hsl(220 8% 16%); flex:0 0 auto; }
136
+ #diag button { background:hsl(220 8% 12%); color:hsl(220 10% 92%);
137
+ border:1px solid hsl(220 8% 16%); border-radius:6px; padding:5px 11px;
138
+ font-size:11.5px; cursor:pointer; }
139
+ #diag button:hover { background:hsl(220 8% 15%); }
140
+ #diag .stats { font:11px ui-monospace,Consolas,monospace; color:hsl(220 5% 45%);
141
+ margin-left:auto; }
142
+ #log { flex:1; overflow-y:auto; padding:8px 12px;
143
+ font:11px ui-monospace,Consolas,monospace; color:hsl(220 6% 62%); }
144
+ #log .ok { color:hsl(142 60% 45%); }
145
+ #log .err { color:hsl(0 85% 68%); }
146
+ #log .cache { color:hsl(200 70% 60%); }
147
+
148
+ /* THE ERROR PANEL — it lives in the drawer, not over the page.
149
+ This was a full-screen modal, and it was wrong twice over. It covered the
150
+ page it was complaining about (so you could not see the thing that failed,
151
+ or keep working around it), and it was hard to get rid of — a retrying
152
+ failure reopened it as fast as you dismissed it. An artifact is a PANE in
153
+ someone else's window; nothing in it has the standing to take the whole
154
+ screen away.
155
+ So the failure lands where the rest of this bar's information lands: the
156
+ drawer, expanded. The line keeps a red badge that says an error is there and
157
+ opens straight to it, which is what "on the screen, not folded into one
158
+ status word" actually required — the modal was never the point. */
159
+ #diagErr { flex:0 0 auto; max-height:150px; overflow-y:auto; padding:10px 12px;
160
+ border-bottom:1px solid hsl(220 8% 16%); background:hsl(0 30% 8%);
161
+ font-size:12px; line-height:1.5; color:hsl(220 8% 78%); display:none; }
162
+ #diagErr.on { display:block; }
163
+ #diagErr h2 { margin:0 0 6px; font-size:12.5px; color:hsl(0 80% 74%); }
164
+ #diagErr .msg { white-space:pre-wrap; word-break:break-word;
165
+ font:11.5px ui-monospace,Consolas,monospace;
166
+ background:hsl(220 10% 6%); border:1px solid hsl(220 8% 16%);
167
+ border-radius:6px; padding:8px 10px; color:hsl(0 75% 76%); }
168
+ /* Repeats collapse into a count. A retrying failure otherwise writes the same
169
+ paragraph a hundred times and buries the one that is different. */
170
+ #diagErr .rep { color:hsl(0 50% 62%); font-size:11px; margin-left:6px; }
171
+ #diagErr h3 { font-size:11.5px; color:hsl(220 10% 92%); margin:10px 0 4px; }
172
+ #diagErr ul { margin:0; padding-left:18px; font:11.5px ui-monospace,Consolas,monospace; }
173
+ #diagErr li.no { color:hsl(0 80% 74%); }
174
+ #diagErr li.yes { color:hsl(142 50% 60%); }
175
+ #diagErr p { margin:5px 0; }
176
+ #diagErr .dim { color:hsl(220 5% 52%); }
177
+ #diagErr details { margin-top:8px; }
178
+ #diagErr summary { cursor:pointer; color:hsl(220 6% 58%); font-size:11.5px; }
179
+ #diagErr .acts { display:flex; gap:8px; margin-top:9px; }
180
+ #diagErr button { background:hsl(220 8% 12%); color:hsl(220 10% 92%);
181
+ border:1px solid hsl(220 8% 16%); border-radius:6px; padding:4px 10px;
182
+ font-size:11.5px; cursor:pointer; }
183
+ #diagErr button:hover { background:hsl(220 8% 15%); }
184
+ #errBadge { background:none; border:1px solid hsl(0 60% 34%); border-radius:5px;
185
+ background:hsl(0 60% 34% / .14); color:hsl(0 80% 74%); font-size:11px;
186
+ cursor:pointer; padding:1px 8px; }
187
+ #errBadge:hover { background:hsl(0 65% 40% / .26); color:hsl(0 85% 82%); }
188
+ </style>
189
+ </head>
190
+ <body>
191
+
192
+ <!-- The page fills #main; #navCats floats over its top-right corner. navCats is
193
+ the FIRST child so it is never wiped when a page mounts — mountPage swaps
194
+ only the <iframe>, leaving its siblings in place. -->
195
+ <div id="main">
196
+ <span id="navCats" role="navigation" aria-label="Workspace"></span>
197
+ </div>
198
+
199
+ <div id="foot">
200
+ <span class="led" id="led"></span>
201
+ <span id="stConnT" class="mono">…</span>
202
+ <span id="ctxSeg" class="mono" title="Current context — click to refresh">—</span>
203
+ <label id="followWrap" title="Auto-switch: when chat or the website moves to another matter, open it here automatically. Turn off to stay put — you will be offered the move instead of taken there, which is what you want mid-edit."><input type="checkbox" id="follow"><span>auto-switch</span></label>
204
+ <button id="ctxGo" style="display:none">open →</button>
205
+ <!-- The URL comes last of the context group: it is the longest item and the
206
+ only one that truncates, so anything after it would sit at a position that
207
+ shifts with the address. The control keeps a fixed home. -->
208
+ <a id="ctxUrl" class="mono" target="_blank" rel="noreferrer"></a>
209
+ <span id="stCacheT" class="mono"></span>
210
+ <span id="stMsg"></span>
211
+ <span class="spacer"></span>
212
+ <button id="errBadge" style="display:none">show error</button>
213
+ <button id="diagBtn">⋯</button>
214
+ </div>
215
+
216
+ <div id="diag">
217
+ <div class="bar">
218
+ <button id="btnReload">Reload page</button>
219
+ <button id="btnClear">Clear page cache</button>
220
+ <button id="btnCopy">Copy details</button>
221
+ <span class="stats" id="diagStats"></span>
222
+ </div>
223
+ <!-- Above the log, because it is the thing you opened the drawer for. Not a
224
+ dialog: it steals no focus and blocks nothing. -->
225
+ <div id="diagErr" role="status" aria-live="polite">
226
+ <h2><span id="errTitle"></span><span class="rep" id="errRepeat"></span></h2>
227
+ <div class="msg" id="errMsg"></div>
228
+ <div id="errGrantWrap">
229
+ <h3>Tool authorization</h3>
230
+ <div id="errGrant"></div>
231
+ </div>
232
+ <!-- Collapsed. This explains the single most common install failure, but it
233
+ is four paragraphs, and on a server-side page error it is noise. Opened
234
+ automatically when the grant is the actual problem. -->
235
+ <details id="errInstall">
236
+ <summary>Where you install this from decides whether it works</summary>
237
+ <p>
238
+ <strong>💻 Local session (laptop icon)</strong> — a conversation running on this
239
+ computer. Installing from there registers the tool grant, and the artifact can call
240
+ Fenix. This is the only install that works.
241
+ </p>
242
+ <p>
243
+ <strong>☁️ Cloud session (cloud icon)</strong> — a conversation running on Anthropic's
244
+ servers. It <em>can</em> create the artifact, and the artifact will open and render —
245
+ but the grant is stripped in transit, by design: a remote session must not be able to
246
+ hand out access to tools on your machine. The result is a page that looks installed and
247
+ can never load data.
248
+ </p>
249
+ <p class="dim">
250
+ So reinstalling from the same cloud conversation produces the identical failure. Open a
251
+ local conversation — the one with the laptop icon — and install it there. There is no
252
+ permission prompt to find: with no grant, there is nothing to approve.
253
+ </p>
254
+ </details>
255
+ <div class="acts">
256
+ <button id="errCopy">Copy error</button>
257
+ <button id="errReload">Retry page</button>
258
+ <button id="errClose" style="margin-left:auto">Clear</button>
259
+ </div>
260
+ </div>
261
+ <div id="log"></div>
262
+ </div>
263
+
264
+ <script>
265
+ (function () {
266
+ 'use strict';
267
+ var $ = function (id) { return document.getElementById(id); };
268
+ var ART_ID = 'fenix-workspace';
269
+ var VERSION = 'v13';
270
+ var CACHE_NS = 'fx.pagecache.';
271
+ var CACHE_FMT = 1;
272
+ // The context poll. 3s, because the point of following context is that it
273
+ // feels immediate — a workspace that lags 20s behind the conversation is one
274
+ // you stop trusting and start refreshing by hand.
275
+ //
276
+ // Affordable because of what the poll actually SENDS: `haveRev`, the revision
277
+ // last seen. An unchanged context comes back {notModified:true, rev} — no
278
+ // payload, and on the server no query beyond the user-document read that
279
+ // authentication already had to do. See get_context in mcp.ts.
280
+ //
281
+ // The TTL is now SHORTER than the interval on purpose. It used to be longer
282
+ // (15s TTL against a 20s poll), which guaranteed every scheduled poll missed
283
+ // its own cache; the cache existed only to absorb the page's own reads. It
284
+ // still does that, and now never stands between the poll and a change.
285
+ var CTX_TTL_MS = 2000;
286
+ var CTX_POLL_MS = 3000;
287
+ /** Backs off to this while the workspace is hidden — nobody is watching. */
288
+ var CTX_POLL_IDLE_MS = 30000;
289
+ /** localStorage key: does this workspace follow the context, or stay put? */
290
+ var FOLLOW_KEY = 'fx.followContext';
291
+ var details = { kind: null, rpc: [], errors: [] };
292
+ var currentPage = null; // {name, version}
293
+
294
+ // ---- meta / manifest / grant -------------------------------------------
295
+ function readJsonBlock(id) {
296
+ try { var el = document.getElementById(id); return el ? JSON.parse(el.textContent) : null; }
297
+ catch (e) { return null; }
298
+ }
299
+ var manifest = readJsonBlock('fenix-install-manifest') || { tools: [] };
300
+ var meta = readJsonBlock('cowork-artifact-meta') || {};
301
+ var grantedFq = meta.mcpTools || [];
302
+ function bare(n) {
303
+ n = String(n);
304
+ if (n.indexOf('mcp__') === 0) { var i = n.lastIndexOf('__'); return n.slice(i + 2); }
305
+ return n;
306
+ }
307
+ var grantedBare = {};
308
+ for (var i = 0; i < grantedFq.length; i++) grantedBare[bare(grantedFq[i])] = true;
309
+
310
+ var PREFIX = '';
311
+ if (grantedFq.length) {
312
+ var f0 = grantedFq[0];
313
+ PREFIX = f0.slice(0, f0.lastIndexOf('__') + 2);
314
+ try { localStorage.setItem('fenix.mcp.prefix', PREFIX); } catch (e) {}
315
+ } else {
316
+ try { PREFIX = localStorage.getItem('fenix.mcp.prefix') || ''; } catch (e) {}
317
+ }
318
+
319
+ // ---- status (the one line) ---------------------------------------------
320
+ var LIVE = !!(window.cowork && typeof window.cowork.callMcpTool === 'function');
321
+ function setConn(cls, text, msg) {
322
+ $('led').className = 'led ' + cls;
323
+ $('stConnT').textContent = text;
324
+ $('stMsg').textContent = msg || '';
325
+ }
326
+ // Deferred so the modal opens AFTER the whole script has run — selfCheck sits
327
+ // above the modal's own section, and its result is the single most common
328
+ // reason this page never loads, so it must be shown, not just summarised.
329
+ (function selfCheck() {
330
+ if (!LIVE) {
331
+ details.kind = 'no-bridge';
332
+ setConn('bad', 'offline', 'No connection to Fenix from this surface — open the installed copy from the Cowork sidebar.');
333
+ setTimeout(function () {
334
+ showErr('No connection to Fenix',
335
+ 'This surface has no MCP bridge, so no tool call can be made from it at all.\n\n' +
336
+ 'This is what a file preview looks like. Open the INSTALLED copy from the Cowork ' +
337
+ 'sidebar rather than the generated file.', true);
338
+ }, 0);
339
+ return;
340
+ }
341
+ var missing = missingTools();
342
+ if (grantedFq.length === 0) {
343
+ details.kind = 'no-access';
344
+ setConn('bad', 'no access', 'No tool grant on this copy. Ask a desktop conversation to reinstall it with its Fenix permissions.');
345
+ setTimeout(function () {
346
+ showErr('This copy has no tool grant',
347
+ 'The installed artifact registered zero tools, so every call fails before it reaches ' +
348
+ 'Fenix.\n\nThe overwhelmingly common cause is below: it was installed from a cloud ' +
349
+ 'session, where the grant is stripped in transit.', true);
350
+ }, 0);
351
+ } else if (missing.length) {
352
+ details.kind = 'partial-access';
353
+ setConn('warn', grantedFq.length + '/' + manifest.tools.length, 'Missing: ' + missing.join(', ') + '. Ask a desktop conversation to reinstall with all Fenix permissions.');
354
+ setTimeout(function () {
355
+ showErr('This copy is missing tool permissions',
356
+ 'Granted ' + grantedFq.length + ' of ' + manifest.tools.length + ' tools. Missing: ' +
357
+ missing.join(', ') + '.\n\nA partial grant fails at the first missing tool, so pages ' +
358
+ 'will load partway and then stop.', true);
359
+ }, 0);
360
+ } else {
361
+ details.kind = 'ready';
362
+ setConn('ok', 'fenix', '');
363
+ }
364
+ })();
365
+
366
+ // ---- the error panel ----------------------------------------------------
367
+ //
368
+ // An error once reached the user as one word on the status line ("page error")
369
+ // plus a log line behind a ⋯ drawer nobody opens. That is not enough to act on
370
+ // and not enough to relay: failures arrived in chat as "it isn't working". The
371
+ // answer to that was a full-screen modal, and it overshot — it covered the
372
+ // page that had failed and fought you when you tried to close it.
373
+ //
374
+ // What was actually needed is that the failure be VISIBLE and ADDRESSABLE, not
375
+ // that it be blocking. So: the line always carries a red badge while an error
376
+ // stands, and the badge opens the drawer with the full text, the grant list and
377
+ // a copy button. The drawer opens BY ITSELF only when the artifact can never
378
+ // work at all (no bridge, no grant, partial grant) — those are not events to
379
+ // acknowledge, they are the state of the thing, and no page is going to render
380
+ // behind them anyway. A page-level error stays an offer to look.
381
+ var lastError = null;
382
+ var errRepeats = 0;
383
+ /** Identity of the displayed error, so a retry loop increments instead of shouting. */
384
+ var errKey = null;
385
+ function missingTools() {
386
+ return manifest.tools.filter(function (t) { return !grantedBare[t]; });
387
+ }
388
+ function renderGrant() {
389
+ var el = $('errGrant');
390
+ el.innerHTML = '';
391
+ if (!manifest.tools.length && !grantedFq.length) {
392
+ var p = document.createElement('p');
393
+ p.className = 'dim';
394
+ p.textContent = 'This copy declares no tools at all — it is not an installed artifact.';
395
+ el.appendChild(p);
396
+ return;
397
+ }
398
+ var ul = document.createElement('ul');
399
+ manifest.tools.forEach(function (t) {
400
+ var ok = !!grantedBare[t];
401
+ var li = document.createElement('li');
402
+ li.className = ok ? 'yes' : 'no';
403
+ li.textContent = (ok ? '✓ ' : '✗ ') + t + (ok ? '' : ' — not granted');
404
+ ul.appendChild(li);
405
+ });
406
+ el.appendChild(ul);
407
+ var note = document.createElement('p');
408
+ if (!grantedFq.length) {
409
+ note.className = 'no';
410
+ note.textContent = 'No tools are granted, so nothing on this page can reach Fenix. ' +
411
+ 'This is not a prompt someone dismissed — there is nothing to approve.';
412
+ } else if (missingTools().length) {
413
+ note.className = 'no';
414
+ note.textContent = 'A partial grant fails at the first missing tool. Reinstall with all of them.';
415
+ } else {
416
+ note.className = 'dim';
417
+ note.textContent = 'The grant is complete, so this is a server-side error rather than a permission problem.';
418
+ }
419
+ el.appendChild(note);
420
+ }
421
+ /**
422
+ * Show an error in the drawer.
423
+ *
424
+ * `fatal` means the artifact cannot work at all — the drawer opens itself.
425
+ * Everything else badges the line and waits to be asked.
426
+ */
427
+ function showErr(title, text, fatal) {
428
+ // ESCAPE, not a literal control character. This was a raw NUL byte, which
429
+ // JavaScript accepts happily -- and which made this whole file register as
430
+ // BINARY: grep found nothing in it, diffs went opaque, and a \u0000 shipped
431
+ // inside artifacts.generated.json. A separator that cannot occur in a title
432
+ // or a server message is the right idea; writing it as an escape keeps the
433
+ // source itself plain text.
434
+ var key = title + ' \u0001 ' + text;
435
+ errRepeats = (key === errKey) ? errRepeats + 1 : 0;
436
+ errKey = key;
437
+ lastError = { title: title, text: text, at: new Date().toISOString(),
438
+ repeats: errRepeats };
439
+ $('errTitle').textContent = title;
440
+ $('errMsg').textContent = text;
441
+ $('errRepeat').textContent = errRepeats ? '(×' + (errRepeats + 1) + ')' : '';
442
+ renderGrant();
443
+ // The install essay is only the answer when the grant is the problem.
444
+ var grantProblem = !grantedFq.length || missingTools().length > 0;
445
+ $('errInstall').open = grantProblem;
446
+ $('errGrantWrap').style.display = grantProblem || !LIVE ? '' : 'none';
447
+ $('diagErr').classList.add('on');
448
+ $('errBadge').style.display = '';
449
+ if (fatal) $('diag').classList.add('open');
450
+ }
451
+ function clearErr() {
452
+ errKey = null; errRepeats = 0;
453
+ $('diagErr').classList.remove('on');
454
+ $('errBadge').style.display = 'none';
455
+ }
456
+ $('errClose').addEventListener('click', clearErr);
457
+ $('errBadge').addEventListener('click', function () {
458
+ $('diag').classList.add('open');
459
+ $('diagErr').scrollTop = 0;
460
+ });
461
+ $('errCopy').addEventListener('click', function () {
462
+ copyTo($('errCopy'), JSON.stringify({ error: lastError, report: buildReport() }, null, 2));
463
+ });
464
+ $('errReload').addEventListener('click', function () {
465
+ // An explicit retry is consent to try the unreachable address again.
466
+ undrawn = {};
467
+ openPage(currentPage ? (currentPage.address || currentPage.name) : null);
468
+ });
469
+
470
+ // ---- log ----------------------------------------------------------------
471
+ function log(msg, cls) {
472
+ var d = document.createElement('div');
473
+ if (cls) d.className = cls;
474
+ d.textContent = msg;
475
+ $('log').appendChild(d);
476
+ if ($('log').childNodes.length > 400) $('log').removeChild($('log').firstChild);
477
+ $('log').scrollTop = 1e9;
478
+ }
479
+ $('diagBtn').addEventListener('click', function () { $('diag').classList.toggle('open'); });
480
+
481
+ // ---- page cache ---------------------------------------------------------
482
+ /**
483
+ * THE SHARED EDITOR RUNTIME — Tiptap/ProseMirror + Preact.
484
+ *
485
+ * Both editors are built WITHOUT it and read it from a `__FXV` global instead,
486
+ * so it ships once rather than ~419 KB inside each. This wrapper is what
487
+ * provides it: fetched by name through the same page channel, cached PINNED, and
488
+ * spliced into an editor's frame ahead of the editor's own script.
489
+ *
490
+ * Held in memory for the session as well as in the cache, because every editor
491
+ * open needs it and the frame is rebuilt each time. The version is verified ONCE
492
+ * per session (a cheap notModified poll); a Tiptap upgrade lands on the next
493
+ * load, which is soon enough for something that changes a few times a year.
494
+ */
495
+ var VENDOR_NAME = 'tiptap-vendor';
496
+ var vendorJs = null; // the runtime source for this session, once resolved
497
+ var vendorChecked = false; // version verified against the server this session
498
+
499
+ /** Pages that need the shared runtime: the editors, which are all under tools/. */
500
+ function needsVendor(name) { return String(name || '').indexOf('tools/') === 0; }
501
+
502
+ function cacheKey(name) { return CACHE_NS + 'page.' + name; }
503
+
504
+ // WHAT SURVIVES EVICTION when localStorage fills. Higher stays.
505
+ //
506
+ // The editors are the biggest pages by far AND the ones you return to across a
507
+ // session, so a dashboard you glanced at once must not push out the editor you
508
+ // keep opening. Two tiers do that: editors sit above every other page. A future
509
+ // PINNED tier is reserved for a shared vendor bundle (the Tiptap runtime split),
510
+ // which must never be evicted — re-fetching it would defeat the whole point —
511
+ // so it is named here even though nothing writes it yet.
512
+ var CACHE_PRI = { pinned: 3, editor: 2, page: 1 };
513
+ function cachePriority(name) {
514
+ // The shared editor runtime is PINNED: both editors reference it instead of
515
+ // carrying their own copy, so evicting it would mean re-downloading half a
516
+ // megabyte the next time either one opens — the exact cost the split removed.
517
+ if (name === VENDOR_NAME) return CACHE_PRI.pinned;
518
+ return String(name || '').indexOf('tools/') === 0 ? CACHE_PRI.editor : CACHE_PRI.page;
519
+ }
520
+
521
+ function cacheGet(name) {
522
+ try {
523
+ var rec = JSON.parse(localStorage.getItem(cacheKey(name)) || 'null');
524
+ if (!rec || rec.fmt !== CACHE_FMT || typeof rec.html !== 'string') return null;
525
+ // READING A PAGE IS USING IT. Bump its access time so eviction is LRU by
526
+ // USE, not by write. The old logic ordered by savedAt, which a cache HIT
527
+ // never touched — so an editor opened fifty times but fetched once looked
528
+ // older than a dashboard fetched a moment ago, and got evicted first. This
529
+ // is the whole bug. Best-effort and same-size (only usedAt changes on an
530
+ // existing key), so it cannot exceed quota; a failed write just leaves the
531
+ // previous time, which is harmless.
532
+ rec.usedAt = Date.now();
533
+ try { localStorage.setItem(cacheKey(name), JSON.stringify(rec)); } catch (e2) {}
534
+ return rec;
535
+ } catch (e) { return null; }
536
+ }
537
+ function ownKeys() {
538
+ var keys = [];
539
+ try {
540
+ for (var i = 0; i < localStorage.length; i++) {
541
+ var k = localStorage.key(i);
542
+ if (k && k.indexOf(CACHE_NS) === 0) keys.push(k);
543
+ }
544
+ } catch (e) {}
545
+ return keys;
546
+ }
547
+ function cacheSet(name, version, html) {
548
+ var now = Date.now();
549
+ var priority = cachePriority(name);
550
+ var rec = JSON.stringify({ fmt: CACHE_FMT, version: version, savedAt: now, usedAt: now, priority: priority, html: html });
551
+ try { localStorage.setItem(cacheKey(name), rec); return true; }
552
+ catch (e) {
553
+ // Make room by evicting the LEAST VALUABLE resident first — lowest priority,
554
+ // and within a priority the least recently USED. Crucially, never evict a
555
+ // page MORE important than the one being stored: a cheap page runs uncached
556
+ // rather than displacing an editor. `self` is excluded — a stale copy of
557
+ // THIS page is replaced by the setItem above (a same-key overwrite), not
558
+ // evicted, so removing it here would just churn.
559
+ var self = cacheKey(name);
560
+ var victims = ownKeys()
561
+ .filter(function (k) { return k.indexOf(CACHE_NS + 'page.') === 0 && k !== self; })
562
+ .map(function (k) {
563
+ var r = {}; try { r = JSON.parse(localStorage.getItem(k)) || {}; } catch (e2) {}
564
+ var pri = typeof r.priority === 'number' ? r.priority : CACHE_PRI.page;
565
+ return { k: k, pri: pri, at: r.usedAt || r.savedAt || 0 };
566
+ })
567
+ .filter(function (v) { return v.pri <= priority; })
568
+ .sort(function (a, b) { return a.pri - b.pri || a.at - b.at; });
569
+ for (var i = 0; i < victims.length; i++) {
570
+ try { localStorage.removeItem(victims[i].k); } catch (e2) {}
571
+ try { localStorage.setItem(cacheKey(name), rec); log('cache: evicted ' + victims[i].k, 'cache'); return true; } catch (e3) {}
572
+ }
573
+ log('cache: quota — running uncached', 'err');
574
+ return false;
575
+ }
576
+ }
577
+ function haveVersions() {
578
+ var v = {};
579
+ ownKeys().forEach(function (k) {
580
+ if (k.indexOf(CACHE_NS + 'page.') !== 0) return;
581
+ try {
582
+ var rec = JSON.parse(localStorage.getItem(k));
583
+ if (rec && rec.fmt === CACHE_FMT) v[k.slice((CACHE_NS + 'page.').length)] = rec.version;
584
+ } catch (e) {}
585
+ });
586
+ return v;
587
+ }
588
+ function cacheSummary() {
589
+ var n = 0, chars = 0;
590
+ ownKeys().forEach(function (k) { n++; try { chars += (localStorage.getItem(k) || '').length; } catch (e) {} });
591
+ $('stCacheT').textContent = n ? (n + '·' + Math.round(chars / 1024) + 'KB') : '';
592
+ }
593
+ $('btnClear').addEventListener('click', function () {
594
+ ownKeys().forEach(function (k) { try { localStorage.removeItem(k); } catch (e) {} });
595
+ log('cache: cleared own keys', 'cache');
596
+ cacheSummary();
597
+ });
598
+
599
+ // ---- context cache ------------------------------------------------------
600
+ var ctxCache = { payload: null, at: 0 };
601
+ var ctxStats = { hits: 0, misses: 0, invalidations: 0 };
602
+ function ctxFresh() { return !!(ctxCache.payload && (Date.now() - ctxCache.at) < CTX_TTL_MS); }
603
+ function ctxInvalidate(why) {
604
+ if (ctxCache.payload) {
605
+ ctxCache.payload = null; ctxCache.at = 0; ctxStats.invalidations++;
606
+ log('ctx: invalidated (' + why + ')', 'cache');
607
+ setTimeout(updateCtxSeg, 600);
608
+ }
609
+ }
610
+
611
+ // ---- tool calls (the one choke point) -----------------------------------
612
+ function isErr(text) { return { isError: true, content: [{ type: 'text', text: text }] }; }
613
+ function firstText(r) {
614
+ if (!r) return '';
615
+ if (Array.isArray(r.content))
616
+ return r.content.map(function (c) { return (c && c.text) || ''; }).join('');
617
+ return typeof r === 'string' ? r : JSON.stringify(r);
618
+ }
619
+ function shellCall(name, args) {
620
+ var b = bare(name);
621
+ var t0 = Date.now();
622
+ if (!LIVE) return Promise.resolve(isErr('FX: no bridge on this surface.'));
623
+ if (!grantedBare[b]) {
624
+ log('✗ ' + b + ' — not in grant', 'err');
625
+ details.rpc.push({ tool: b, ok: false, ms: 0, error: 'not-in-grant' });
626
+ return Promise.resolve(isErr('FX: tool "' + b + '" is not in this artifact’s grant (' +
627
+ Object.keys(grantedBare).join(', ') + '). Ask a desktop conversation to reinstall it with its Fenix permissions.'));
628
+ }
629
+ var isCtxRead = (b === 'read' && args && args.resource === 'context');
630
+ if (isCtxRead && ctxFresh()) {
631
+ ctxStats.hits++;
632
+ details.rpc.push({ tool: 'read:context', ok: true, ms: 0, cached: true });
633
+ return Promise.resolve(ctxCache.payload);
634
+ }
635
+ var label = b + (args && args.resource ? ':' + args.resource : '');
636
+ return window.cowork.callMcpTool(PREFIX + b, args || {}).then(function (r) {
637
+ var ms = Date.now() - t0;
638
+ var bad = r && r.isError;
639
+ log((bad ? '✗ ' : '✓ ') + label + ' — ' + ms + 'ms', bad ? 'err' : 'ok');
640
+ details.rpc.push({ tool: label, ok: !bad, ms: ms, error: bad ? firstText(r).slice(0, 200) : undefined });
641
+ if (!bad) {
642
+ try { localStorage.setItem('fx.installed.' + ART_ID, '1'); } catch (e) {}
643
+ if (isCtxRead) { ctxCache.payload = r; ctxCache.at = Date.now(); ctxStats.misses++; }
644
+ else if (b === 'write') ctxInvalidate('write:' + ((args && args.resource) || '?'));
645
+ // any write may move context (writes update context by default);
646
+ // the wrapper needs no knowledge of which resources are matter-scoped.
647
+ }
648
+ return r;
649
+ }, function (e) {
650
+ var ms = Date.now() - t0;
651
+ log('✗ ' + label + ' — threw (' + ms + 'ms)', 'err');
652
+ details.rpc.push({ tool: label, ok: false, ms: ms, error: ('threw: ' + ((e && e.message) || e)).slice(0, 200) });
653
+ return isErr('FX: call threw: ' + ((e && e.message) || String(e)));
654
+ });
655
+ }
656
+
657
+ // ---- RPC bridge + shim --------------------------------------------------
658
+ var frame = null;
659
+ window.addEventListener('message', function (e) {
660
+ var d = e.data;
661
+ if (!d) return;
662
+ if (d.__fx === 'call') {
663
+ shellCall(d.tool, d.args).then(function (r) {
664
+ if (frame && frame.contentWindow)
665
+ frame.contentWindow.postMessage({ __fx: 'result', seq: d.seq, payload: r }, '*');
666
+ });
667
+ } else if (d.__fx === 'open' && d.page) {
668
+ openPage(String(d.page)); // navigation is content-driven
669
+ } else if (d.__fx === 'showing' && d.address) {
670
+ // The mounted page navigated INSIDE itself. Nothing to fetch; just record
671
+ // where it is, so the context poll recognises the address as current.
672
+ if (currentPage) currentPage.address = String(d.address);
673
+ updateDraftBtn();
674
+ } else if (d.__fx === 'pageError') {
675
+ // A page that died while its modules were still evaluating. It reports
676
+ // itself through the prelude that build-nodes.mjs puts at the head of every
677
+ // node bundle — the shell cannot see inside the frame, and until this
678
+ // existed such a failure was invisible from out here: the page fetch had
679
+ // SUCCEEDED, so the status line stayed green and `errors: []` was the
680
+ // honest-looking lie in every copied diagnostic.
681
+ var pe = String(d.text || 'The page failed to start.');
682
+ details.errors.push('page runtime: ' + pe.slice(0, 300));
683
+ setConn('bad', 'page failed', 'The page failed to start.');
684
+ showErr('The page failed to start', pe);
685
+ }
686
+ });
687
+ /**
688
+ * The shim, built per mount so it can carry the ADDRESS this page was opened at.
689
+ *
690
+ * A page is cached and served by TEMPLATE — every matter shares one copy of
691
+ * `tools/patent/claims` — so the html itself cannot say which matter or which
692
+ * subview was asked for. The editor was left to guess, and it guessed "claims"
693
+ * every time: opening `tools/patent/figures/<id>` landed on Claims. Harmless
694
+ * while nothing produced subview addresses; not harmless now that the Draft
695
+ * button and auto-switch both do.
696
+ *
697
+ * The wrapper is the only thing that knows, because it is what asked. So it
698
+ * tells the page, here, rather than the page inferring it from a context whose
699
+ * navPath is set by the WEBSITE and says nothing about what this frame opened.
700
+ */
701
+ function shimFor(address, name) {
702
+ // JSON.stringify, then neutralise every "<" as \u003c. The result is
703
+ // embedded in an inline script, and a closing script tag appearing inside a
704
+ // value would end the block early. (Note this comment carefully does not
705
+ // write that tag out either — the same rule applies to comments, and one
706
+ // spelled out here as an example is what a parse check caught.)
707
+ var page = JSON.stringify({ address: address || null, name: name || null }).replace(/</g, '\\u003c');
708
+ return '<script>(function(){' +
709
+ 'var seq=0,pending={};' +
710
+ 'window.addEventListener("message",function(e){var d=e.data;if(!d||d.__fx!=="result")return;' +
711
+ 'var p=pending[d.seq];if(!p)return;delete pending[d.seq];p(d.payload);});' +
712
+ 'function call(name,args){return new Promise(function(res){' +
713
+ 'var s=++seq;pending[s]=res;' +
714
+ 'setTimeout(function(){if(pending[s]){delete pending[s];' +
715
+ 'res({isError:true,content:[{type:"text",text:"FX: call timed out after 30s"}]});}},30000);' +
716
+ 'parent.postMessage({__fx:"call",seq:s,tool:String(name),args:args||{}},"*");});}' +
717
+ 'function open(page){parent.postMessage({__fx:"open",page:String(page)},"*");}' +
718
+ // A page that serves several addresses (collections is one bundle for all
719
+ // its lists) can move between them WITHOUT a fetch. It tells the wrapper
720
+ // where it went, so the wrapper does not later "correct" it by re-opening
721
+ // an address it is already showing.
722
+ 'function showing(addr){parent.postMessage({__fx:"showing",address:String(addr)},"*");}' +
723
+ 'window.cowork={callMcpTool:call,openPage:open,setPage:showing,__fxShim:true,page:' + page + '};' +
724
+ '})();<\/script>';
725
+ }
726
+ /**
727
+ * The runtime as a <script> for splicing into a frame.
728
+ *
729
+ * A closing script tag anywhere in the bundle would end the block early and
730
+ * leave the rest as page text, so neutralise it — the same rule shimFor follows
731
+ * for its JSON. Written `<\/script` here so this file's OWN script block is not
732
+ * terminated by the pattern that describes it.
733
+ */
734
+ function vendorScript() {
735
+ if (!vendorJs) return '';
736
+ return '<script>' + vendorJs.replace(/<\/script/gi, '<\\/script') + '<\/script>';
737
+ }
738
+
739
+ /**
740
+ * Make sure the shared runtime is in hand, then continue.
741
+ *
742
+ * One version check per session: pass the cached version and an unchanged
743
+ * runtime answers notModified with no payload. Every failure path still calls
744
+ * back — a page that cannot get the runtime should mount and let the editor
745
+ * report a missing runtime itself, which it does clearly, rather than hang on a
746
+ * blank frame.
747
+ */
748
+ function ensureVendor(done) {
749
+ if (vendorChecked) return done();
750
+ var cached = cacheGet(VENDOR_NAME);
751
+ var args = { resource: 'page', name: VENDOR_NAME };
752
+ if (cached) { args.haveVersions = {}; args.haveVersions[VENDOR_NAME] = cached.version; }
753
+ shellCall('read', args).then(function (r) {
754
+ vendorChecked = true;
755
+ try {
756
+ if (r && !r.isError) {
757
+ var p = JSON.parse(firstText(r));
758
+ if (p.notModified) {
759
+ if (cached) vendorJs = cached.html;
760
+ } else if (typeof p.html === 'string' && p.html) {
761
+ vendorJs = p.html;
762
+ cacheSet(VENDOR_NAME, p.version, p.html);
763
+ log('runtime: ' + VENDOR_NAME + ' v' + p.version + ' (' + Math.round(p.html.length / 1024) + 'KB)', 'cache');
764
+ }
765
+ } else if (r && r.isError) {
766
+ details.errors.push('runtime: ' + firstText(r).slice(0, 200));
767
+ }
768
+ } catch (e) {}
769
+ if (!vendorJs && cached) vendorJs = cached.html; // last resort: whatever we held
770
+ if (!vendorJs) log('runtime unavailable — the editor will ask for a workspace update', 'err');
771
+ done();
772
+ }, function () {
773
+ vendorChecked = true;
774
+ if (cached) vendorJs = cached.html;
775
+ done();
776
+ });
777
+ }
778
+
779
+ function injectShim(html, address, name) {
780
+ var head = shimFor(address, name);
781
+ // The runtime goes in FIRST: it must define __FXV before the page's own
782
+ // bundle runs, and that bundle is a later <script> in the body.
783
+ if (needsVendor(name) && vendorJs) head = vendorScript() + head;
784
+ var m = html.match(/<head[^>]*>/i);
785
+ if (m) return html.slice(0, m.index + m[0].length) + head + html.slice(m.index + m[0].length);
786
+ m = html.match(/<html[^>]*>/i);
787
+ if (m) return html.slice(0, m.index + m[0].length) + head + html.slice(m.index + m[0].length);
788
+ return head + html;
789
+ }
790
+ /**
791
+ * `address` is the FULL path asked for (id and all); `name` is its template,
792
+ * which is the cache key. The page needs the address — it is the only thing
793
+ * carrying which subview and which matter — while the wrapper tracks by
794
+ * template so two matters share one cached copy.
795
+ */
796
+ function mountPage(html, name, version, how, address) {
797
+ // An editor cannot start without the shared runtime, so fetch it first and
798
+ // mount in the callback. ensureVendor short-circuits once it has resolved, so
799
+ // this costs one call per session, not one per open.
800
+ if (needsVendor(name) && !vendorJs) {
801
+ return ensureVendor(function () { mountPageNow(html, name, version, how, address); });
802
+ }
803
+ mountPageNow(html, name, version, how, address);
804
+ }
805
+ function mountPageNow(html, name, version, how, address) {
806
+ var main = $('main');
807
+ if (frame) { main.removeChild(frame); frame = null; }
808
+ frame = document.createElement('iframe');
809
+ main.appendChild(frame);
810
+ frame.srcdoc = injectShim(html, address || name, name);
811
+ currentPage = { name: name, version: version, address: address || name };
812
+ // Visibility depends on where we now ARE, not only on what the context says.
813
+ updateDraftBtn();
814
+ log('page: ' + name + ' v' + version + ' (' + how + ', ' + Math.round(html.length / 1024) + 'KB)');
815
+ }
816
+
817
+ // ---- page delivery via the read tool ------------------------------------
818
+ // read {resource:'page'} → server-chosen entry page
819
+ // read {resource:'page', name:<n>} → that page
820
+ // both accept haveVersions:{name:ver} → server may answer notModified
821
+ // response: {name, version, html} | {name, version, notModified:true}
822
+ /**
823
+ * NAVIGATION BEATS THE POLL.
824
+ *
825
+ * A page read carrying an id also SETS the working context — that is the
826
+ * server's rule, arriving at an address is navigating. So between asking for a
827
+ * page and that call returning, the context on the server is still the OLD
828
+ * one. A poll landing in that window reads the old context, decides the
829
+ * workspace is in the wrong place, and (with auto-switch on) navigates back —
830
+ * undoing the click that was still in flight.
831
+ *
832
+ * So polling is suspended for the whole of a navigation and resumes only once
833
+ * the call that moves the context has ANSWERED. The counter rather than a
834
+ * boolean because a navigation can nest: notModified refetches the page a
835
+ * second time before it mounts.
836
+ */
837
+ var navPending = 0;
838
+ // Bumped every time a navigation BEGINS. The poll snapshots it before issuing
839
+ // its context read and re-checks it when the read returns: if a navigation
840
+ // started in that window, the reply describes where we WERE, and acting on it
841
+ // (openPage of the old target, under auto-switch) would undo the move just
842
+ // made. navPending alone cannot catch this — it is 0 when the poll starts and
843
+ // only goes up mid-flight, so the guard at the top of updateCtxSeg passes and
844
+ // the stale reply is processed anyway. The generation counter closes that gap.
845
+ var navGen = 0;
846
+ function beginNav() { navPending++; navGen++; }
847
+ function endNav() {
848
+ navPending = Math.max(0, navPending - 1);
849
+ if (navPending === 0) {
850
+ // The context has moved and the server has confirmed it. Drop the cached
851
+ // rev so the next poll takes the full reply rather than a notModified
852
+ // computed against what we knew before we moved.
853
+ ctxRev = null;
854
+ setTimeout(updateCtxSeg, 0);
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Addresses the server has told us no artifact draws, as {address: message}.
860
+ *
861
+ * THIS IS A LOOP BREAKER, and the loop was real. A failed openPage still calls
862
+ * endNav(), which drops ctxRev so the next poll takes a FULL context reply
863
+ * rather than a notModified. That reply names the same unreachable target, the
864
+ * follow logic opens it, it fails, and round again — one fetch every three
865
+ * seconds, forever, each one re-raising the same error. (Observed against
866
+ * `dashboards/project/<id>`, which the context URL points at and which nothing
867
+ * draws.) Latched only for the server's deterministic "real page, nothing
868
+ * draws it" answer: a transient failure must stay retryable, and this one will
869
+ * never come out differently until the artifact ships.
870
+ */
871
+ var undrawn = {};
872
+ var UNDRAWN_RE = /no artifact draws it yet/i;
873
+
874
+ function openPage(name) {
875
+ // The address requested, kept for the mount below: the server answers with a
876
+ // TEMPLATE name (the id stripped), so by the time the html arrives the
877
+ // subview and matter are no longer in the reply.
878
+ var requested = name || null;
879
+ if (name && undrawn[name]) return; // already answered, definitively
880
+ var args = { resource: 'page', haveVersions: haveVersions() };
881
+ if (name) args.name = name;
882
+ beginNav();
883
+ shellCall('read', args).then(function (r) {
884
+ if (r && r.isError) {
885
+ var msg = firstText(r);
886
+ details.errors.push('page ' + (name || '(entry)') + ': ' + msg.slice(0, 300));
887
+ setConn(details.kind === 'ready' ? 'warn' : 'bad', 'page error', 'Fenix answered with an error.');
888
+ if (name && UNDRAWN_RE.test(msg)) {
889
+ undrawn[name] = msg;
890
+ // Not a malfunction — a place the workspace does not go yet, and the
891
+ // page you were on is still there and still usable. Say it on the
892
+ // line and leave the page alone; the badge carries the detail.
893
+ pendingTarget = null;
894
+ $('ctxGo').style.display = 'none';
895
+ setConn(details.kind === 'ready' ? 'warn' : 'bad', 'not here',
896
+ name + ' opens on the website — nothing draws it here yet.');
897
+ }
898
+ showErr('Fenix could not open ' + (name || 'the entry page'), msg);
899
+ endNav();
900
+ return;
901
+ }
902
+ var p;
903
+ try { p = JSON.parse(firstText(r)); } catch (e) {
904
+ log('✗ page parse: ' + e.message, 'err');
905
+ showErr('Fenix sent a page this shell could not read',
906
+ e.message + '\n\n--- response ---\n' + firstText(r).slice(0, 4000));
907
+ endNav();
908
+ return;
909
+ }
910
+ if (p.notModified) {
911
+ var c = cacheGet(p.name);
912
+ if (c && c.version === p.version) { mountPage(c.html, p.name, p.version, 'cache', requested); cacheSummary(); endNav(); return; }
913
+ // By ADDRESS, not by the template the server answered with. A page read
914
+ // publishes where you are (see recordPageArrival), so re-requesting
915
+ // `tools/patent` instead of `tools/patent/claims/<id>` would drop the
916
+ // matter out of the address every other surface follows.
917
+ shellCall('read', { resource: 'page', name: requested || p.name }).then(function (r2) {
918
+ if (r2 && r2.isError) { details.errors.push('page refetch: ' + firstText(r2).slice(0, 300)); endNav(); return; }
919
+ var p2; try { p2 = JSON.parse(firstText(r2)); } catch (e) { endNav(); return; }
920
+ if (typeof p2.html === 'string') {
921
+ if (p2.cache !== false) cacheSet(p2.name, p2.version, p2.html);
922
+ mountPage(p2.html, p2.name, p2.version, 'fetched', requested);
923
+ cacheSummary();
924
+ }
925
+ endNav();
926
+ });
927
+ return;
928
+ }
929
+ if (typeof p.html === 'string' && p.html) {
930
+ // cache:false means the HTML has live data baked into it — a builder
931
+ // page rendered server-side. Storing it would pin the numbers as they
932
+ // were at fetch time and show them again on the next open.
933
+ if (p.cache === false) {
934
+ try { localStorage.removeItem(cacheKey(p.name)); } catch (e) {}
935
+ mountPage(p.html, p.name, p.version, 'fetched', requested);
936
+ cacheSummary();
937
+ endNav();
938
+ return;
939
+ }
940
+ var cc = cacheGet(p.name);
941
+ if (!cc || cc.version !== p.version) cacheSet(p.name, p.version, p.html);
942
+ mountPage(p.html, p.name, p.version, (cc && cc.version === p.version) ? 'refreshed' : 'fetched', requested);
943
+ cacheSummary();
944
+ } else {
945
+ log('✗ page response had no html', 'err');
946
+ }
947
+ endNav();
948
+ }, function () {
949
+ // The call itself never rejects (shellCall converts throws into error
950
+ // envelopes), but releasing here too means no future change to that can
951
+ // strand the guard and freeze the poll permanently.
952
+ endNav();
953
+ });
954
+ }
955
+ $('btnReload').addEventListener('click', function () {
956
+ if (currentPage) {
957
+ try { localStorage.removeItem(cacheKey(currentPage.name)); } catch (e) {}
958
+ // Reopen the ADDRESS, not the template — reloading must not silently drop
959
+ // the matter and subview you were on.
960
+ openPage(currentPage.address || currentPage.name);
961
+ } else openPage(null);
962
+ });
963
+
964
+ // ---- context segment ----------------------------------------------------
965
+ /**
966
+ * The context, as a label and — when the server can name one — a URL.
967
+ *
968
+ * `url` is derived server-side in get_context from the session's navPath (else
969
+ * its bound matter), because only the server knows the tenant's subdomain. The
970
+ * shell never builds it: a guessed address is worse than no address.
971
+ */
972
+ var ctxUrlValue = null;
973
+ function parseCtx(r) {
974
+ var t = firstText(r);
975
+ try {
976
+ var j = JSON.parse(t);
977
+ var o = j.session || j.context || j;
978
+ var id = o.label || o.matterNumber || o.docket || o.projectNumber || o.title || o.name ||
979
+ (o.ids && o.ids.projectId) || o.projectId || null;
980
+ var src = o.source ? (' · ' + o.source) : '';
981
+ var url = typeof o.url === 'string' && o.url ? o.url : null;
982
+ // The ADDRESS the context points at — what the workspace would open if it
983
+ // were following. navPath first (it names a specific view), the bound
984
+ // matter as the fallback. Never derived from the url: that is absolute and
985
+ // tenant-specific, while pages are addressed by nav path.
986
+ var target = typeof o.navPath === 'string' && o.navPath
987
+ ? o.navPath.replace(/^\/+/, '')
988
+ : (o.ids && o.ids.projectId ? 'collections/projects/' + o.ids.projectId : null);
989
+ if (id || url) return { label: id ? String(id) + src : '—', url: url, target: target, full: t };
990
+ } catch (e) {}
991
+ t = (t || '').replace(/\s+/g, ' ').trim();
992
+ return { label: t ? t.slice(0, 40) : '—', url: null, target: null, full: t };
993
+ }
994
+ // ---- following the context ----------------------------------------------
995
+ //
996
+ // WHY THIS IS A CHOICE AND NOT A BEHAVIOUR. Chasing the context automatically
997
+ // is right when the workspace is a window onto whatever the conversation is
998
+ // doing, and WRONG when someone is mid-edit: the patent editor deliberately
999
+ // reads context once and PINS it, precisely so a matter cannot be swapped out
1000
+ // from under an open editor. Both are legitimate; only the person knows which
1001
+ // they are doing. So the wrapper follows by default and the toggle turns it
1002
+ // off, and with it off a move becomes an offer ("open →") instead of a jump.
1003
+ var ctxRev = null; // last rev seen; sent as haveRev so the poll is cheap
1004
+ var pendingTarget = null; // where the context points, when we have not gone there
1005
+ function following() {
1006
+ try { return localStorage.getItem(FOLLOW_KEY) !== '0'; } catch (e) { return true; }
1007
+ }
1008
+ function setFollowing(on) {
1009
+ try { localStorage.setItem(FOLLOW_KEY, on ? '1' : '0'); } catch (e) {}
1010
+ $('follow').checked = on;
1011
+ // Turning it back on adopts whatever was waiting, which is what someone
1012
+ // ticking the box is asking for.
1013
+ if (on && pendingTarget) openPage(pendingTarget);
1014
+ }
1015
+ $('follow').addEventListener('change', function () { setFollowing($('follow').checked); });
1016
+ $('ctxGo').addEventListener('click', function () { if (pendingTarget) openPage(pendingTarget); });
1017
+
1018
+ /**
1019
+ * The "Draft" control: open the current matter in its editor.
1020
+ *
1021
+ * The ADDRESS comes from the server (`draft` on the context reply), not from
1022
+ * here. Choosing between the application editor and the OA editor means
1023
+ * knowing project types, and this wrapper deliberately knows nothing about
1024
+ * Fenix beyond two tool names — a copy of that rule living here is a copy that
1025
+ * will disagree with the editor's own classifier, and the failure mode is
1026
+ * silent: the OA tool opened on a drafting matter, minus its figures,
1027
+ * specification and disclosure tabs.
1028
+ *
1029
+ * Hidden on the editors themselves. A button whose only effect is to reload
1030
+ * the page you are already on is noise, and worse, it reads as though it does
1031
+ * something.
1032
+ */
1033
+ var draftTarget = null;
1034
+ var navTargets = null; // {category: address|null}, named by the server
1035
+
1036
+ /**
1037
+ * The category icons, drawn from whatever the server named.
1038
+ *
1039
+ * The LIST comes from the server's `nav` object, not from a list here: adding
1040
+ * a category is then a server change, and an installed wrapper picks it up
1041
+ * without a reinstall — the one kind of change this architecture exists to
1042
+ * avoid. The icons and labels below are presentation, so they do live here;
1043
+ * an unknown category still renders, with its name as the label, rather than
1044
+ * silently vanishing.
1045
+ */
1046
+ // No `admin` — not a destination the workspace offers. It is still a real
1047
+ // CATEGORY (admin/logs is a page, and lands here as "no icon active"), just
1048
+ // not somewhere this bar navigates to. Adding it later is a server change:
1049
+ // the server names the destinations and anything it names renders.
1050
+ var CAT_ICON = { tools: '🔧', collections: '📚', dashboards: '▦' };
1051
+ var CAT_LABEL = { tools: 'Editor', collections: 'Collections', dashboards: 'Dashboards' };
1052
+ var CAT_ORDER = ['tools', 'collections', 'dashboards'];
1053
+
1054
+ /** The category of the page currently mounted — the first path segment. */
1055
+ function currentCategory() {
1056
+ if (!currentPage || !currentPage.name) return null;
1057
+ return String(currentPage.name).split('/')[0] || null;
1058
+ }
1059
+
1060
+ function renderNavCats() {
1061
+ var host = $('navCats');
1062
+ if (!navTargets) { host.innerHTML = ''; return; }
1063
+ var here = currentCategory();
1064
+ var keys = CAT_ORDER.filter(function (k) { return k in navTargets; })
1065
+ .concat(Object.keys(navTargets).filter(function (k) { return CAT_ORDER.indexOf(k) < 0; }));
1066
+
1067
+ host.innerHTML = '';
1068
+ keys.forEach(function (cat) {
1069
+ var target = navTargets[cat];
1070
+ var active = cat === here;
1071
+ var b = document.createElement('button');
1072
+ b.type = 'button';
1073
+ b.textContent = CAT_ICON[cat] || '•';
1074
+ var label = CAT_LABEL[cat] || cat;
1075
+ if (active) {
1076
+ b.className = 'active';
1077
+ b.disabled = true;
1078
+ b.title = label + ' — you are here';
1079
+ b.setAttribute('aria-current', 'page');
1080
+ } else if (!target) {
1081
+ // Only reachable for `tools`, which needs a bound matter.
1082
+ b.className = 'empty';
1083
+ b.disabled = true;
1084
+ b.title = label + ' — no matter is open, so there is nothing to draft';
1085
+ } else {
1086
+ b.title = 'Go to ' + label +
1087
+ (cat === 'tools' && draftTarget
1088
+ ? (draftTarget.kind === 'oa' ? ' (OA response)' : ' (application)')
1089
+ : '') + ' — ' + target;
1090
+ b.onclick = function () { openPage(target); };
1091
+ }
1092
+ b.setAttribute('aria-label', label);
1093
+ host.appendChild(b);
1094
+ });
1095
+ }
1096
+ /**
1097
+ * THE MATTER SELECTOR LIVES IN THE PAGE, not here.
1098
+ *
1099
+ * It was briefly in this bar, and that was wrong for a reason worth writing
1100
+ * down: a matter is not relevant to every page. A collections list, a
1101
+ * dashboard — most of what the workspace shows is not ABOUT one matter, and a
1102
+ * control that is meaningless on most pages still has to be explained on all
1103
+ * of them. It also cost the wrapper knowledge it otherwise does not need
1104
+ * (what a matter is, how they are listed and labelled) inside an INSTALLED
1105
+ * artifact, where changes cost a reinstall.
1106
+ *
1107
+ * The editor owns it again, because that is the page where the matter IS the
1108
+ * subject. The wrapper still names the current one in its status line, which
1109
+ * is the part that genuinely belongs to every page.
1110
+ */
1111
+
1112
+ /** Kept as the old name so the mount/context call sites read the same. */
1113
+ function updateDraftBtn() { renderNavCats(); }
1114
+
1115
+ /**
1116
+ * The context moved. Decide what the workspace does about it.
1117
+ *
1118
+ * `target` is the address the context points at — navPath, else the bound
1119
+ * matter. Compared against the TEMPLATE of what is mounted, because
1120
+ * currentPage.name is the address with its id stripped: two matters share one
1121
+ * page, so comparing raw addresses would re-navigate on every matter change
1122
+ * even when the page is identical.
1123
+ */
1124
+ function onContextTarget(target) {
1125
+ if (!target) { pendingTarget = null; $('ctxGo').style.display = 'none'; return; }
1126
+ /*
1127
+ * Already showing it?
1128
+ *
1129
+ * Three ways that can be true, and the third is the one that matters now
1130
+ * that pages write context when they navigate internally:
1131
+ *
1132
+ * - the target IS the mounted template;
1133
+ * - it is an address UNDER that template (same page, another matter);
1134
+ * - it is the exact address the page last reported showing. A bundle that
1135
+ * serves several addresses moves between them without a fetch, so the
1136
+ * mounted TEMPLATE stops being the whole truth about where we are.
1137
+ *
1138
+ * Without the third, clicking a tab inside collections wrote the context,
1139
+ * the next poll saw an address that did not match the template, and the
1140
+ * wrapper re-fetched and remounted the bundle already on screen — throwing
1141
+ * away the tab that had just been chosen.
1142
+ */
1143
+ var showing = currentPage && (target === currentPage.name ||
1144
+ target.indexOf(currentPage.name + '/') === 0 ||
1145
+ target === currentPage.address);
1146
+ if (showing) { pendingTarget = null; $('ctxGo').style.display = 'none'; return; }
1147
+ // The server has already said nothing draws this. Offering it, or chasing it
1148
+ // because auto-switch is on, only reproduces the failure — and the context
1149
+ // sits on such an address for as long as the conversation is working there,
1150
+ // so it would reproduce it every poll. The URL on the line is the way in.
1151
+ if (undrawn[target]) { pendingTarget = null; $('ctxGo').style.display = 'none'; return; }
1152
+ pendingTarget = target;
1153
+ if (following()) { $('ctxGo').style.display = 'none'; openPage(target); return; }
1154
+ // Not following: say so, and let them choose.
1155
+ $('ctxGo').style.display = '';
1156
+ $('ctxGo').textContent = 'open ' + target.split('/').slice(0, 2).join('/') + ' →';
1157
+ $('ctxGo').title = 'The working context moved to ' + target + '. Click to open it here.';
1158
+ }
1159
+
1160
+ function updateCtxSeg() {
1161
+ if (!LIVE || !grantedBare['read']) return;
1162
+ // A navigation is in flight and the server has not yet been told where we
1163
+ // went. Reading context now would return the PREVIOUS page and, with
1164
+ // auto-switch on, navigate back to it — the poll undoing the click.
1165
+ if (navPending) return;
1166
+ // haveRev is the whole reason a 3s poll is reasonable: unchanged means an
1167
+ // empty reply and no server-side query. See get_context.
1168
+ var gen = navGen;
1169
+ var args = { resource: 'context' };
1170
+ if (ctxRev !== null) args.haveRev = ctxRev;
1171
+ shellCall('read', args).then(function (r) {
1172
+ // A navigation began (and maybe already finished) while this read was in
1173
+ // flight. Its result names the PREVIOUS location; with auto-switch on,
1174
+ // onContextTarget would openPage that old target and revert the move. Drop
1175
+ // it — endNav has nulled ctxRev, so the next poll re-reads from clean.
1176
+ if (navPending || navGen !== gen) return;
1177
+ if (r && r.isError) { $('ctxSeg').textContent = '—'; return; }
1178
+ var raw = firstText(r), parsedRev = null, notMod = false;
1179
+ try { var j = JSON.parse(raw); notMod = !!j.notModified; parsedRev = j.rev; } catch (e) {}
1180
+ if (typeof parsedRev === 'number') ctxRev = parsedRev;
1181
+ if (notMod) return; // nothing moved — and nothing to redraw
1182
+
1183
+ var p = parseCtx(r);
1184
+ $('ctxSeg').textContent = p.label;
1185
+ $('ctxSeg').title = 'Current context (click to refresh)\n' + p.full.slice(0, 600);
1186
+ // An artifact has no address bar. This link is the only thing on screen
1187
+ // that says where the page IS, and the only way back to the same thing in
1188
+ // Fenix. Cleared when the server names none, never guessed.
1189
+ ctxUrlValue = p.url;
1190
+ var a = $('ctxUrl');
1191
+ if (p.url) { a.textContent = p.url; a.href = p.url; a.title = 'Open in Fenix — ' + p.url; }
1192
+ else { a.textContent = ''; a.removeAttribute('href'); }
1193
+ // Where the Draft control goes for this matter, named by the server. Only
1194
+ // present on a full reply; a notModified poll returns above, so this is not
1195
+ // cleared by the 3s heartbeat.
1196
+ try {
1197
+ var j2 = JSON.parse(raw);
1198
+ draftTarget = j2 && j2.draft && j2.draft.path ? j2.draft : null;
1199
+ navTargets = j2 && j2.nav && typeof j2.nav === 'object' ? j2.nav : null;
1200
+ } catch (e) { draftTarget = null; navTargets = null; }
1201
+ updateDraftBtn();
1202
+ onContextTarget(p.target);
1203
+ });
1204
+ }
1205
+ $('ctxSeg').addEventListener('click', function () {
1206
+ ctxRev = null; // force a full reply, not a notModified
1207
+ ctxInvalidate('manual refresh');
1208
+ updateCtxSeg();
1209
+ });
1210
+
1211
+ // Poll fast while visible, slowly while not: a hidden workspace has nobody to
1212
+ // be immediate for, and the interval is the one thing multiplied by every
1213
+ // open copy.
1214
+ var pollTimer = null;
1215
+ function schedulePoll() {
1216
+ if (pollTimer) clearInterval(pollTimer);
1217
+ var hidden = typeof document.hidden === 'boolean' && document.hidden;
1218
+ pollTimer = setInterval(updateCtxSeg, hidden ? CTX_POLL_IDLE_MS : CTX_POLL_MS);
1219
+ }
1220
+ document.addEventListener('visibilitychange', function () {
1221
+ schedulePoll();
1222
+ if (!document.hidden) updateCtxSeg(); // catch up immediately on return
1223
+ });
1224
+ schedulePoll();
1225
+
1226
+ // ---- diagnostics --------------------------------------------------------
1227
+ setInterval(function () {
1228
+ $('diagStats').textContent = ART_ID + ' ' + VERSION + ' · ctx ' + ctxStats.hits + 'h/' +
1229
+ ctxStats.misses + 'm/' + ctxStats.invalidations + 'i · rpc ' + details.rpc.length;
1230
+ }, 2000);
1231
+ function buildReport() {
1232
+ var installedMarker = null;
1233
+ try { installedMarker = localStorage.getItem('fx.installed.' + ART_ID); } catch (e) {}
1234
+ var cacheState = ownKeys().map(function (k) {
1235
+ var chars = 0, ver = null;
1236
+ try { var raw = localStorage.getItem(k); chars = raw.length; ver = (JSON.parse(raw) || {}).version; } catch (e) {}
1237
+ return { key: k, chars: chars, version: ver };
1238
+ });
1239
+ return {
1240
+ kind: details.kind, artifact: ART_ID, version: VERSION,
1241
+ surface: LIVE ? 'installed' : 'no-bridge',
1242
+ deliveredMetaToolCount: grantedFq.length, deliveredMetaTools: grantedFq,
1243
+ manifestTools: manifest.tools, missingTools: missingTools(),
1244
+ prefix: PREFIX, installedMarker: installedMarker,
1245
+ currentPage: currentPage, contextUrl: ctxUrlValue,
1246
+ contextCache: { ttlMs: CTX_TTL_MS, hits: ctxStats.hits, misses: ctxStats.misses,
1247
+ invalidations: ctxStats.invalidations },
1248
+ cache: cacheState, rpc: details.rpc.slice(-60), errors: details.errors,
1249
+ // Addresses this session asked for and the server said nothing draws.
1250
+ // Names the page-coverage gaps a real session actually walked into, which
1251
+ // is better evidence of what to build next than the coverage list.
1252
+ undrawn: Object.keys(undrawn),
1253
+ timestamp: new Date().toISOString()
1254
+ };
1255
+ }
1256
+ /** Copy `text`, reporting on `btn`. execCommand fallback: not every host exposes the async clipboard. */
1257
+ function copyTo(btn, text) {
1258
+ var label = btn.textContent;
1259
+ function done(suffix) { btn.textContent = 'Copied' + (suffix || ''); setTimeout(function () { btn.textContent = label; }, 2000); }
1260
+ function fb() {
1261
+ var ta = document.createElement('textarea'); ta.value = text;
1262
+ document.body.appendChild(ta); ta.select();
1263
+ try { document.execCommand('copy'); } catch (e) {}
1264
+ document.body.removeChild(ta);
1265
+ done(' (fallback)');
1266
+ }
1267
+ if (navigator.clipboard && navigator.clipboard.writeText)
1268
+ navigator.clipboard.writeText(text).then(function () { done(); }, fb);
1269
+ else fb();
1270
+ }
1271
+ $('btnCopy').addEventListener('click', function () {
1272
+ copyTo($('btnCopy'), JSON.stringify(buildReport(), null, 2));
1273
+ });
1274
+
1275
+ // ---- startup ------------------------------------------------------------
1276
+ cacheSummary();
1277
+ $('follow').checked = following();
1278
+ if (LIVE && details.kind !== 'no-access') {
1279
+ openPage(null); // server chooses the entry page (may depend on context)
1280
+ updateCtxSeg();
1281
+ }
1282
+ })();
1283
+ </script>
1284
+ </body>
1285
+ </html>