fenix-claude-plugin 0.1.3 → 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.
- package/.claude-plugin/plugin.json +13 -13
- package/.mcp.json +11 -11
- package/README.md +33 -40
- package/artifacts/collections.html +149 -0
- package/artifacts/fenix-workspace.html +1285 -0
- package/artifacts/oa-editor.html +210 -0
- package/artifacts/patent-editor.html +325 -0
- package/artifacts/review-workspace.html +1135 -0
- package/commands/fenix-install.md +19 -11
- package/commands/fenix-setup.md +18 -13
- package/package.json +16 -16
- package/skills/fenix-application/SKILL.md +35 -12
- package/skills/fenix-application/VERSION +1 -1
- package/skills/fenix-application/references/claim1-method-drafting.md +1 -1
- package/skills/fenix-application/references/claims.md +4 -5
- package/skills/fenix-application/references/disclosure.md +116 -10
- package/skills/fenix-application/references/editors.md +222 -51
- package/skills/fenix-application/references/figure-description.md +1 -1
- package/skills/fenix-application/references/figure-strategy.md +166 -0
- package/skills/fenix-application/references/figures-guidance.md +1 -1
- package/skills/fenix-application/references/figures.md +51 -14
- package/skills/fenix-application/references/patent-review-checklist.md +3 -1
- package/skills/fenix-application/references/patent-review.md +6 -2
- package/skills/fenix-application/references/patent-safe.md +1 -1
- package/skills/fenix-application/references/patent.md +9 -9
- package/skills/fenix-application/references/spec-guidance.md +1 -1
- package/skills/fenix-application/references/spec.md +17 -7
- package/skills/fenix-application/scripts/figure_strategy_server.py +136 -0
- package/skills/fenix-diagram/SKILL.md +54 -0
- package/skills/fenix-diagram/VERSION +1 -0
- package/skills/fenix-diagram/references/diagram-svg-guide.md +271 -0
- package/skills/fenix-office-action/SKILL.md +6 -4
- package/skills/fenix-office-action/VERSION +1 -1
- package/skills/fenix-office-action/references/oa-analysis.md +14 -4
- package/skills/fenix-office-action/references/oa-drafting.md +98 -21
- package/skills/fenix-office-action/references/oa-response.md +13 -13
- package/skills/fenix-project/SKILL.md +4 -3
- package/skills/fenix-project/VERSION +1 -1
- package/skills/fenix-project/references/create-project.md +11 -1
- package/artifacts/claims-editor.html +0 -205
- package/artifacts/figures-editor.html +0 -237
- package/artifacts/oa-arguments-editor.html +0 -216
- package/artifacts/practice-dashboard.html +0 -469
- package/artifacts/project-overview.html +0 -481
- package/artifacts/sections-editor.html +0 -205
- package/artifacts/user-dashboard.html +0 -368
- package/skills/fenix-application/CUSTOMIZE.md +0 -1
- package/skills/fenix-application/assets/claims_editor.html +0 -86
- package/skills/fenix-application/assets/figures_editor.html +0 -86
- package/skills/fenix-application/assets/spec_editor.html +0 -86
- package/skills/fenix-office-action/CUSTOMIZE.md +0 -1
- package/skills/fenix-project/CUSTOMIZE.md +0 -1
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
<!doctype html><script type="application/json" id="cowork-artifact-meta">
|
|
2
|
+
{
|
|
3
|
+
"name": "Fenix Review Workspace",
|
|
4
|
+
"schemaVersion": 1,
|
|
5
|
+
"description": "Tri-fold review and signature workspace for one task.",
|
|
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
|
+
|
|
27
|
+
<html>
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="utf8">
|
|
30
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
31
|
+
<title>Document Review Workspace</title>
|
|
32
|
+
<!--
|
|
33
|
+
Fenix — Document Review Workspace (tri-fold). SINGLE SOURCE OF TRUTH in shared/artifacts.
|
|
34
|
+
|
|
35
|
+
ONE base, TWO hosts (see callTool):
|
|
36
|
+
• MCP / claude.ai (Cowork) — data via window.cowork.callMcpTool(__SERVER_PREFIX__ + tool, args)
|
|
37
|
+
• Next.js front end (iframe) — data via a postMessage bridge to the parent app (window.__fenixHost)
|
|
38
|
+
Both hosts resolve the SAME backend service (shared/services/review), so the MCP and in-app
|
|
39
|
+
experiences stay identical. No mock data: it starts empty and is populated entirely by the host.
|
|
40
|
+
|
|
41
|
+
Backend contract (built in shared/services/review, exposed as MCP tools AND FE API routes):
|
|
42
|
+
get_review_workspace({ taskId }) -> the workspace payload below
|
|
43
|
+
save_review_layout({ taskId, layout }) -> persists panel layout
|
|
44
|
+
sign_form_workspace({ formDocumentId }) -> signs via the signForm pipeline
|
|
45
|
+
-->
|
|
46
|
+
<style>
|
|
47
|
+
:root{
|
|
48
|
+
color-scheme: light dark;
|
|
49
|
+
--bg:#eef0f4; --panel:#ffffff; --panel2:#f7f8fa; --header:#f3f4f7;
|
|
50
|
+
--ink:#1a1d26; --ink2:#3d4351; --muted:#6b7280; --faint:#9aa1af;
|
|
51
|
+
--line:#e2e5ea; --line2:#d3d7df;
|
|
52
|
+
--accent:#5b60e6; --accent-ink:#ffffff; --accent-soft:#ecedfc;
|
|
53
|
+
--signed:#0f9d6e; --signed-soft:#e3f5ee;
|
|
54
|
+
--finalized:#2f6fed; --finalized-soft:#e6efff;
|
|
55
|
+
--await:#c47d0a; --await-soft:#fbf0da;
|
|
56
|
+
--new:#7a8290; --new-soft:#eceef2;
|
|
57
|
+
--paper:#ffffff; --paper-line:#e9ebf0; --paper-ink:#2b3040;
|
|
58
|
+
--shadow:0 1px 2px rgba(20,25,40,.06),0 8px 24px rgba(20,25,40,.06);
|
|
59
|
+
--radius:11px;
|
|
60
|
+
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
|
|
61
|
+
--ui:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
62
|
+
}
|
|
63
|
+
@media (prefers-color-scheme: dark){
|
|
64
|
+
:root{
|
|
65
|
+
--bg:#0a0c11; --panel:#14171f; --panel2:#171b24; --header:#1a1e28;
|
|
66
|
+
--ink:#e7eaf1; --ink2:#c3c8d4; --muted:#8b93a4; --faint:#666e80;
|
|
67
|
+
--line:#242936; --line2:#2f3543;
|
|
68
|
+
--accent:#868cf6; --accent-ink:#0a0c11; --accent-soft:#1f2438;
|
|
69
|
+
--signed:#37d39b; --signed-soft:#12261f;
|
|
70
|
+
--finalized:#6ba1ff; --finalized-soft:#12203a;
|
|
71
|
+
--await:#e6b15a; --await-soft:#2a2214;
|
|
72
|
+
--new:#8b93a4; --new-soft:#20242e;
|
|
73
|
+
--paper:#f6f7fb; --paper-line:#dfe2ea; --paper-ink:#242938;
|
|
74
|
+
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.35);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
:root[data-theme="light"]{
|
|
78
|
+
color-scheme: light;
|
|
79
|
+
--bg:#eef0f4; --panel:#ffffff; --panel2:#f7f8fa; --header:#f3f4f7;
|
|
80
|
+
--ink:#1a1d26; --ink2:#3d4351; --muted:#6b7280; --faint:#9aa1af;
|
|
81
|
+
--line:#e2e5ea; --line2:#d3d7df;
|
|
82
|
+
--accent:#5b60e6; --accent-ink:#ffffff; --accent-soft:#ecedfc;
|
|
83
|
+
--signed:#0f9d6e; --signed-soft:#e3f5ee; --finalized:#2f6fed; --finalized-soft:#e6efff;
|
|
84
|
+
--await:#c47d0a; --await-soft:#fbf0da; --new:#7a8290; --new-soft:#eceef2;
|
|
85
|
+
--paper:#ffffff; --paper-line:#e9ebf0; --paper-ink:#2b3040;
|
|
86
|
+
--shadow:0 1px 2px rgba(20,25,40,.06),0 8px 24px rgba(20,25,40,.06);
|
|
87
|
+
}
|
|
88
|
+
:root[data-theme="dark"]{
|
|
89
|
+
color-scheme: dark;
|
|
90
|
+
--bg:#0a0c11; --panel:#14171f; --panel2:#171b24; --header:#1a1e28;
|
|
91
|
+
--ink:#e7eaf1; --ink2:#c3c8d4; --muted:#8b93a4; --faint:#666e80;
|
|
92
|
+
--line:#242936; --line2:#2f3543;
|
|
93
|
+
--accent:#868cf6; --accent-ink:#0a0c11; --accent-soft:#1f2438;
|
|
94
|
+
--signed:#37d39b; --signed-soft:#12261f; --finalized:#6ba1ff; --finalized-soft:#12203a;
|
|
95
|
+
--await:#e6b15a; --await-soft:#2a2214; --new:#8b93a4; --new-soft:#20242e;
|
|
96
|
+
--paper:#f6f7fb; --paper-line:#dfe2ea; --paper-ink:#242938;
|
|
97
|
+
--shadow:0 1px 2px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.35);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
*{box-sizing:border-box}
|
|
101
|
+
html,body{margin:0;padding:0}
|
|
102
|
+
body{
|
|
103
|
+
height:100vh; overflow:hidden;
|
|
104
|
+
background:var(--bg); color:var(--ink);
|
|
105
|
+
font-family:var(--ui); font-size:14px; line-height:1.45;
|
|
106
|
+
-webkit-font-smoothing:antialiased;
|
|
107
|
+
display:flex; flex-direction:column;
|
|
108
|
+
}
|
|
109
|
+
button{font-family:inherit}
|
|
110
|
+
.mono{font-family:var(--mono)}
|
|
111
|
+
|
|
112
|
+
/* App bar */
|
|
113
|
+
/* Docked at the BOTTOM: this is an editor node, so the app shell above keeps the nav and
|
|
114
|
+
global search. The node's own actions sit out of the shell's way. */
|
|
115
|
+
/* Three columns, so the prev/next group is centred against the BAR rather
|
|
116
|
+
than against the width of whatever sits to its left. */
|
|
117
|
+
.appbar{
|
|
118
|
+
flex:0 0 auto; display:grid; grid-template-columns:1fr auto 1fr; align-items:center;
|
|
119
|
+
gap:14px; padding:0 16px; height:56px; background:var(--panel);
|
|
120
|
+
border-top:1px solid var(--line);
|
|
121
|
+
}
|
|
122
|
+
.bar-left{min-width:0; display:flex; align-items:center}
|
|
123
|
+
.bar-mid{display:flex; align-items:center; gap:8px}
|
|
124
|
+
.bar-right{display:flex; align-items:center; gap:8px; justify-content:flex-end}
|
|
125
|
+
|
|
126
|
+
/* ── The task selector ─────────────────────────────────
|
|
127
|
+
Names the current review and changes it. Matter number FIRST (Mike,
|
|
128
|
+
2026-07-22) — it is the identifier a reviewer recognises; the task name
|
|
129
|
+
alone repeats across matters ("Review response", "Review ADS") and cannot
|
|
130
|
+
tell two queue entries apart. */
|
|
131
|
+
.tasksel{position:relative; min-width:0; max-width:min(46vw, 520px)}
|
|
132
|
+
.tasksel-btn{
|
|
133
|
+
display:flex; align-items:center; gap:9px; width:100%; min-width:0;
|
|
134
|
+
padding:7px 10px; border-radius:9px; border:1px solid var(--line2);
|
|
135
|
+
background:var(--panel2); color:var(--ink); cursor:pointer; text-align:left;
|
|
136
|
+
}
|
|
137
|
+
.tasksel-btn:hover{border-color:var(--accent)}
|
|
138
|
+
.tasksel-matter{flex:0 0 auto; font-size:12.5px; color:var(--ink2)}
|
|
139
|
+
.tasksel-name{
|
|
140
|
+
flex:1 1 auto; min-width:0; font-size:14px; font-weight:600;
|
|
141
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
142
|
+
}
|
|
143
|
+
.tasksel-caret{flex:0 0 auto; color:var(--muted)}
|
|
144
|
+
/* Opens UPWARD: the bar is docked at the bottom, so a downward menu would
|
|
145
|
+
open off-screen. */
|
|
146
|
+
.tasksel-drop{
|
|
147
|
+
position:absolute; bottom:calc(100% + 6px); left:0; z-index:40;
|
|
148
|
+
width:max(320px, 100%); max-height:min(58vh, 420px);
|
|
149
|
+
display:flex; flex-direction:column;
|
|
150
|
+
background:var(--panel); border:1px solid var(--line2); border-radius:10px;
|
|
151
|
+
box-shadow:0 -10px 30px rgba(0,0,0,.28); overflow:hidden;
|
|
152
|
+
}
|
|
153
|
+
/* THE `hidden` ATTRIBUTE DOES NOT WIN ON ITS OWN. It works through the UA
|
|
154
|
+
stylesheet's `[hidden]{display:none}`, and an author `display` — the
|
|
155
|
+
`display:flex` directly above — beats any UA rule regardless of
|
|
156
|
+
specificity. So closeTaskSel() set `.hidden = true` and nothing happened:
|
|
157
|
+
the selector's filter list was permanently open over the bar (Mike,
|
|
158
|
+
2026-07-22: "the filter is always visible"). Any element this file hides by
|
|
159
|
+
attribute AND styles with `display` needs this line. */
|
|
160
|
+
.tasksel-drop[hidden]{display:none}
|
|
161
|
+
.tasksel-q{
|
|
162
|
+
flex:0 0 auto; margin:8px; padding:7px 9px; border-radius:7px;
|
|
163
|
+
border:1px solid var(--line2); background:var(--panel2); color:var(--ink);
|
|
164
|
+
font:inherit; font-size:13px; outline:none;
|
|
165
|
+
}
|
|
166
|
+
.tasksel-q:focus{border-color:var(--accent)}
|
|
167
|
+
.tasksel-list{flex:1 1 auto; overflow:auto; padding:0 6px 6px}
|
|
168
|
+
.tasksel-item{
|
|
169
|
+
display:flex; align-items:baseline; gap:9px; width:100%;
|
|
170
|
+
padding:7px 8px; border-radius:7px; border:0; background:none;
|
|
171
|
+
color:var(--ink2); cursor:pointer; text-align:left; font:inherit;
|
|
172
|
+
}
|
|
173
|
+
.tasksel-item:hover{background:var(--panel2); color:var(--ink)}
|
|
174
|
+
.tasksel-item.current{background:var(--panel2); color:var(--ink); font-weight:600}
|
|
175
|
+
.tasksel-item .m{flex:0 0 auto; font-family:var(--mono); font-size:12px; color:var(--muted)}
|
|
176
|
+
.tasksel-item .n{flex:1 1 auto; min-width:0; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
|
|
177
|
+
.tasksel-empty{padding:10px; font-size:12.5px; color:var(--muted)}
|
|
178
|
+
.chip{
|
|
179
|
+
font-family:var(--mono); font-size:11px; letter-spacing:.02em;
|
|
180
|
+
padding:2px 8px; border-radius:999px; border:1px solid var(--line2);
|
|
181
|
+
color:var(--ink2); background:var(--panel2); white-space:nowrap;
|
|
182
|
+
}
|
|
183
|
+
.rv{font-family:var(--mono); font-size:10px; letter-spacing:.06em; text-transform:uppercase;
|
|
184
|
+
padding:3px 9px; border-radius:999px; display:inline-flex; align-items:center; gap:6px; white-space:nowrap}
|
|
185
|
+
.rv::before{content:""; width:6px; height:6px; border-radius:50%; background:currentColor}
|
|
186
|
+
.rv-needs_review{color:var(--await); background:var(--await-soft)}
|
|
187
|
+
.rv-in_review{color:var(--finalized); background:var(--finalized-soft)}
|
|
188
|
+
.rv-changes_requested{color:#e0533d; background:var(--await-soft)}
|
|
189
|
+
.rv-approved{color:var(--signed); background:var(--signed-soft)}
|
|
190
|
+
.rv-signed{color:var(--signed); background:var(--signed-soft)}
|
|
191
|
+
.rv-none{color:var(--new); background:var(--new-soft)}
|
|
192
|
+
.pill{font-family:var(--mono); font-size:10.5px; border:1px solid var(--line2); border-radius:999px;
|
|
193
|
+
padding:2px 9px; color:var(--muted); white-space:nowrap}
|
|
194
|
+
.spacer{flex:1 1 auto}
|
|
195
|
+
.btn{
|
|
196
|
+
display:inline-flex; align-items:center; gap:7px; cursor:pointer;
|
|
197
|
+
border-radius:9px; border:1px solid var(--line2); background:var(--panel2);
|
|
198
|
+
color:var(--ink2); font-weight:550; font-size:13px; padding:7px 12px;
|
|
199
|
+
}
|
|
200
|
+
.btn:hover{border-color:var(--accent); color:var(--accent)}
|
|
201
|
+
.btn.primary{background:var(--accent); border-color:var(--accent); color:var(--accent-ink)}
|
|
202
|
+
.btn.primary:hover{filter:brightness(1.06); color:var(--accent-ink)}
|
|
203
|
+
.btn.danger{color:#e0533d}
|
|
204
|
+
.btn.danger:hover{border-color:#e0533d; color:#e0533d}
|
|
205
|
+
.btn.primary:disabled,.btn:disabled{opacity:.5; cursor:not-allowed; filter:none}
|
|
206
|
+
.btn svg{width:15px; height:15px}
|
|
207
|
+
|
|
208
|
+
/* Panels row */
|
|
209
|
+
.board{flex:1 1 auto; display:flex; min-height:0; padding:10px; gap:0}
|
|
210
|
+
.panel{
|
|
211
|
+
flex:1 1 0; min-width:150px; display:flex; flex-direction:column;
|
|
212
|
+
background:var(--panel); border:1px solid var(--line);
|
|
213
|
+
border-radius:var(--radius); box-shadow:var(--shadow); overflow:hidden;
|
|
214
|
+
}
|
|
215
|
+
.gutter{
|
|
216
|
+
flex:0 0 12px; align-self:stretch; cursor:col-resize;
|
|
217
|
+
display:flex; align-items:center; justify-content:center; touch-action:none;
|
|
218
|
+
}
|
|
219
|
+
.gutter i{width:3px; height:34px; border-radius:3px; background:var(--line2); transition:background .12s, height .12s}
|
|
220
|
+
.gutter:hover i{background:var(--accent); height:56px}
|
|
221
|
+
|
|
222
|
+
.p-head{
|
|
223
|
+
flex:0 0 auto; display:flex; align-items:center; gap:8px;
|
|
224
|
+
height:42px; padding:0 8px 0 12px; background:var(--header);
|
|
225
|
+
border-bottom:1px solid var(--line);
|
|
226
|
+
}
|
|
227
|
+
.p-title{
|
|
228
|
+
font-size:12.5px; font-weight:650; color:var(--ink);
|
|
229
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:default;
|
|
230
|
+
/* Yields to the header search box rather than pushing it out. */
|
|
231
|
+
flex:0 1 auto; min-width:0; max-width:45%;
|
|
232
|
+
}
|
|
233
|
+
.p-title[contenteditable="true"]{outline:2px solid var(--accent); border-radius:4px; padding:0 3px; cursor:text}
|
|
234
|
+
.statusdot{
|
|
235
|
+
font-family:var(--mono); font-size:10px; letter-spacing:.06em; text-transform:uppercase;
|
|
236
|
+
padding:2px 7px; border-radius:999px; display:inline-flex; align-items:center; gap:5px; white-space:nowrap;
|
|
237
|
+
}
|
|
238
|
+
.statusdot::before{content:""; width:6px; height:6px; border-radius:50%; background:currentColor}
|
|
239
|
+
.s-signed{color:var(--signed); background:var(--signed-soft)}
|
|
240
|
+
.s-finalized{color:var(--finalized); background:var(--finalized-soft)}
|
|
241
|
+
.s-await{color:var(--await); background:var(--await-soft)}
|
|
242
|
+
.s-new{color:var(--new); background:var(--new-soft)}
|
|
243
|
+
.count{font-family:var(--mono); font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums}
|
|
244
|
+
.gear{
|
|
245
|
+
margin-left:auto; width:28px; height:28px; border-radius:7px; border:1px solid transparent;
|
|
246
|
+
background:transparent; color:var(--muted); cursor:pointer; display:flex; align-items:center; justify-content:center;
|
|
247
|
+
}
|
|
248
|
+
.gear:hover{background:var(--panel2); border-color:var(--line2); color:var(--ink2)}
|
|
249
|
+
.gear svg{width:15px;height:15px}
|
|
250
|
+
|
|
251
|
+
/* The subject panel's decisions, on its own header (Mike, 2026-07-22). They
|
|
252
|
+
sit where the gear does on every other panel — the rightmost affordance —
|
|
253
|
+
because that is the slot the eye already goes to. This panel has no gear:
|
|
254
|
+
it cannot be cleared, removed or repointed. */
|
|
255
|
+
.p-acts{margin-left:auto; display:flex; align-items:center; gap:6px}
|
|
256
|
+
.pbtn{
|
|
257
|
+
display:inline-flex; align-items:center; cursor:pointer; white-space:nowrap;
|
|
258
|
+
border-radius:7px; border:1px solid var(--line2); background:var(--panel2);
|
|
259
|
+
color:var(--ink2); font:inherit; font-weight:600; font-size:12px; padding:4px 10px;
|
|
260
|
+
}
|
|
261
|
+
.pbtn:hover{border-color:var(--accent); color:var(--accent)}
|
|
262
|
+
.pbtn.primary{background:var(--accent); border-color:var(--accent); color:var(--accent-ink)}
|
|
263
|
+
.pbtn.primary:hover{filter:brightness(1.06); color:var(--accent-ink)}
|
|
264
|
+
.pbtn.danger{color:#e0533d}
|
|
265
|
+
.pbtn.danger:hover{border-color:#e0533d; color:#e0533d}
|
|
266
|
+
/* The subject panel's title is not renameable either — see isSubjectPanel. */
|
|
267
|
+
.panel.subject .p-title{cursor:default}
|
|
268
|
+
|
|
269
|
+
/* The filter chips had NO rule at all and rendered as bare browser buttons.
|
|
270
|
+
They are the right panel's only control, so they get the same pill grammar
|
|
271
|
+
as everything else here. */
|
|
272
|
+
.fchip{
|
|
273
|
+
cursor:pointer; border-radius:999px; border:1px solid var(--line2);
|
|
274
|
+
background:var(--panel2); color:var(--muted); font:inherit; font-size:11.5px;
|
|
275
|
+
font-weight:600; padding:3px 10px; white-space:nowrap;
|
|
276
|
+
}
|
|
277
|
+
.fchip:hover{color:var(--ink2); border-color:var(--ink2)}
|
|
278
|
+
.fchip.active{background:var(--accent); border-color:var(--accent); color:var(--accent-ink)}
|
|
279
|
+
|
|
280
|
+
.p-body{flex:1 1 auto; min-height:0; display:flex; flex-direction:column; overflow:hidden}
|
|
281
|
+
|
|
282
|
+
/* Filter lives in the PANEL HEADER and the document list drops out of it — the list no longer
|
|
283
|
+
spends a fixed slice of every pane, so the preview gets the whole body. Same interaction as the
|
|
284
|
+
shell's global search: focus opens it, typing filters, picking closes. */
|
|
285
|
+
.pfilter{position:relative; flex:1 1 auto; min-width:0; display:flex; align-items:center}
|
|
286
|
+
.pfilter input{
|
|
287
|
+
width:100%; min-width:0; background:var(--panel2); color:var(--ink);
|
|
288
|
+
border:1px solid var(--line2); border-radius:8px; outline:none;
|
|
289
|
+
font-family:inherit; font-size:12px; padding:4px 26px 4px 8px;
|
|
290
|
+
}
|
|
291
|
+
.pfilter input:focus{border-color:var(--accent)}
|
|
292
|
+
.pfilter .caret{position:absolute; right:8px; color:var(--faint); font-size:9px; pointer-events:none}
|
|
293
|
+
.pdrop{
|
|
294
|
+
position:absolute; top:calc(100% + 5px); left:0; right:0; z-index:40;
|
|
295
|
+
background:var(--panel); border:1px solid var(--line2); border-radius:10px;
|
|
296
|
+
box-shadow:var(--shadow); overflow:hidden; max-height:340px; display:flex; flex-direction:column;
|
|
297
|
+
}
|
|
298
|
+
.pdrop .chips{flex:0 0 auto; padding:6px 6px 4px; border-bottom:1px solid var(--line)}
|
|
299
|
+
.pdrop .rows{overflow:auto; padding:5px}
|
|
300
|
+
.docrow{
|
|
301
|
+
display:flex; align-items:flex-start; gap:8px; width:100%; text-align:left;
|
|
302
|
+
padding:7px 8px; border-radius:8px; border:1px solid transparent; background:transparent;
|
|
303
|
+
cursor:pointer; color:var(--ink2);
|
|
304
|
+
}
|
|
305
|
+
.docrow:hover{background:var(--panel2)}
|
|
306
|
+
.docrow.active{background:var(--accent-soft); border-color:transparent}
|
|
307
|
+
.docrow .di{flex:0 0 auto; margin-top:1px; color:var(--faint)}
|
|
308
|
+
.docrow.active .di{color:var(--accent)}
|
|
309
|
+
.docrow .dn{font-size:12.5px; font-weight:550; line-height:1.3; overflow:hidden}
|
|
310
|
+
.docrow .dm{font-family:var(--mono); font-size:10px; color:var(--muted); letter-spacing:.03em; text-transform:uppercase}
|
|
311
|
+
.needs{margin-left:auto; flex:0 0 auto}
|
|
312
|
+
|
|
313
|
+
.preview{flex:1 1 auto; min-height:0; overflow:auto; padding:16px; display:flex; justify-content:center; background:var(--panel2)}
|
|
314
|
+
.preview.pdfwrap{padding:0; align-items:stretch}
|
|
315
|
+
.docframe{flex:1 1 auto; width:100%; height:100%; min-height:480px; border:0; background:#fff}
|
|
316
|
+
.paper.htmldoc{max-width:660px}
|
|
317
|
+
.htmlbody{font-size:12.5px; color:var(--paper-ink); line-height:1.55; outline:none}
|
|
318
|
+
.htmlbody:focus{outline:none}
|
|
319
|
+
.htmlbody h2{font-size:13px; margin:16px 0 6px; color:var(--paper-ink); border-bottom:1px solid var(--paper-line); padding-bottom:3px}
|
|
320
|
+
.htmlbody p{margin:7px 0}
|
|
321
|
+
.htmlbody [data-claim]{padding-left:14px}
|
|
322
|
+
.emailhead{font-size:12px}
|
|
323
|
+
.emailhead > div{margin:2px 0}
|
|
324
|
+
.emailhead .k{display:inline-block; width:52px; color:var(--muted); font-weight:600}
|
|
325
|
+
.emailbody{font-size:12.5px; line-height:1.55; white-space:pre-wrap; word-break:break-word; margin-top:8px; padding-top:10px; border-top:1px solid var(--paper-line)}
|
|
326
|
+
.empty{
|
|
327
|
+
flex:1 1 auto; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
|
328
|
+
gap:10px; text-align:center; color:var(--muted); padding:24px;
|
|
329
|
+
}
|
|
330
|
+
.empty .big{width:34px; height:34px; color:var(--faint)}
|
|
331
|
+
.empty h4{margin:0; font-size:13px; color:var(--ink2); font-weight:600}
|
|
332
|
+
.empty p{margin:0; font-size:12px; max-width:220px}
|
|
333
|
+
|
|
334
|
+
.paper{
|
|
335
|
+
width:100%; max-width:340px; background:var(--paper); color:var(--paper-ink);
|
|
336
|
+
border:1px solid var(--paper-line); border-radius:6px; box-shadow:var(--shadow);
|
|
337
|
+
padding:20px 22px; align-self:flex-start;
|
|
338
|
+
}
|
|
339
|
+
.paper .stamp{
|
|
340
|
+
font-family:var(--mono); font-size:9.5px; letter-spacing:.16em; text-transform:uppercase;
|
|
341
|
+
color:var(--muted); border:1px solid var(--paper-line); border-radius:4px;
|
|
342
|
+
padding:3px 7px; display:inline-block; margin-bottom:14px;
|
|
343
|
+
}
|
|
344
|
+
.paper h3{margin:0 0 4px; font-size:14px; line-height:1.3; color:var(--paper-ink)}
|
|
345
|
+
.paper .sub{font-family:var(--mono); font-size:10.5px; color:var(--muted); margin-bottom:16px}
|
|
346
|
+
.bar{height:8px; border-radius:3px; background:var(--paper-line); margin:8px 0}
|
|
347
|
+
.bar.s{width:64%} .bar.m{width:88%} .bar.l{width:100%} .bar.xs{width:40%}
|
|
348
|
+
.sigblock{margin-top:22px; padding-top:14px; border-top:1px dashed var(--paper-line)}
|
|
349
|
+
.sigline{display:flex; align-items:flex-end; gap:10px}
|
|
350
|
+
.sigline .lbl{font-family:var(--mono); font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.08em; flex:0 0 auto}
|
|
351
|
+
.sigline .ln{flex:1 1 auto; border-bottom:1.5px solid var(--paper-ink); height:26px; display:flex; align-items:flex-end; padding-bottom:2px}
|
|
352
|
+
.sig-script{font-family:"Segoe Script","Bradley Hand","Apple Chancery",cursive; font-size:19px; color:#1b3a8f; transform:rotate(-2deg)}
|
|
353
|
+
|
|
354
|
+
.signstrip{
|
|
355
|
+
flex:0 0 auto; margin:10px; padding:12px 13px; border-radius:10px;
|
|
356
|
+
border:1px solid var(--line2); background:var(--panel2);
|
|
357
|
+
display:flex; align-items:center; gap:12px;
|
|
358
|
+
}
|
|
359
|
+
.signstrip .who{min-width:0}
|
|
360
|
+
.signstrip .who .k{font-family:var(--mono); font-size:9.5px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted)}
|
|
361
|
+
.signstrip .who .v{font-size:13px; font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
|
|
362
|
+
.signstrip .act{margin-left:auto; flex:0 0 auto}
|
|
363
|
+
|
|
364
|
+
.menu{
|
|
365
|
+
position:fixed; z-index:50; min-width:214px; background:var(--panel);
|
|
366
|
+
border:1px solid var(--line2); border-radius:10px; box-shadow:var(--shadow);
|
|
367
|
+
padding:6px; font-size:13px;
|
|
368
|
+
}
|
|
369
|
+
.menu .lbl{font-family:var(--mono); font-size:9.5px; letter-spacing:.12em; text-transform:uppercase; color:var(--muted); padding:6px 8px 4px}
|
|
370
|
+
.mi{display:flex; align-items:center; gap:9px; width:100%; text-align:left; padding:7px 9px; border-radius:7px; border:0; background:transparent; color:var(--ink2); cursor:pointer}
|
|
371
|
+
.mi:hover{background:var(--panel2); color:var(--ink)}
|
|
372
|
+
.mi.danger:hover{color:#e0533d; background:var(--await-soft)}
|
|
373
|
+
.mi svg{width:15px; height:15px; color:var(--muted); flex:0 0 auto}
|
|
374
|
+
.mi:hover svg{color:inherit}
|
|
375
|
+
.sep{height:1px; background:var(--line); margin:5px 4px}
|
|
376
|
+
|
|
377
|
+
.toast{
|
|
378
|
+
position:fixed; bottom:44px; left:50%; transform:translateX(-50%) translateY(12px);
|
|
379
|
+
background:var(--ink); color:var(--bg); font-size:13px; font-weight:550;
|
|
380
|
+
padding:10px 16px; border-radius:10px; box-shadow:var(--shadow); opacity:0; pointer-events:none;
|
|
381
|
+
transition:opacity .22s, transform .22s; display:flex; align-items:center; gap:9px; z-index:60;
|
|
382
|
+
}
|
|
383
|
+
.toast.show{opacity:1; transform:translateX(-50%) translateY(0)}
|
|
384
|
+
.toast svg{width:16px;height:16px; color:var(--signed)}
|
|
385
|
+
|
|
386
|
+
@media (max-width:760px){
|
|
387
|
+
.board{flex-direction:column; overflow:auto}
|
|
388
|
+
.gutter{display:none}
|
|
389
|
+
.panel{min-height:280px}
|
|
390
|
+
.task-title{max-width:40vw}
|
|
391
|
+
}
|
|
392
|
+
@media (prefers-reduced-motion: reduce){*{transition:none !important}}
|
|
393
|
+
</style>
|
|
394
|
+
</head>
|
|
395
|
+
<body>
|
|
396
|
+
|
|
397
|
+
<div class="board" id="board"></div>
|
|
398
|
+
|
|
399
|
+
<!--
|
|
400
|
+
The bottom bar (Mike, 2026-07-22). It carried a back button, a review-state
|
|
401
|
+
chip, a host pill, the word "Review", the task title, and both decision
|
|
402
|
+
buttons — seven things, of which the reviewer used two. The decisions moved to
|
|
403
|
+
the document's own header, where the thing being decided about is, and what is
|
|
404
|
+
left is navigation: WHICH review (left), MOVE between them (centre), and the
|
|
405
|
+
board control (right).
|
|
406
|
+
|
|
407
|
+
Three columns rather than a spacer, so prev/next is centred against the BAR
|
|
408
|
+
and not against whatever width the selector happens to have.
|
|
409
|
+
-->
|
|
410
|
+
<div class="appbar">
|
|
411
|
+
<div class="bar-left">
|
|
412
|
+
<!-- The task selector replaces the title: it names the current review AND
|
|
413
|
+
changes it, so the bar spends its width on a control instead of a
|
|
414
|
+
label. Opens UPWARD — it is docked at the bottom of the viewport. -->
|
|
415
|
+
<div class="tasksel" id="taskSel">
|
|
416
|
+
<button class="tasksel-btn" id="taskSelBtn" aria-haspopup="listbox" aria-expanded="false">
|
|
417
|
+
<span class="tasksel-matter mono" id="selMatter">—</span>
|
|
418
|
+
<span class="tasksel-name" id="selName">Document Review Workspace</span>
|
|
419
|
+
<svg class="tasksel-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M18 15l-6-6-6 6"/></svg>
|
|
420
|
+
</button>
|
|
421
|
+
<div class="tasksel-drop" id="taskSelDrop" role="listbox" hidden>
|
|
422
|
+
<input class="tasksel-q" id="taskSelQ" type="text" placeholder="Filter by matter or task…" autocomplete="off">
|
|
423
|
+
<div class="tasksel-list" id="taskSelList"></div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<div class="bar-mid">
|
|
429
|
+
<button class="btn" id="prevTask" title="Previous review">‹</button>
|
|
430
|
+
<span class="chip" id="queuePos" title="Position in your review queue">—</span>
|
|
431
|
+
<button class="btn" id="nextTask" title="Next review">›</button>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div class="bar-right">
|
|
435
|
+
<button class="btn" id="addPanel">
|
|
436
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
|
437
|
+
Add panel
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
<div class="toast" id="toast"></div>
|
|
444
|
+
|
|
445
|
+
<script>
|
|
446
|
+
"use strict";
|
|
447
|
+
/* One base, two hosts — see callTool(). Tool names go out BARE, matching the names declared in
|
|
448
|
+
the cowork-artifact-meta block; the host resolves the server itself. The Next.js host injects
|
|
449
|
+
window.__fenixHost + posts an init with the taskId and never uses this at all.
|
|
450
|
+
|
|
451
|
+
This used to be a "__SERVER_PREFIX__" placeholder that the opening assistant substituted. That
|
|
452
|
+
is gone — nothing is inlined into artifacts any more. Unlike the other legacy artifacts, this
|
|
453
|
+
one concatenated the placeholder with no fallback, so an unsubstituted copy called
|
|
454
|
+
"__SERVER_PREFIX__get_review_workspace" and failed on Cowork. Keep it empty. */
|
|
455
|
+
const PREFIX = "";
|
|
456
|
+
const TASK_ID_PLACEHOLDER = "__TASK_ID__";
|
|
457
|
+
|
|
458
|
+
// No mock data. The workspace starts empty and is populated entirely by get_review_workspace (host).
|
|
459
|
+
const EMPTY_STATE = {
|
|
460
|
+
taskId: "", taskName: "", matterNo: "", projectId: "", reviewState: "none", taskKind: "review",
|
|
461
|
+
currentUser: { userId: "", initials: "", name: "" },
|
|
462
|
+
documents: [], layout: { version: 1, panels: [] }, queue: [], queueIndex: -1
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
let state = JSON.parse(JSON.stringify(EMPTY_STATE));
|
|
466
|
+
let taskId = null; // resolved at boot (host init, or the server's pick — see boot)
|
|
467
|
+
let emptyQueue = false; // the server looked and there is nothing awaiting this reviewer
|
|
468
|
+
const selection = {}; // panelId -> selected docId (for filter panels)
|
|
469
|
+
const panelFilter = {}; // panelId -> { q, type, h } (name filter / type chip / list height)
|
|
470
|
+
const board = document.getElementById("board");
|
|
471
|
+
|
|
472
|
+
/* ── dual-host data access ─────────────────────────────── */
|
|
473
|
+
let bridgeHost = false; // set true once the Next.js parent handshakes via __fenixInit (postMessage host)
|
|
474
|
+
const hasCowork = () => !!(window.cowork && typeof window.cowork.callMcpTool === "function");
|
|
475
|
+
const hasFenixHost = () => !!(window.__fenixHost && typeof window.__fenixHost.callTool === "function");
|
|
476
|
+
const hasHost = () => hasCowork() || hasFenixHost() || bridgeHost;
|
|
477
|
+
|
|
478
|
+
let seq = 0; const pending = {};
|
|
479
|
+
function bridgeCall(name, args){
|
|
480
|
+
// Front-end host: prefer an injected sync bridge; else postMessage to the parent app.
|
|
481
|
+
if (window.__fenixHost && typeof window.__fenixHost.callTool === "function")
|
|
482
|
+
return window.__fenixHost.callTool(name, args || {});
|
|
483
|
+
return new Promise((resolve, reject) => {
|
|
484
|
+
const id = "c" + (seq++); pending[id] = { resolve, reject };
|
|
485
|
+
parent.postMessage({ __fenixTool: { id, name, args: args || {} } }, "*");
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
window.addEventListener("message", (e) => {
|
|
489
|
+
const r = e.data && e.data.__fenixToolResult;
|
|
490
|
+
if (r && pending[r.id]) { const p = pending[r.id]; delete pending[r.id]; r.error ? p.reject(r.error) : p.resolve(r.result); }
|
|
491
|
+
const init = e.data && e.data.__fenixInit;
|
|
492
|
+
if (init && init.taskId) { taskId = init.taskId; bridgeHost = true; boot(); }
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
async function callTool(name, args){
|
|
496
|
+
if (hasCowork()){
|
|
497
|
+
const r = await window.cowork.callMcpTool(PREFIX + name, args || {});
|
|
498
|
+
const t = r && r.content && r.content[0] && r.content[0].text;
|
|
499
|
+
return t ? JSON.parse(t) : r;
|
|
500
|
+
}
|
|
501
|
+
return bridgeCall(name, args);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* The two-tool surface. Every resource is reached through read or write; these
|
|
505
|
+
wrap callTool because callTool is where the two hosts diverge, and neither
|
|
506
|
+
host cares which tool is named. */
|
|
507
|
+
async function callRead(resource, options){ return callTool("read", options ? { resource, options } : { resource }); }
|
|
508
|
+
async function callWrite(resource, action, options){
|
|
509
|
+
const a = { resource }; if(action) a.action = action; if(options) a.options = options;
|
|
510
|
+
return callTool("write", a);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* ── icons ─────────────────────────────────────────────── */
|
|
514
|
+
const IC = {
|
|
515
|
+
file:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></svg>',
|
|
516
|
+
mail:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
|
|
517
|
+
book:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/></svg>',
|
|
518
|
+
pen:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>',
|
|
519
|
+
gear:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-2.82 1.17V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 8 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 3.6 15H3.5a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 5 8a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 3.6h.09A1.65 1.65 0 0 0 11 2h2a2 2 0 0 1 0 4h-.09"/></svg>',
|
|
520
|
+
check:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
521
|
+
inbox:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
|
|
522
|
+
x:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 6 6 18M6 6l12 12"/></svg>'
|
|
523
|
+
};
|
|
524
|
+
const typeIcon = t => t==="email"?IC.mail : t==="citation"?IC.book : t==="form"?IC.pen : IC.file;
|
|
525
|
+
const typeLabel = t => ({email:"Correspondence",citation:"Prior art",form:"Form",office_action:"Office Action",uspto:"USPTO",oa_response:"OA response"})[t]||t||"Document";
|
|
526
|
+
|
|
527
|
+
/* ── resolve a panel to its documents (mirrors resolvePanelDocuments) ──────── */
|
|
528
|
+
function resolve(source){
|
|
529
|
+
const docs = state.documents || [];
|
|
530
|
+
if(!source) return [];
|
|
531
|
+
if(source.kind==="document") return docs.filter(d=>d.id===source.documentId);
|
|
532
|
+
if(source.kind==="form") return docs.filter(d=>d.formId===source.formId);
|
|
533
|
+
if(source.kind==="filter"){
|
|
534
|
+
// Accept a single documentType or a documentTypes[] (e.g. the OA + prior-art references together).
|
|
535
|
+
const types = source.documentTypes || (source.documentType ? [source.documentType] : null);
|
|
536
|
+
return docs.filter(d=>(!types||types.includes(d.documentType))&&(source.category==null||String(d.category||"")===source.category));
|
|
537
|
+
}
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
const formDoc = fid => (state.documents||[]).find(d=>d.formId===fid);
|
|
541
|
+
|
|
542
|
+
/* ── render ────────────────────────────────────────────── */
|
|
543
|
+
function render(){
|
|
544
|
+
board.innerHTML="";
|
|
545
|
+
const panels = (state.layout && state.layout.panels) || [];
|
|
546
|
+
if(!panels.length){
|
|
547
|
+
const e=document.createElement("div");
|
|
548
|
+
e.style.cssText="flex:1 1 auto; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:13px";
|
|
549
|
+
// "Nothing to review" is an ANSWER, not a failure to load — and it is the
|
|
550
|
+
// one answer this workspace cannot show with panels, so it says it here.
|
|
551
|
+
// Same wording as /dashboards/reviews, which resolves the same way.
|
|
552
|
+
e.textContent = emptyQueue
|
|
553
|
+
? "Nothing is awaiting your review right now."
|
|
554
|
+
: (hasHost() ? "Loading review workspace…" : "Open a review task to load the workspace.");
|
|
555
|
+
board.appendChild(e);
|
|
556
|
+
renderHeader();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
panels.forEach((panel,i)=>{
|
|
560
|
+
board.appendChild(renderPanel(panel));
|
|
561
|
+
if(i<panels.length-1) board.appendChild(gutter(panel, panels[i+1]));
|
|
562
|
+
});
|
|
563
|
+
renderHeader();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function renderHeader(){
|
|
567
|
+
// The selector's resting label IS the current review: matter first, then task.
|
|
568
|
+
document.getElementById("selMatter").textContent = state.matterNo || "—";
|
|
569
|
+
document.getElementById("selName").textContent =
|
|
570
|
+
state.taskName || "Document Review Workspace";
|
|
571
|
+
|
|
572
|
+
const q = state.queue || [];
|
|
573
|
+
const qi = (state.queueIndex==null ? -1 : state.queueIndex);
|
|
574
|
+
document.getElementById("queuePos").textContent = q.length ? ((qi>=0?qi+1:"?") + " / " + q.length) : "—";
|
|
575
|
+
document.getElementById("prevTask").disabled = !(qi>0);
|
|
576
|
+
document.getElementById("nextTask").disabled = !(qi>=0 && qi < q.length-1);
|
|
577
|
+
renderTaskSelList();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* ── The task selector ──────────────────────────────────
|
|
581
|
+
The queue already carries {taskId, name, matterNo} for exactly this, so
|
|
582
|
+
picking a review is the same navigation prev/next performs — just addressed
|
|
583
|
+
directly instead of walked to. */
|
|
584
|
+
let taskSelOpen = false;
|
|
585
|
+
|
|
586
|
+
function renderTaskSelList(){
|
|
587
|
+
const list = document.getElementById("taskSelList");
|
|
588
|
+
const qEl = document.getElementById("taskSelQ");
|
|
589
|
+
const needle = (qEl.value || "").trim().toLowerCase();
|
|
590
|
+
const q = state.queue || [];
|
|
591
|
+
const rows = q.filter(t =>
|
|
592
|
+
!needle ||
|
|
593
|
+
(t.matterNo||"").toLowerCase().includes(needle) ||
|
|
594
|
+
(t.name||"").toLowerCase().includes(needle)
|
|
595
|
+
);
|
|
596
|
+
list.textContent = "";
|
|
597
|
+
if(!rows.length){
|
|
598
|
+
const e = document.createElement("div");
|
|
599
|
+
e.className = "tasksel-empty";
|
|
600
|
+
e.textContent = q.length ? "Nothing matches that filter." : "No reviews in your queue.";
|
|
601
|
+
list.appendChild(e);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
rows.forEach(t => {
|
|
605
|
+
const b = document.createElement("button");
|
|
606
|
+
b.className = "tasksel-item" + (t.taskId === state.taskId ? " current" : "");
|
|
607
|
+
b.setAttribute("role","option");
|
|
608
|
+
const m = document.createElement("span"); m.className = "m"; m.textContent = t.matterNo || "—";
|
|
609
|
+
const n = document.createElement("span"); n.className = "n"; n.textContent = t.name || "Review";
|
|
610
|
+
b.appendChild(m); b.appendChild(n);
|
|
611
|
+
b.onclick = () => {
|
|
612
|
+
closeTaskSel();
|
|
613
|
+
if(t.taskId && t.taskId !== state.taskId) navigate(t.taskId);
|
|
614
|
+
};
|
|
615
|
+
list.appendChild(b);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function openTaskSel(){
|
|
620
|
+
taskSelOpen = true;
|
|
621
|
+
document.getElementById("taskSelDrop").hidden = false;
|
|
622
|
+
document.getElementById("taskSelBtn").setAttribute("aria-expanded","true");
|
|
623
|
+
const qEl = document.getElementById("taskSelQ");
|
|
624
|
+
qEl.value = ""; renderTaskSelList(); qEl.focus();
|
|
625
|
+
}
|
|
626
|
+
function closeTaskSel(){
|
|
627
|
+
taskSelOpen = false;
|
|
628
|
+
document.getElementById("taskSelDrop").hidden = true;
|
|
629
|
+
document.getElementById("taskSelBtn").setAttribute("aria-expanded","false");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Which panel holds the thing being reviewed.
|
|
634
|
+
*
|
|
635
|
+
* The server marks it (`subject: true` on the middle panel of the default
|
|
636
|
+
* layout), but layouts are SAVED per task and older ones carry no marker — so
|
|
637
|
+
* fall back to what "the document under review" means structurally: the panel
|
|
638
|
+
* showing a specific document, or the form to be signed. Filter panels are
|
|
639
|
+
* lists of context; they are never the subject.
|
|
640
|
+
*
|
|
641
|
+
* Deliberately not "panel-2": panels can be reordered and removed, so the id
|
|
642
|
+
* stops describing the role the moment anyone rearranges the board.
|
|
643
|
+
*/
|
|
644
|
+
function isSubjectPanel(panel){
|
|
645
|
+
const panels = (state.layout && state.layout.panels) || [];
|
|
646
|
+
const marked = panels.find(p => p.subject);
|
|
647
|
+
if(marked) return panel.id === marked.id;
|
|
648
|
+
const derived = panels.find(p => p.source && (p.source.kind==="document" || p.source.kind==="form"));
|
|
649
|
+
return !!derived && panel.id === derived.id;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function renderPanel(panel){
|
|
653
|
+
const el = document.createElement("div");
|
|
654
|
+
el.className="panel";
|
|
655
|
+
if(panel.width) el.style.flexGrow = panel.width;
|
|
656
|
+
|
|
657
|
+
const docs = resolve(panel.source);
|
|
658
|
+
const kind = panel.source ? panel.source.kind : "empty";
|
|
659
|
+
|
|
660
|
+
const head = document.createElement("div"); head.className="p-head";
|
|
661
|
+
const title = document.createElement("div"); title.className="p-title"; title.textContent = panel.title || defaultTitle(panel.source);
|
|
662
|
+
// The subject panel's title names what is being signed, so it is not the
|
|
663
|
+
// reviewer's to rewrite — the same reason its source is fixed.
|
|
664
|
+
if(!isSubjectPanel(panel)){
|
|
665
|
+
title.title = "Double-click to rename";
|
|
666
|
+
title.ondblclick = ()=>renameInline(title, panel);
|
|
667
|
+
}
|
|
668
|
+
head.appendChild(title);
|
|
669
|
+
|
|
670
|
+
if(kind==="form"){
|
|
671
|
+
const fd = formDoc(panel.source.formId);
|
|
672
|
+
head.appendChild(statusBadge(fd ? fd.workspaceStatus : "new"));
|
|
673
|
+
} else if(kind==="filter"){
|
|
674
|
+
const c = document.createElement("span"); c.className="count"; c.textContent = docs.length; head.appendChild(c);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* THE SUBJECT PANEL IS LOCKED (Mike, 2026-07-22).
|
|
678
|
+
*
|
|
679
|
+
* The document under review is the reason the workspace is open, and its two
|
|
680
|
+
* decisions — Sign and Request changes — now live on ITS header rather than
|
|
681
|
+
* in the bottom bar, next to the thing being decided about. That only holds
|
|
682
|
+
* if the panel cannot be emptied, removed, or repointed at something else:
|
|
683
|
+
* "Sign" over a panel showing correspondence would be signing whatever
|
|
684
|
+
* happened to be there.
|
|
685
|
+
*
|
|
686
|
+
* The gear IS the whole surface for clear / remove / change-source, so the
|
|
687
|
+
* lock is simply its absence — no separate disabling to keep in sync. Every
|
|
688
|
+
* other panel keeps it.
|
|
689
|
+
*/
|
|
690
|
+
if(isSubjectPanel(panel)){
|
|
691
|
+
el.classList.add("subject");
|
|
692
|
+
const acts = document.createElement("div"); acts.className="p-acts";
|
|
693
|
+
|
|
694
|
+
const rc = document.createElement("button");
|
|
695
|
+
rc.className="pbtn danger"; rc.textContent="Request changes";
|
|
696
|
+
rc.title="Send this back to the drafter with notes";
|
|
697
|
+
rc.onclick = requestChanges;
|
|
698
|
+
acts.appendChild(rc);
|
|
699
|
+
|
|
700
|
+
// Sign vs Approve: a sign task signs (and saves the signed document) then
|
|
701
|
+
// completes; a review task just completes. Same distinction the bottom
|
|
702
|
+
// bar's primary button carried.
|
|
703
|
+
const isSign = state.taskKind === "sign";
|
|
704
|
+
const ok = document.createElement("button");
|
|
705
|
+
ok.className="pbtn primary"; ok.textContent = isSign ? "Sign" : "Approve";
|
|
706
|
+
ok.title = isSign ? "Sign, save the signed document & complete" : "Approve & complete";
|
|
707
|
+
ok.onclick = doPrimary;
|
|
708
|
+
acts.appendChild(ok);
|
|
709
|
+
|
|
710
|
+
head.appendChild(acts);
|
|
711
|
+
} else {
|
|
712
|
+
const gear = document.createElement("button"); gear.className="gear"; gear.innerHTML=IC.gear; gear.title="Configure panel";
|
|
713
|
+
gear.onclick = e => openMenu(e, panel);
|
|
714
|
+
head.appendChild(gear);
|
|
715
|
+
}
|
|
716
|
+
el.appendChild(head);
|
|
717
|
+
|
|
718
|
+
const body = document.createElement("div"); body.className="p-body";
|
|
719
|
+
if(kind==="empty"){ body.appendChild(emptyState("Empty panel","Use the gear to show a document, a filter, or the form to sign.")); }
|
|
720
|
+
else if(kind==="form"){ body.appendChild(signPanel(panel)); }
|
|
721
|
+
else if(kind==="document"){
|
|
722
|
+
docs.length ? body.appendChild(previewWrap(docs[0])) : body.appendChild(emptyState("Not found","That document is no longer on this project."));
|
|
723
|
+
} else if(kind==="filter"){
|
|
724
|
+
if(!docs.length){ body.appendChild(emptyState("Nothing here","No matching documents on this project.")); }
|
|
725
|
+
else filterPanel(head, body, panel, docs);
|
|
726
|
+
}
|
|
727
|
+
el.appendChild(body);
|
|
728
|
+
return el;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function statusBadge(status){
|
|
732
|
+
const s = status||"new";
|
|
733
|
+
const map = {signed:["s-signed","Signed"],finalized:["s-finalized","Finalized"],draft:["s-await","Draft"],new:["s-new","New"]};
|
|
734
|
+
const [cls,txt] = map[s]||map.new;
|
|
735
|
+
const b=document.createElement("span"); b.className="statusdot "+cls; b.textContent=txt; return b;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/* A filter pane. The search box sits in the PANEL HEADER and the document list drops out of it
|
|
739
|
+
(like the shell's global search) instead of taking a fixed slice of the pane — so the preview
|
|
740
|
+
owns the whole body. Type chips live inside the dropdown, where they cost no header room. */
|
|
741
|
+
function filterPanel(head, body, panel, docs){
|
|
742
|
+
const pf = (panelFilter[panel.id] = panelFilter[panel.id] || { q:"", type:"all" });
|
|
743
|
+
|
|
744
|
+
const types = Array.from(new Set(docs.map(d=>d.documentType)));
|
|
745
|
+
/**
|
|
746
|
+
* Chips come from what the panel DECLARES it holds, not from what happens to
|
|
747
|
+
* be in it today (Mike, 2026-07-22: "we need an OA filter in the right panel,
|
|
748
|
+
* along with ALL and References").
|
|
749
|
+
*
|
|
750
|
+
* They were derived from the documents present, so the right panel — declared
|
|
751
|
+
* as office_action + uspto + citation — showed "All / References" on any
|
|
752
|
+
* matter whose office action had not been pulled yet, and the OA filter
|
|
753
|
+
* appeared and vanished depending on the data. A filter set that changes
|
|
754
|
+
* shape per matter cannot be learned. Declared types give it a fixed set; a
|
|
755
|
+
* chip whose bucket is empty renders dimmed, which is itself the answer to
|
|
756
|
+
* "is there an OA on this matter".
|
|
757
|
+
*
|
|
758
|
+
* Plain USPTO docs (892 notices, IDS, transmittals) still get no chip of
|
|
759
|
+
* their own — they live under "All".
|
|
760
|
+
*/
|
|
761
|
+
const declared = (panel.source && panel.source.documentTypes) || null;
|
|
762
|
+
const chipTypes = (declared || types).filter(t=>t!=="uspto");
|
|
763
|
+
const showChips = chipTypes.length >= 1 && (!!declared || types.length >= 2);
|
|
764
|
+
|
|
765
|
+
const wrap=document.createElement("div"); wrap.className="pfilter";
|
|
766
|
+
const input=document.createElement("input"); input.type="text"; input.value=pf.q;
|
|
767
|
+
input.placeholder="Search documents…"; input.setAttribute("aria-label","Search documents in this panel");
|
|
768
|
+
const caret=document.createElement("span"); caret.className="caret"; caret.textContent="▾";
|
|
769
|
+
wrap.appendChild(input); wrap.appendChild(caret);
|
|
770
|
+
// Before the gear: the gear stays the rightmost affordance in every panel header.
|
|
771
|
+
const gearEl = head.querySelector(".gear");
|
|
772
|
+
gearEl ? head.insertBefore(wrap, gearEl) : head.appendChild(wrap);
|
|
773
|
+
|
|
774
|
+
const drop=document.createElement("div"); drop.className="pdrop"; drop.style.display="none";
|
|
775
|
+
const chips=document.createElement("div"); chips.className="chips";
|
|
776
|
+
const rows=document.createElement("div"); rows.className="rows";
|
|
777
|
+
if(showChips) drop.appendChild(chips);
|
|
778
|
+
drop.appendChild(rows); wrap.appendChild(drop);
|
|
779
|
+
|
|
780
|
+
const chipEls={};
|
|
781
|
+
if(showChips){
|
|
782
|
+
const label=t=>({office_action:"OA",uspto:"USPTO",citation:"References",email:"Correspondence",form:"Forms",oa_response:"Response"}[t]||typeLabel(t));
|
|
783
|
+
[["All","all"]].concat(chipTypes.map(t=>[label(t),t])).forEach(([lbl,val])=>{
|
|
784
|
+
const b=document.createElement("button"); b.className="fchip"; b.textContent=lbl;
|
|
785
|
+
// A declared type with nothing behind it is shown, dimmed — "no OA here"
|
|
786
|
+
// is worth saying, and saying it by omission is what made the chip set
|
|
787
|
+
// move around in the first place.
|
|
788
|
+
if(val!=="all" && !types.includes(val)){ b.style.opacity=".45"; b.title="Nothing of this kind on this matter"; }
|
|
789
|
+
b.onmousedown=e=>{ e.preventDefault(); pf.type=val; refresh(); };
|
|
790
|
+
chipEls[val]=b; chips.appendChild(b);
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const openDrop=()=>{ drop.style.display="flex"; };
|
|
795
|
+
const closeDrop=()=>{ drop.style.display="none"; };
|
|
796
|
+
input.onfocus=openDrop;
|
|
797
|
+
input.onclick=openDrop;
|
|
798
|
+
input.oninput=()=>{ pf.q=input.value; openDrop(); refresh(); };
|
|
799
|
+
input.onkeydown=e=>{ if(e.key==="Escape"){ closeDrop(); input.blur(); } };
|
|
800
|
+
// A click inside the dropdown must not close it before the row handler runs; anything else does.
|
|
801
|
+
input.onblur=()=>setTimeout(closeDrop,150);
|
|
802
|
+
|
|
803
|
+
const pv=document.createElement("div"); pv.style.cssText="flex:1 1 auto; min-height:0; display:flex; flex-direction:column; overflow:hidden"; body.appendChild(pv);
|
|
804
|
+
|
|
805
|
+
function refresh(){
|
|
806
|
+
Object.keys(chipEls).forEach(val=> chipEls[val].classList.toggle("active", pf.type===val));
|
|
807
|
+
const q=(pf.q||"").toLowerCase();
|
|
808
|
+
const typeMatch=d=> pf.type==="all" || d.documentType===pf.type;
|
|
809
|
+
const filtered=docs.filter(d=> typeMatch(d) && (!q || (d.name||"").toLowerCase().includes(q)));
|
|
810
|
+
let sel=filtered.find(d=>d.id===selection[panel.id]);
|
|
811
|
+
if(!sel) sel=filtered.find(d=>d.documentType==="office_action")||filtered[0]; // OA first for an OA project
|
|
812
|
+
if(sel) selection[panel.id]=sel.id;
|
|
813
|
+
|
|
814
|
+
rows.innerHTML="";
|
|
815
|
+
if(!filtered.length){
|
|
816
|
+
const e=document.createElement("div"); e.className="empty"; e.style.padding="12px"; e.innerHTML='<h4>No matches</h4>'; rows.appendChild(e);
|
|
817
|
+
}
|
|
818
|
+
filtered.forEach(d=>{
|
|
819
|
+
const row=document.createElement("button"); row.className="docrow"+(sel&&sel.id===d.id?" active":"");
|
|
820
|
+
row.innerHTML=`<span class="di">${typeIcon(d.documentType)}</span><span style="min-width:0"><span class="dn">${esc(d.name)}</span><div class="dm">${typeLabel(d.documentType)}</div></span>`;
|
|
821
|
+
if(d.needsSignature){ const n=document.createElement("span"); n.className="needs"; n.appendChild(statusBadge("finalized")); n.firstChild.textContent="Sign"; row.appendChild(n); }
|
|
822
|
+
row.onmousedown=e=>{ e.preventDefault(); selection[panel.id]=d.id; closeDrop(); refresh(); };
|
|
823
|
+
rows.appendChild(row);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// The header box doubles as the current-selection label when not being typed in.
|
|
827
|
+
if(document.activeElement!==input) input.value = pf.q || (sel ? sel.name : "");
|
|
828
|
+
|
|
829
|
+
pv.innerHTML="";
|
|
830
|
+
if(sel) pv.appendChild(previewWrap(sel)); else pv.appendChild(emptyState("Nothing here","No matching documents."));
|
|
831
|
+
}
|
|
832
|
+
refresh();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/* The ACTUAL file, embedded — the reference PDFs, the stored document. The #pagemode=none/navpanes=0
|
|
836
|
+
hint opens the PDF with its thumbnail/sidebar panel CLOSED by default. */
|
|
837
|
+
function docFrame(doc){
|
|
838
|
+
const f=document.createElement("iframe"); f.className="docframe";
|
|
839
|
+
const u=doc.previewUrl||"";
|
|
840
|
+
f.src = u + (u.indexOf("#")<0 ? "#pagemode=none&navpanes=0&toolbar=1" : "");
|
|
841
|
+
f.title=doc.name||"document";
|
|
842
|
+
return f;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/* The ACTUAL document content rendered as (editable) HTML — the OA response, not a Word doc. */
|
|
846
|
+
function htmlDoc(doc){
|
|
847
|
+
const p=document.createElement("div"); p.className="paper htmldoc";
|
|
848
|
+
const stamp = doc.documentType==="oa_response" ? "USPTO · Response" : typeLabel(doc.documentType).toUpperCase();
|
|
849
|
+
const body=document.createElement("div"); body.className="htmlbody"; body.innerHTML=doc.contentHtml||"";
|
|
850
|
+
p.innerHTML = `<span class="stamp">${esc(stamp)}</span><h3>${esc(doc.name||"Document")}</h3>`;
|
|
851
|
+
p.appendChild(body);
|
|
852
|
+
return p;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function previewWrap(doc){
|
|
856
|
+
const wrap=document.createElement("div"); wrap.className="preview";
|
|
857
|
+
if(doc.previewKind==="pdf" && doc.previewUrl){ wrap.classList.add("pdfwrap"); wrap.appendChild(docFrame(doc)); return wrap; }
|
|
858
|
+
if(doc.contentHtml){ wrap.appendChild(htmlDoc(doc)); return wrap; }
|
|
859
|
+
wrap.appendChild(paper(doc,false));
|
|
860
|
+
return wrap;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/* faux document "paper" — stand-in for the real presigned-PDF preview */
|
|
864
|
+
function paper(doc, signed){
|
|
865
|
+
const p=document.createElement("div"); p.className="paper";
|
|
866
|
+
const isForm = doc.documentType==="form";
|
|
867
|
+
const stamp = isForm ? "USPTO · Application Data Sheet" : typeLabel(doc.documentType).toUpperCase();
|
|
868
|
+
p.innerHTML = `<span class="stamp">${esc(stamp)}</span>
|
|
869
|
+
<h3>${esc(doc.name)}</h3>
|
|
870
|
+
<div class="sub">${esc(doc.documentType)}${doc.category?" · "+esc(doc.category):""}</div>
|
|
871
|
+
<div class="bar l"></div><div class="bar m"></div><div class="bar l"></div>
|
|
872
|
+
<div class="bar s"></div><div class="bar m"></div><div class="bar xs"></div>`;
|
|
873
|
+
if(isForm){
|
|
874
|
+
const sig=document.createElement("div"); sig.className="sigblock";
|
|
875
|
+
const line=document.createElement("div"); line.className="sigline";
|
|
876
|
+
const st = formDoc(doc.formId);
|
|
877
|
+
const isSigned = signed || (st && st.workspaceStatus==="signed");
|
|
878
|
+
const who = (state.currentUser && state.currentUser.name) || "Attorney";
|
|
879
|
+
line.innerHTML = `<span class="lbl">Signature</span><span class="ln">${isSigned?'<span class="sig-script">/'+esc(who).replace(/ /g," ")+'/</span>':''}</span>`;
|
|
880
|
+
const dt=document.createElement("div"); dt.className="sub"; dt.style.marginTop="8px";
|
|
881
|
+
dt.textContent = isSigned ? ("signed "+today()) : "";
|
|
882
|
+
sig.appendChild(line); sig.appendChild(dt); p.appendChild(sig);
|
|
883
|
+
}
|
|
884
|
+
return p;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/* sign panel — status strip + "sign when ready" + live preview */
|
|
888
|
+
function signPanel(panel){
|
|
889
|
+
const wrap=document.createElement("div"); wrap.style.cssText="display:flex;flex-direction:column;min-height:0;flex:1 1 auto";
|
|
890
|
+
const fd = formDoc(panel.source.formId);
|
|
891
|
+
const strip=document.createElement("div"); strip.className="signstrip";
|
|
892
|
+
const who=document.createElement("div"); who.className="who";
|
|
893
|
+
const signed = fd && fd.workspaceStatus==="signed";
|
|
894
|
+
const canSign = fd && fd.intendedSigner===(state.currentUser && state.currentUser.initials);
|
|
895
|
+
who.innerHTML = signed
|
|
896
|
+
? `<div class="k">Signed by</div><div class="v">${esc((state.currentUser&&state.currentUser.name)||"")} · ${today()}</div>`
|
|
897
|
+
: `<div class="k">Intended signer</div><div class="v">${esc((state.currentUser&&state.currentUser.name)||"")} (${esc((state.currentUser&&state.currentUser.initials)||"")})</div>`;
|
|
898
|
+
strip.appendChild(who);
|
|
899
|
+
|
|
900
|
+
const act=document.createElement("div"); act.className="act";
|
|
901
|
+
if(signed){
|
|
902
|
+
const b=document.createElement("button"); b.className="btn"; b.disabled=true;
|
|
903
|
+
b.innerHTML = IC.check+" Signed"; act.appendChild(b);
|
|
904
|
+
} else {
|
|
905
|
+
const b=document.createElement("button"); b.className="btn primary";
|
|
906
|
+
b.innerHTML = IC.pen+" Sign when ready";
|
|
907
|
+
b.disabled = !canSign;
|
|
908
|
+
if(!canSign) b.title="Only the intended signer can sign";
|
|
909
|
+
b.onclick=()=>doSign(fd);
|
|
910
|
+
act.appendChild(b);
|
|
911
|
+
}
|
|
912
|
+
strip.appendChild(act);
|
|
913
|
+
wrap.appendChild(strip);
|
|
914
|
+
|
|
915
|
+
const pv=document.createElement("div"); pv.className="preview"; pv.style.paddingTop="6px";
|
|
916
|
+
if(fd && fd.previewKind==="pdf" && fd.previewUrl){ pv.classList.add("pdfwrap"); pv.appendChild(docFrame(fd)); }
|
|
917
|
+
else if(fd && fd.contentHtml){ pv.appendChild(htmlDoc(fd)); }
|
|
918
|
+
else pv.appendChild(paper(fd,signed));
|
|
919
|
+
wrap.appendChild(pv);
|
|
920
|
+
return wrap;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function emptyState(h,p){
|
|
924
|
+
const e=document.createElement("div"); e.className="empty";
|
|
925
|
+
e.innerHTML = `<span class="big">${IC.inbox}</span><h4>${esc(h)}</h4><p>${esc(p)}</p>`;
|
|
926
|
+
return e;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function defaultTitle(source){
|
|
930
|
+
if(!source) return "New panel";
|
|
931
|
+
if(source.kind==="form") return "Document to sign";
|
|
932
|
+
if(source.kind==="document") return "Document";
|
|
933
|
+
if(source.kind==="filter"){ return {email:"Correspondence",citation:"References",office_action:"Office actions",uspto:"USPTO documents"}[source.documentType]||"Documents"; }
|
|
934
|
+
return "New panel";
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/* ── interactions ──────────────────────────────────────── */
|
|
938
|
+
async function doSign(fd){
|
|
939
|
+
if(!fd) return;
|
|
940
|
+
if(hasHost()){
|
|
941
|
+
try{ await callWrite("review", "sign-form", { formDocumentId: fd.id }); }
|
|
942
|
+
catch(e){ toast("Sign failed"); return; }
|
|
943
|
+
}
|
|
944
|
+
fd.workspaceStatus="signed"; fd.needsSignature=false;
|
|
945
|
+
render();
|
|
946
|
+
toast("Signed — "+fd.name);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* The primary action closes the task and advances to the next review. Sign task -> sign_oa_response
|
|
950
|
+
(creates + saves the signed document, then completes); review task -> complete_review_task. */
|
|
951
|
+
async function doPrimary(){
|
|
952
|
+
const isSign = state.taskKind === "sign";
|
|
953
|
+
const action = isSign ? "sign-oa-response" : "complete";
|
|
954
|
+
if(hasHost()){
|
|
955
|
+
try{ await callWrite("review", action, { taskId }); }
|
|
956
|
+
catch(e){ toast((isSign?"Sign":"Approve")+" failed"); return; }
|
|
957
|
+
}
|
|
958
|
+
toast(isSign ? "Signed & completed" : "Approved & completed");
|
|
959
|
+
advance();
|
|
960
|
+
}
|
|
961
|
+
function advance(){
|
|
962
|
+
const q = state.queue || [];
|
|
963
|
+
const qi = (state.queueIndex==null ? -1 : state.queueIndex);
|
|
964
|
+
const next = qi>=0 ? q[qi+1] : (q[0]||null);
|
|
965
|
+
if(next && next.taskId) navigate(next.taskId);
|
|
966
|
+
else toast("No more reviews in your queue");
|
|
967
|
+
}
|
|
968
|
+
function navigate(tid){
|
|
969
|
+
if(!tid) return;
|
|
970
|
+
if(hasCowork()){ taskId=tid; boot(); return; } // MCP: reload the workspace in place
|
|
971
|
+
if(window.__fenixHost && window.__fenixHost.navigate){ window.__fenixHost.navigate(tid); return; }
|
|
972
|
+
parent.postMessage({ __fenixNavigate:{ taskId:tid } }, "*"); // FE: parent routes to that task
|
|
973
|
+
}
|
|
974
|
+
/* `openProject` lived here, called by the matter number in the old bar. Both are
|
|
975
|
+
gone (Mike, 2026-07-22): the matter number is now the selector's label, and
|
|
976
|
+
opening the matter is the crumb bar's job one row up in the shell — which is
|
|
977
|
+
exactly the chrome a node is not supposed to duplicate (§4). */
|
|
978
|
+
async function requestChanges(){
|
|
979
|
+
const note = prompt("What needs to change?"); if(note===null) return;
|
|
980
|
+
if(hasHost()){
|
|
981
|
+
try{ await callWrite("task", "review", { taskId, decision:"request_changes", notes:note }); }catch(e){ toast("Failed"); return; }
|
|
982
|
+
}
|
|
983
|
+
state.reviewState = "changes_requested"; renderHeader(); toast("Changes requested");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function renameInline(node, panel){
|
|
987
|
+
node.setAttribute("contenteditable","true"); node.focus();
|
|
988
|
+
const sel=window.getSelection(); const r=document.createRange(); r.selectNodeContents(node); sel.removeAllRanges(); sel.addRange(r);
|
|
989
|
+
const done=()=>{ node.removeAttribute("contenteditable"); panel.title=node.textContent.trim()||undefined; scheduleSave(); node.textContent=panel.title||defaultTitle(panel.source); };
|
|
990
|
+
node.onblur=done;
|
|
991
|
+
node.onkeydown=e=>{ if(e.key==="Enter"){e.preventDefault();node.blur();} if(e.key==="Escape"){node.textContent=panel.title||defaultTitle(panel.source);node.blur();} };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function setSource(panel, source, title){
|
|
995
|
+
panel.source=source; if(title!==undefined) panel.title=title; delete selection[panel.id];
|
|
996
|
+
render(); scheduleSave();
|
|
997
|
+
}
|
|
998
|
+
function addPanel(){
|
|
999
|
+
const used=new Set(state.layout.panels.map(p=>p.id)); let n=1; while(used.has("panel-"+n)) n++;
|
|
1000
|
+
state.layout.panels.push({ id:"panel-"+n, title:"New panel", source:{kind:"empty"} });
|
|
1001
|
+
render(); scheduleSave();
|
|
1002
|
+
}
|
|
1003
|
+
function removePanel(panel){
|
|
1004
|
+
state.layout.panels=state.layout.panels.filter(p=>p.id!==panel.id); render(); scheduleSave();
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/* gear popover */
|
|
1008
|
+
let openEl=null;
|
|
1009
|
+
function closeMenu(){ if(openEl){openEl.remove(); openEl=null; document.removeEventListener("mousedown",onDoc,true);} }
|
|
1010
|
+
function onDoc(e){ if(openEl && !openEl.contains(e.target)) closeMenu(); }
|
|
1011
|
+
function openMenu(e, panel){
|
|
1012
|
+
e.stopPropagation(); closeMenu();
|
|
1013
|
+
const m=document.createElement("div"); m.className="menu";
|
|
1014
|
+
const forms = (state.documents||[]).filter(d=>d.documentType==="form");
|
|
1015
|
+
const mkItem=(icon,label,fn,danger)=>{ const b=document.createElement("button"); b.className="mi"+(danger?" danger":""); b.innerHTML=icon+"<span>"+label+"</span>"; b.onclick=()=>{closeMenu();fn();}; return b; };
|
|
1016
|
+
m.insertAdjacentHTML("beforeend",'<div class="lbl">Show in this panel</div>');
|
|
1017
|
+
forms.forEach(f=> m.appendChild(mkItem(IC.pen,"Sign: "+f.name.slice(0,22)+(f.name.length>22?"…":""),()=>setSource(panel,{kind:"form",formId:f.formId},"Document to sign"))) );
|
|
1018
|
+
m.appendChild(mkItem(IC.mail,"All correspondence",()=>setSource(panel,{kind:"filter",documentType:"email"},"Correspondence")));
|
|
1019
|
+
m.appendChild(mkItem(IC.book,"Prior-art references",()=>setSource(panel,{kind:"filter",documentType:"citation"},"References")));
|
|
1020
|
+
m.appendChild(mkItem(IC.file,"Office actions",()=>setSource(panel,{kind:"filter",documentType:"office_action"},"Office actions")));
|
|
1021
|
+
// The USPTO panel declares the whole family (OA + USPTO + references), NOT a
|
|
1022
|
+
// bare "uspto" bucket — otherwise its filter panel offers no OA chip and the
|
|
1023
|
+
// office action is filtered out of its own document set, which is exactly how
|
|
1024
|
+
// the OA chip went missing on a saved layout (see reconcileLayout server-side).
|
|
1025
|
+
m.appendChild(mkItem(IC.file,"USPTO documents",()=>setSource(panel,{kind:"filter",documentTypes:["office_action","uspto","citation"]},"USPTO Documents")));
|
|
1026
|
+
m.appendChild(document.createElement("div")).className="sep";
|
|
1027
|
+
m.appendChild(mkItem(IC.x,"Clear panel",()=>setSource(panel,{kind:"empty"},"New panel")));
|
|
1028
|
+
m.appendChild(mkItem(IC.x,"Remove panel",()=>removePanel(panel),true));
|
|
1029
|
+
document.body.appendChild(m);
|
|
1030
|
+
const r=e.currentTarget.getBoundingClientRect();
|
|
1031
|
+
m.style.top=(r.bottom+6)+"px";
|
|
1032
|
+
m.style.left=Math.max(8,Math.min(r.right-m.offsetWidth, window.innerWidth-m.offsetWidth-8))+"px";
|
|
1033
|
+
openEl=m; setTimeout(()=>document.addEventListener("mousedown",onDoc,true),0);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/* draggable gutter between two panels */
|
|
1037
|
+
function gutter(a,b){
|
|
1038
|
+
const g=document.createElement("div"); g.className="gutter"; g.innerHTML="<i></i>";
|
|
1039
|
+
g.onpointerdown=e=>{
|
|
1040
|
+
e.preventDefault(); const startX=e.clientX;
|
|
1041
|
+
const pa=g.previousElementSibling, pb=g.nextElementSibling;
|
|
1042
|
+
const wa=pa.getBoundingClientRect().width, wb=pb.getBoundingClientRect().width;
|
|
1043
|
+
const ga=parseFloat(getComputedStyle(pa).flexGrow)||1, gb=parseFloat(getComputedStyle(pb).flexGrow)||1;
|
|
1044
|
+
const totalG=ga+gb, totalW=wa+wb;
|
|
1045
|
+
g.setPointerCapture(e.pointerId);
|
|
1046
|
+
const move=ev=>{
|
|
1047
|
+
let nwa=wa+(ev.clientX-startX); nwa=Math.max(150,Math.min(totalW-150,nwa));
|
|
1048
|
+
const fa=totalG*(nwa/totalW), fb=totalG-fa;
|
|
1049
|
+
pa.style.flexGrow=fa; pb.style.flexGrow=fb;
|
|
1050
|
+
a.width=fa; b.width=fb;
|
|
1051
|
+
};
|
|
1052
|
+
const up=()=>{ g.releasePointerCapture(e.pointerId); g.removeEventListener("pointermove",move); g.removeEventListener("pointerup",up); scheduleSave(); };
|
|
1053
|
+
g.addEventListener("pointermove",move); g.addEventListener("pointerup",up);
|
|
1054
|
+
};
|
|
1055
|
+
return g;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/* ── persistence ───────────────────────────────────────── */
|
|
1059
|
+
let saveT=null;
|
|
1060
|
+
function scheduleSave(){
|
|
1061
|
+
clearTimeout(saveT);
|
|
1062
|
+
saveT=setTimeout(async ()=>{
|
|
1063
|
+
if(hasHost()){ try{ await callWrite("review", "save-layout", { taskId, layout: state.layout }); }catch(e){} }
|
|
1064
|
+
// The host pill carried this feedback; with it gone the toast does, which is
|
|
1065
|
+
// where every other confirmation in this workspace already appears.
|
|
1066
|
+
toast("Layout saved");
|
|
1067
|
+
},700);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/* ── boot ──────────────────────────────────────────────── */
|
|
1071
|
+
async function boot(){
|
|
1072
|
+
const tid = taskId || (TASK_ID_PLACEHOLDER.indexOf("__")===0 ? null : TASK_ID_PLACEHOLDER);
|
|
1073
|
+
if(hasHost()){
|
|
1074
|
+
taskId = tid;
|
|
1075
|
+
try{
|
|
1076
|
+
/**
|
|
1077
|
+
* NO taskId IS A NORMAL WAY TO ARRIVE. Under the front-end host the
|
|
1078
|
+
* parent posts one; under the MCP host nothing can be inlined into an
|
|
1079
|
+
* artifact and there is no chat→artifact channel, so this page opens
|
|
1080
|
+
* blind. It used to give up there — `if(hasHost() && tid)` — which is
|
|
1081
|
+
* why it showed an empty board and "No reviews in your queue" no matter
|
|
1082
|
+
* how many reviews were waiting (Mike, 2026-07-22: "it can't find the
|
|
1083
|
+
* review tasks"). The placeholder it was still waiting for, __TASK_ID__,
|
|
1084
|
+
* was retired when substitution into artifacts was banned.
|
|
1085
|
+
*
|
|
1086
|
+
* So ASK. Omitting taskId makes the server resolve it — the session's
|
|
1087
|
+
* context task if it is one of yours to review, else the head of your
|
|
1088
|
+
* queue — which is exactly what /dashboards/reviews does on the website.
|
|
1089
|
+
*/
|
|
1090
|
+
const ws = await callRead("review-workspace", tid ? { taskId: tid } : {});
|
|
1091
|
+
if(ws && ws.empty){ emptyQueue = true; }
|
|
1092
|
+
else if(ws && ws.documents){ state = ws; taskId = state.taskId || tid; }
|
|
1093
|
+
}catch(e){
|
|
1094
|
+
// The host pill that used to report "app · live" / "load failed" is gone
|
|
1095
|
+
// (Mike, 2026-07-22). It described the PLUMBING, which is not a reviewer's
|
|
1096
|
+
// concern; a load that fails now shows as an empty workspace and says so
|
|
1097
|
+
// in the selector, which is the same information in the reviewer's terms.
|
|
1098
|
+
toast("Could not load this review");
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
render();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/* ── helpers ───────────────────────────────────────────── */
|
|
1105
|
+
function esc(s){ return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
|
1106
|
+
function today(){ const d=new Date(); return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); }
|
|
1107
|
+
let toastT=null;
|
|
1108
|
+
function toast(msg){
|
|
1109
|
+
const t=document.getElementById("toast"); t.innerHTML=IC.check+"<span>"+esc(msg)+"</span>"; t.classList.add("show");
|
|
1110
|
+
clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove("show"),2600);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
document.getElementById("addPanel").onclick=addPanel;
|
|
1114
|
+
document.getElementById("prevTask").onclick=()=>{ const q=state.queue||[],qi=state.queueIndex; if(qi>0) navigate(q[qi-1] && q[qi-1].taskId); };
|
|
1115
|
+
document.getElementById("nextTask").onclick=()=>{ const q=state.queue||[],qi=state.queueIndex; if(qi>=0&&qi<q.length-1) navigate(q[qi+1] && q[qi+1].taskId); };
|
|
1116
|
+
|
|
1117
|
+
/* The task selector. Decisions (Sign / Request changes) are no longer here —
|
|
1118
|
+
they live on the reviewed document's own header. The back button went with
|
|
1119
|
+
them: the shell above this node has its own nav, and a node must not carry
|
|
1120
|
+
chrome that competes with it (§4). */
|
|
1121
|
+
document.getElementById("taskSelBtn").onclick=(e)=>{ e.stopPropagation(); taskSelOpen ? closeTaskSel() : openTaskSel(); };
|
|
1122
|
+
document.getElementById("taskSelQ").oninput=renderTaskSelList;
|
|
1123
|
+
document.getElementById("taskSelQ").onkeydown=(e)=>{ if(e.key==="Escape"){ e.stopPropagation(); closeTaskSel(); } };
|
|
1124
|
+
document.getElementById("taskSelDrop").onclick=(e)=>e.stopPropagation();
|
|
1125
|
+
document.addEventListener("click",()=>{ if(taskSelOpen) closeTaskSel(); });
|
|
1126
|
+
document.addEventListener("keydown",(e)=>{ if(e.key==="Escape"&&taskSelOpen) closeTaskSel(); });
|
|
1127
|
+
window.addEventListener("resize",()=>{ closeMenu(); closeTaskSel(); });
|
|
1128
|
+
|
|
1129
|
+
// Tell the FE host we're ready to receive an init (taskId). The MCP host boots immediately.
|
|
1130
|
+
parent.postMessage({ __fenixReviewReady:true }, "*");
|
|
1131
|
+
boot();
|
|
1132
|
+
</script>
|
|
1133
|
+
|
|
1134
|
+
</body>
|
|
1135
|
+
</html>
|