Kennedy Johnson Cursor commited on
Commit
3dffa48
·
1 Parent(s): 371ef22

Require user text before critic widgets generate a critique.

Browse files

Devil's Advocate, Scope Realism, and Reviewer 2 were auto-responding on empty fields and mouse clicks could not reach the inputs.

Co-authored-by: Cursor <cursoragent@cursor.com>

phd-advisor-frontend/src/components/canvas/CanvasCriticWidgets.js CHANGED
@@ -4,6 +4,20 @@ import Icon from './CanvasIcon';
4
  const fireToast = (msg, kind = 'success') =>
5
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  // Critic widgets are scripted in canvas; the *real* critique should happen in
8
  // the main chat so message history lives in one place (per Daniel's review).
9
  // "Open in chat" stashes a draft prompt + persona hint then asks CanvasPage to
@@ -18,45 +32,229 @@ const handoffToChat = (persona, prompt, context = {}) => {
18
  fireToast(`Opening ${persona} in chat — full history will be there.`);
19
  };
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  // ---------- Reviewer 2 widget ----------
22
  export function Reviewer2Widget({ state, setState, openModal }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  return (
24
  <>
25
- <div className="critic-prompt">
26
- "Paste a draft paragraph, abstract, or section. I'll respond as the most uncharitable but technically competent reviewer your work will ever see."
27
- </div>
28
- {state.lastReview ? (
 
 
 
 
 
 
 
 
 
 
29
  <div className="review">
30
  <span className="review-tag">Last critique · {state.lastReview.severity}/10 severity</span>
31
  <div style={{ marginBottom: 6 }}><strong>Major:</strong> {state.lastReview.major}</div>
32
  <div style={{ color: 'var(--canvas-text-2)', fontSize: 12 }}>+{state.lastReview.minorCount} minor issues, {state.lastReview.suggestionCount} suggestions</div>
33
  </div>
34
- ) : (
35
- <div style={{ fontSize: 11.5, color: 'var(--canvas-text-3)', fontStyle: 'italic', padding: '4px 2px' }}>
36
- No drafts reviewed yet.
37
- </div>
38
  )}
39
  <div className="critic-meter">
40
  <span>tone</span>
41
  <div className="bar"><i style={{ width: '92%' }}/></div>
42
  <span style={{ color: 'var(--canvas-critic)' }}>harsh</span>
43
  </div>
44
- <div style={{ display: 'flex', gap: 6, alignSelf: 'flex-start' }}>
45
  <button
46
  className="btn btn-critic"
47
- onClick={() => openModal('reviewer-2', {
48
- initial: state.lastDraft,
49
- onComplete: (review) => setState({ ...state, lastDraft: review.draft, lastReview: review }),
50
- })}
51
  >
52
- <Icon name="gavel" size={13}/>Get critique
53
  </button>
 
 
 
 
 
 
 
 
 
 
 
54
  <button
55
  className="btn"
56
  title="Open Reviewer 2 in the main chat (history lives there)"
57
- onClick={() => handoffToChat('Reviewer 2', state.lastDraft
58
- ? `Critique this draft as Reviewer 2: "${state.lastDraft}"`
59
- : 'Open Reviewer 2 and ready a critique.')}
60
  >
61
  <Icon name="message" size={13}/>Open in chat
62
  </button>
@@ -67,37 +265,76 @@ export function Reviewer2Widget({ state, setState, openModal }) {
67
 
68
  // ---------- Devil's Advocate widget ----------
69
  export function DevilsAdvocateWidget({ state, setState, openModal }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  return (
71
  <>
72
- <div style={{ fontSize: 11, color: 'var(--canvas-text-3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Your claim</div>
73
- <div style={{ fontSize: 13, color: 'var(--canvas-text)', fontWeight: 500, lineHeight: 1.4, padding: '4px 0' }}>"{state.claim}"</div>
74
- <div style={{ fontSize: 11, color: 'var(--canvas-critic)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginTop: 4 }}>
75
- Strongest counters · {state.counters.length}
76
- </div>
77
- <div className="devil-list">
78
- {state.counters.slice(0, 3).map((c, i) => (
79
- <div key={i} className="devil-item">
80
- <div className="lbl">{c.lbl}</div>
81
- <div>{c.text}</div>
 
 
 
 
 
 
82
  </div>
83
- ))}
84
- </div>
85
- <div style={{ display: 'flex', gap: 6, alignSelf: 'flex-start' }}>
 
 
 
 
 
 
 
 
86
  <button
87
  className="btn btn-critic"
88
- onClick={() => openModal('devils-advocate', {
89
- claim: state.claim,
90
- counters: state.counters,
91
- onUpdate: (next) => setState({ ...state, ...next }),
92
- })}
93
  >
94
- <Icon name="scale" size={13}/>Push harder
95
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
96
  <button
97
  className="btn"
98
  title="Open Devil's Advocate in the main chat (history lives there)"
99
  onClick={() => handoffToChat("Devil's Advocate",
100
- `Take the position of devil's advocate on my claim: "${state.claim || 'my current training plan'}". Be ruthless.`)}
 
 
101
  >
102
  <Icon name="message" size={13}/>Open in chat
103
  </button>
@@ -107,42 +344,87 @@ export function DevilsAdvocateWidget({ state, setState, openModal }) {
107
  }
108
 
109
  // ---------- Scope realism widget ----------
110
- export function ScopeRealismWidget({ state, openModal }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  return (
112
  <div className="realism">
113
- <div className="realism-verdict">
114
- <div className="verdict-head">
115
- <div className="verdict-score">{state.score.toFixed(1)}</div>
116
- <div>
117
- <div className="verdict-label">Feasibility</div>
118
- <div style={{ fontSize: 12, color: 'var(--canvas-text-2)', marginTop: 2 }}>{state.label}</div>
119
- </div>
 
 
 
 
120
  </div>
121
- <div style={{ fontSize: 11, color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)' }}>
122
- target: {state.target}
123
- </div>
124
- </div>
125
- <div>
126
- {state.factors.map(f => (
127
- <div key={f.label} className="realism-row">
128
- <span className="label-cell">{f.label}</span>
129
- <span className="gauge"><i style={{ width: f.val + '%' }}/></span>
130
- <span className="val">{f.val}</span>
 
 
 
 
131
  </div>
132
- ))}
133
- </div>
134
- <div style={{ display: 'flex', gap: 6, alignSelf: 'flex-start' }}>
 
 
 
 
 
 
 
 
 
135
  <button
136
  className="btn btn-critic"
137
- onClick={() => openModal('scope-realism', { state })}
 
138
  >
139
- <Icon name="bullseye" size={13}/>Read full verdict
140
  </button>
 
 
 
 
 
 
 
 
 
 
 
141
  <button
142
  className="btn"
143
  title="Open Scope Realism in the main chat (history lives there)"
144
  onClick={() => handoffToChat('Scope Realism',
145
- `Run a brutal feasibility check on my goal: "${state.target || 'my current clean-eating goal'}". Be specific about what's at risk.`)}
 
 
146
  >
147
  <Icon name="message" size={13}/>Open in chat
148
  </button>
@@ -155,63 +437,17 @@ export function ScopeRealismWidget({ state, openModal }) {
155
  // Critic modals
156
  // ===================================================================
157
 
158
- const REVIEW_TEMPLATES = [
159
- {
160
- severity: 8,
161
- major: 'The eating plan states a goal ("eat healthier") before defining what success looks like. You never specify the measurable target — which meals, how many produce servings/week, by when — so there is no way to tell if the plan is working or when to change it.',
162
- minor: [
163
- 'Weekly volume is described as "more vegetables" without a concrete count or quality bar.',
164
- 'No rule for when to pause new recipes and reuse a shortlist instead.',
165
- 'Snack strategy is mentioned once with no prep cadence or tracking.',
166
- 'No Friday review is scheduled — leftover waste will stay invisible.',
167
- ],
168
- suggestions: [
169
- 'Add one sentence naming the target: e.g. "5 produce-forward dinners/week through April 15."',
170
- 'Define what "quality meal" means (real-food ingredients + one produce item).',
171
- 'Add a weekly kitchen review with homemade-meal count.',
172
- ],
173
- },
174
- {
175
- severity: 7,
176
- major: 'You claim this approach is "optimal for eating clean." That word is doing too much work. The plan is also compatible with worse outcomes (generic takeout, skipped prep, burnout). Without a way to check whether homemade meals improve, "optimal" is untestable.',
177
- minor: [
178
- 'The shopping mix is justified by "everyone buys salad kits" rather than by your kitchen time and season.',
179
- 'No statement of what would make you change the plan.',
180
- 'A meal plan with no tracked numbers is a red flag — you cannot audit it later.',
181
- ],
182
- suggestions: [
183
- 'Replace "optimal" with a specific target the plan either hits or misses.',
184
- 'List 1–2 signals (0 homemade lunches after 5 days, missed prep blocks) that trigger a change.',
185
- ],
186
- },
187
- {
188
- severity: 9,
189
- major: 'This reads like a wish list, not a plan. There is no number. There is no timeframe. The strongest claim is that you want to "find something good" — which is the lowest possible bar. If you actually care about the result, lead with the measurable goal, not the vibe.',
190
- minor: [
191
- 'The word "meal prep" appears without a single shopping list drafted.',
192
- '"Dream recipe" is undefined; name 10 real meals.',
193
- 'No mention of pantry staples, despite them driving most weeknight outcomes.',
194
- ],
195
- suggestions: [
196
- 'Lead sentence: "By <date> I will <specific, measurable result>."',
197
- 'Cut "I want to eat healthier" entirely. Replace it with meal lane, volume, and a date.',
198
- ],
199
- },
200
- ];
201
-
202
  export function ReviewerModal({ data, onClose }) {
203
  const [draft, setDraft] = useState(data.initial || '');
204
  const [running, setRunning] = useState(false);
205
  const [review, setReview] = useState(null);
206
 
207
  const run = () => {
208
- if (!draft.trim()) return;
209
  setRunning(true);
210
  setReview(null);
211
  setTimeout(() => {
212
- const idx = (draft.length + draft.split(' ').length) % REVIEW_TEMPLATES.length;
213
- const r = REVIEW_TEMPLATES[idx];
214
- setReview(r);
215
  setRunning(false);
216
  }, 900);
217
  };
@@ -240,13 +476,16 @@ export function ReviewerModal({ data, onClose }) {
240
  <button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
241
  </div>
242
  <div className="modal-body">
243
- <div className="form-row">
244
- <label className="label">Your draft</label>
245
  <textarea
246
- className="textarea"
 
247
  style={{ minHeight: 120, fontFamily: 'var(--canvas-mono)', fontSize: 12.5 }}
248
  value={draft}
249
- onChange={e => setDraft(e.target.value)}
 
 
250
  placeholder="Paste an abstract, a claim, a paragraph…"
251
  />
252
  <div style={{ fontSize: 11, color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)', textAlign: 'right' }}>{draft.length} chars · {draft.trim().split(/\s+/).filter(Boolean).length} words</div>
@@ -303,7 +542,7 @@ export function ReviewerModal({ data, onClose }) {
303
  <button className="btn btn-critic" onClick={accept}><Icon name="check" size={13}/>Save to widget</button>
304
  </>
305
  ) : (
306
- <button className="btn btn-critic" onClick={run} disabled={!draft.trim() || running}>
307
  <Icon name="gavel" size={13}/>{running ? 'Reviewing…' : 'Critique my draft'}
308
  </button>
309
  )}
@@ -312,28 +551,22 @@ export function ReviewerModal({ data, onClose }) {
312
  );
313
  }
314
 
315
- const HARDER_COUNTERS = [
316
- { lbl: 'Reverse causation', text: 'You assume the program drove your gains. The opposite is just as likely early on — newbie gains and better sleep would have driven progress on almost any plan. You may be crediting the split for something else.' },
317
- { lbl: 'Goalpost shift', text: 'When the scale doesn\'t move you\'ll be tempted to redefine "progress" until it does (now it\'s "recomp," now it\'s "strength"). Pick your primary metric up front or you can\'t honestly say the plan worked.' },
318
- { lbl: 'Wrong lever', text: 'You\'re tweaking exercise selection when the real bottleneck is total volume and calories. You\'re optimizing the variable that matters least.' },
319
- { lbl: 'Copied context', text: 'This program was built for a lifter training 5 days a week with years of experience. On your schedule and recovery it may be testing a plan that doesn\'t apply to you.' },
320
- ];
321
-
322
  export function DevilsModal({ data, onClose }) {
323
- const [counters, setCounters] = useState(data.counters);
 
324
  const [pushing, setPushing] = useState(false);
325
 
326
- const pushHarder = () => {
 
327
  setPushing(true);
328
  setTimeout(() => {
329
- const next = HARDER_COUNTERS.find(c => !counters.find(x => x.lbl === c.lbl));
330
- if (next) {
331
- const nc = [...counters, next];
332
- setCounters(nc);
333
- data.onUpdate({ counters: nc });
334
- fireToast('Stronger counter added: "' + next.lbl + '"', 'critic');
335
- } else {
336
  fireToast('No more counters — your plan is more robust than I thought.');
 
 
 
 
337
  }
338
  setPushing(false);
339
  }, 800);
@@ -345,28 +578,44 @@ export function DevilsModal({ data, onClose }) {
345
  <div className="modal-icon critic"><Icon name="scale" size={18}/></div>
346
  <div style={{ flex: 1 }}>
347
  <div className="modal-title">Devil's Advocate</div>
348
- <div className="modal-sub">The strongest counter-arguments to your plan, ranked by how much they should worry you.</div>
349
  </div>
350
  <button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
351
  </div>
352
  <div className="modal-body">
353
- <div style={{ background: 'var(--canvas-bg-2)', border: '1px solid var(--canvas-border-2)', borderRadius: 9, padding: '12px 14px', marginBottom: 16 }}>
354
- <div style={{ fontSize: 10.5, color: 'var(--canvas-text-3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 4 }}>Your claim</div>
355
- <div style={{ fontSize: 14, color: 'var(--canvas-text)', fontWeight: 500 }}>"{data.claim}"</div>
356
- </div>
357
- <div className="devil-list">
358
- {counters.map((c, i) => (
359
- <div key={i} className="devil-item" style={{ padding: '12px 14px 12px 36px' }}>
360
- <div className="lbl">{c.lbl}</div>
361
- <div style={{ fontSize: 13, lineHeight: 1.55 }}>{c.text}</div>
362
- </div>
363
- ))}
364
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
365
  </div>
366
  <div className="modal-foot">
367
  <button className="btn btn-ghost" onClick={onClose}>Close</button>
368
- <button className="btn btn-critic" onClick={pushHarder} disabled={pushing}>
369
- {pushing ? <><div className="spinner critic"/>Thinking…</> : <><Icon name="zap" size={13}/>Push harder</>}
 
 
 
 
370
  </button>
371
  </div>
372
  </div>
@@ -374,56 +623,95 @@ export function DevilsModal({ data, onClose }) {
374
  }
375
 
376
  export function ScopeModal({ data, onClose }) {
377
- const s = data.state;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  return (
379
  <div className="canvas-modal wide" onClick={(e) => e.stopPropagation()}>
380
  <div className="modal-head">
381
  <div className="modal-icon critic"><Icon name="bullseye" size={18}/></div>
382
  <div style={{ flex: 1 }}>
383
  <div className="modal-title">Scope Realism Check</div>
384
- <div className="modal-sub">A brutal feasibility verdict on "{s.target}" given your current pace.</div>
385
  </div>
386
  <button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
387
  </div>
388
  <div className="modal-body">
389
- <div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '14px 16px', background: 'var(--canvas-bg-2)', border: '1px solid rgba(232,100,184,0.3)', borderRadius: 10, marginBottom: 18 }}>
390
- <div style={{ fontFamily: 'var(--canvas-mono)', fontSize: 36, fontWeight: 600, color: 'var(--canvas-critic)', letterSpacing: '-0.02em', lineHeight: 1 }}>{s.score.toFixed(1)}</div>
391
- <div style={{ flex: 1 }}>
392
- <div style={{ fontSize: 10.5, color: 'var(--canvas-text-3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Verdict</div>
393
- <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--canvas-text)', marginTop: 2 }}>{s.label}</div>
394
- <div style={{ fontSize: 12, color: 'var(--canvas-text-3)', marginTop: 4 }}>5.0 = neither feasible nor infeasible · &lt;3 = unrealistic · &gt;7 = comfortable</div>
395
- </div>
 
 
 
 
 
396
  </div>
397
 
398
- <div style={{ marginBottom: 16 }}>
399
- <div className="label" style={{ marginBottom: 8 }}>Factor breakdown</div>
400
- {s.factors.map(f => (
401
- <div key={f.label} className="realism-row" style={{ padding: '7px 0' }}>
402
- <span className="label-cell" style={{ width: 140, fontSize: 12.5 }}>{f.label}</span>
403
- <span className="gauge" style={{ height: 6 }}><i style={{ width: f.val + '%' }}/></span>
404
- <span className="val" style={{ width: 36 }}>{f.val}</span>
 
 
 
 
 
 
405
  </div>
406
- ))}
407
- </div>
408
 
409
- <div className="review">
410
- <span className="review-tag">The actual problem</span>
411
- {s.notes}
412
- </div>
 
 
 
 
 
 
413
 
414
- <div style={{ marginTop: 16 }}>
415
- <div className="label" style={{ marginBottom: 8 }}>Recommended actions</div>
416
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, color: 'var(--canvas-text-2)', lineHeight: 1.6 }}>
417
- <li><strong style={{ color: 'var(--canvas-text)' }}>Commit to one primary goal for this block.</strong> Trying to bulk and cut at once is your bottleneck, not effort.</li>
418
- <li>Build a consistency buffer. Your 9-day streak is great; 90 days is the minimum to absorb the inevitable work/family/illness setbacks.</li>
419
- <li>Cut a training day. A 3-day plan you actually run beats a 5-day one you skip.</li>
420
- <li>Calibrate against reality: a natural lifter gains ~0.25–0.5% bodyweight per week when bulking. Your projected rate is faster than that.</li>
421
- </ul>
422
- </div>
 
 
 
 
 
 
423
  </div>
424
  <div className="modal-foot">
425
  <button className="btn btn-ghost" onClick={onClose}>Close</button>
426
- <button className="btn btn-critic"><Icon name="zap" size={13}/>Re-run with new estimates</button>
 
 
427
  </div>
428
  </div>
429
  );
 
4
  const fireToast = (msg, kind = 'success') =>
5
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
6
 
7
+ export const hasUserText = (value) => String(value || '').trim().length > 0;
8
+
9
+ // Keep typing in the widget/modal from being stolen by drag, drop overlays, or parent capture.
10
+ export const stopFieldPointer = (e) => {
11
+ e.stopPropagation();
12
+ };
13
+
14
+ const hashText = (text) => {
15
+ const s = String(text || '');
16
+ let h = 0;
17
+ for (let i = 0; i < s.length; i += 1) h = (h + s.charCodeAt(i) * (i + 1)) % 997;
18
+ return h;
19
+ };
20
+
21
  // Critic widgets are scripted in canvas; the *real* critique should happen in
22
  // the main chat so message history lives in one place (per Daniel's review).
23
  // "Open in chat" stashes a draft prompt + persona hint then asks CanvasPage to
 
32
  fireToast(`Opening ${persona} in chat — full history will be there.`);
33
  };
34
 
35
+ function CriticField({ id, label, value, onChange, placeholder, minHeight = 88 }) {
36
+ return (
37
+ <div className="critic-form">
38
+ <label className="label" htmlFor={id}>{label}</label>
39
+ <textarea
40
+ id={id}
41
+ className="textarea critic-input"
42
+ value={value}
43
+ onChange={(e) => onChange(e.target.value)}
44
+ onPointerDown={stopFieldPointer}
45
+ onMouseDown={stopFieldPointer}
46
+ onTouchStart={stopFieldPointer}
47
+ placeholder={placeholder}
48
+ style={{ minHeight }}
49
+ />
50
+ </div>
51
+ );
52
+ }
53
+
54
+ const REVIEW_TEMPLATES = [
55
+ {
56
+ severity: 8,
57
+ major: 'The eating plan states a goal ("eat healthier") before defining what success looks like. You never specify the measurable target — which meals, how many produce servings/week, by when — so there is no way to tell if the plan is working or when to change it.',
58
+ minor: [
59
+ 'Weekly volume is described as "more vegetables" without a concrete count or quality bar.',
60
+ 'No rule for when to pause new recipes and reuse a shortlist instead.',
61
+ 'Snack strategy is mentioned once with no prep cadence or tracking.',
62
+ 'No Friday review is scheduled — leftover waste will stay invisible.',
63
+ ],
64
+ suggestions: [
65
+ 'Add one sentence naming the target: e.g. "5 produce-forward dinners/week through April 15."',
66
+ 'Define what "quality meal" means (real-food ingredients + one produce item).',
67
+ 'Add a weekly kitchen review with homemade-meal count.',
68
+ ],
69
+ },
70
+ {
71
+ severity: 7,
72
+ major: 'You claim this approach is "optimal for eating clean." That word is doing too much work. The plan is also compatible with worse outcomes (generic takeout, skipped prep, burnout). Without a way to check whether homemade meals improve, "optimal" is untestable.',
73
+ minor: [
74
+ 'The shopping mix is justified by "everyone buys salad kits" rather than by your kitchen time and season.',
75
+ 'No statement of what would make you change the plan.',
76
+ 'A meal plan with no tracked numbers is a red flag — you cannot audit it later.',
77
+ ],
78
+ suggestions: [
79
+ 'Replace "optimal" with a specific target the plan either hits or misses.',
80
+ 'List 1–2 signals (0 homemade lunches after 5 days, missed prep blocks) that trigger a change.',
81
+ ],
82
+ },
83
+ {
84
+ severity: 9,
85
+ major: 'This reads like a wish list, not a plan. There is no number. There is no timeframe. The strongest claim is that you want to "find something good" — which is the lowest possible bar. If you actually care about the result, lead with the measurable goal, not the vibe.',
86
+ minor: [
87
+ 'The word "meal prep" appears without a single shopping list drafted.',
88
+ '"Dream recipe" is undefined; name 10 real meals.',
89
+ 'No mention of pantry staples, despite them driving most weeknight outcomes.',
90
+ ],
91
+ suggestions: [
92
+ 'Lead sentence: "By <date> I will <specific, measurable result>."',
93
+ 'Cut "I want to eat healthier" entirely. Replace it with meal lane, volume, and a date.',
94
+ ],
95
+ },
96
+ ];
97
+
98
+ export function critiqueDraft(draft) {
99
+ const text = String(draft || '').trim();
100
+ if (!text) return null;
101
+ const idx = (text.length + text.split(/\s+/).filter(Boolean).length) % REVIEW_TEMPLATES.length;
102
+ const r = REVIEW_TEMPLATES[idx];
103
+ return {
104
+ draft: text,
105
+ severity: r.severity,
106
+ major: r.major,
107
+ minor: r.minor,
108
+ suggestions: r.suggestions,
109
+ minorCount: r.minor.length,
110
+ suggestionCount: r.suggestions.length,
111
+ };
112
+ }
113
+
114
+ const DEVIL_POOL = [
115
+ { lbl: 'Reverse causation', text: 'You assume the plan caused the win. A quieter week, leftover produce already in the fridge, or eating out less for unrelated reasons could explain the same result. Name a check that would falsify the plan.' },
116
+ { lbl: 'Goalpost shift', text: 'When dinners slip you will be tempted to redefine success ("at least I bought kale"). Pick the primary metric now — produce servings, homemade dinners, or snack swaps — or you cannot honestly say it worked.' },
117
+ { lbl: 'Wrong lever', text: 'You are tweaking recipes when the bottleneck is shopping cadence and a visible produce default. Optimizing flavor while the fridge is empty will not move the number.' },
118
+ { lbl: 'Copied context', text: 'This cadence was built for someone with a different kitchen, schedule, or household. On your weeknights it may be testing a plan that does not apply to you.' },
119
+ { lbl: 'Hidden willpower tax', text: 'The plan only works if you are unusually motivated. If it requires a perfect Sunday prep, it will fail the first busy Thursday. Write the version that survives a late meeting.' },
120
+ { lbl: 'No kill criteria', text: 'There is no rule for when to stop adding new recipes and reuse a shortlist. Without a stop rule, "trying foods" becomes infinite scope and leftover waste.' },
121
+ ];
122
+
123
+ export function countersForClaim(claim, already = []) {
124
+ const text = String(claim || '').trim();
125
+ if (!text) return [];
126
+ const used = new Set((already || []).map((c) => c.lbl));
127
+ const start = hashText(text) % DEVIL_POOL.length;
128
+ const ordered = DEVIL_POOL.slice(start).concat(DEVIL_POOL.slice(0, start));
129
+ const next = [...(already || [])];
130
+ const want = next.length === 0 ? 3 : next.length + 1;
131
+ for (const c of ordered) {
132
+ if (next.length >= want) break;
133
+ if (used.has(c.lbl)) continue;
134
+ next.push({ lbl: c.lbl, text: c.text });
135
+ used.add(c.lbl);
136
+ }
137
+ return next;
138
+ }
139
+
140
+ export function verdictForTarget(target) {
141
+ const text = String(target || '').trim();
142
+ if (!text) return null;
143
+ const words = text.split(/\s+/).filter(Boolean);
144
+ const hasNumber = /\d/.test(text);
145
+ const hasTime = /(day|days|week|weeks|month|months|by |until|deadline|friday|monday)/i.test(text);
146
+ const vague = /(healthier|better|more|optimal|eat clean|wellness|try to|something good)/i.test(text);
147
+ const specificFood = /(veg|fruit|produce|dinner|lunch|snack|meal|protein|prep|homemade)/i.test(text);
148
+
149
+ const specificity = Math.min(95, (hasNumber ? 36 : 12) + Math.min(36, words.length * 4) + (specificFood ? 18 : 0));
150
+ const timebox = hasTime ? 78 : 28;
151
+ const measurable = hasNumber ? 82 : 24;
152
+ const kitchenLoad = vague ? 34 : 64;
153
+ const buffer = hasTime && hasNumber ? 70 : 38;
154
+ const factors = [
155
+ { label: 'Specificity', val: specificity },
156
+ { label: 'Timebox', val: timebox },
157
+ { label: 'Measurable', val: measurable },
158
+ { label: 'Kitchen load', val: kitchenLoad },
159
+ { label: 'Recovery buffer', val: buffer },
160
+ ];
161
+ const avg = factors.reduce((sum, f) => sum + f.val, 0) / factors.length;
162
+ const score = Math.round((avg / 10) * 10) / 10;
163
+ let label = 'Unrealistic without a rewrite';
164
+ if (score >= 7) label = 'Comfortable if you protect the cadence';
165
+ else if (score >= 5) label = 'Possible, but the plan is still mushy';
166
+ else if (score >= 3) label = 'Stretched — cut scope or add a date';
167
+ const notes = !hasNumber
168
+ ? `“${text}” has no number. A feasibility check cannot tell a vibe from a target. Name servings, meals/week, or a date.`
169
+ : !hasTime
170
+ ? `“${text}” names a quantity but not when. Without a timebox, leftover weeks and skipped prep stay invisible.`
171
+ : `“${text}” is specific enough to pressure-test. The risk is kitchen load on busy nights, not effort on a good week.`;
172
+ const actions = [
173
+ hasNumber ? 'Keep the number; make it visible on a Friday review.' : 'Rewrite as one measurable target (count + meal lane).',
174
+ hasTime ? 'Protect a short prep block that survives a late day.' : 'Add a deadline: this week, two weeks, or a calendar date.',
175
+ 'Commit to one primary goal for this block. Extra recipes are scope creep.',
176
+ 'Keep a frozen-produce backup so dinner never depends on a perfect grocery run.',
177
+ ];
178
+ return { target: text, score, label, factors, notes, actions };
179
+ }
180
+
181
+ function runDelayed(setter, work) {
182
+ setter(true);
183
+ window.setTimeout(() => {
184
+ work();
185
+ setter(false);
186
+ }, 600);
187
+ }
188
+
189
  // ---------- Reviewer 2 widget ----------
190
  export function Reviewer2Widget({ state, setState, openModal }) {
191
+ const savedDraft = state.lastDraft || '';
192
+ const [draft, setDraft] = useState(savedDraft);
193
+ const [running, setRunning] = useState(false);
194
+ const hasReview = !!(state.lastReview && hasUserText(state.lastDraft));
195
+
196
+ const run = () => {
197
+ if (!hasUserText(draft) || running) return;
198
+ runDelayed(setRunning, () => {
199
+ const review = critiqueDraft(draft);
200
+ if (!review) return;
201
+ setState({ ...state, lastDraft: review.draft, lastReview: review });
202
+ fireToast('Critique ready · severity ' + review.severity + '/10', 'critic');
203
+ });
204
+ };
205
+
206
  return (
207
  <>
208
+ <CriticField
209
+ id="reviewer-2-draft"
210
+ label="Paste a draft"
211
+ value={draft}
212
+ onChange={setDraft}
213
+ placeholder="Paste a draft paragraph, abstract, or meal-plan section…"
214
+ minHeight={110}
215
+ />
216
+ {!hasReview && (
217
+ <div className="critic-empty-hint">
218
+ No critique yet. Paste a draft above and submit — this widget will not invent one.
219
+ </div>
220
+ )}
221
+ {hasReview && (
222
  <div className="review">
223
  <span className="review-tag">Last critique · {state.lastReview.severity}/10 severity</span>
224
  <div style={{ marginBottom: 6 }}><strong>Major:</strong> {state.lastReview.major}</div>
225
  <div style={{ color: 'var(--canvas-text-2)', fontSize: 12 }}>+{state.lastReview.minorCount} minor issues, {state.lastReview.suggestionCount} suggestions</div>
226
  </div>
 
 
 
 
227
  )}
228
  <div className="critic-meter">
229
  <span>tone</span>
230
  <div className="bar"><i style={{ width: '92%' }}/></div>
231
  <span style={{ color: 'var(--canvas-critic)' }}>harsh</span>
232
  </div>
233
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignSelf: 'flex-start' }}>
234
  <button
235
  className="btn btn-critic"
236
+ onClick={run}
237
+ disabled={!hasUserText(draft) || running}
 
 
238
  >
239
+ <Icon name="gavel" size={13}/>{running ? 'Reviewing…' : 'Critique my draft'}
240
  </button>
241
+ {hasReview && (
242
+ <button
243
+ className="btn"
244
+ onClick={() => openModal('reviewer-2', {
245
+ initial: draft || state.lastDraft,
246
+ onComplete: (review) => setState({ ...state, lastDraft: review.draft, lastReview: review }),
247
+ })}
248
+ >
249
+ <Icon name="gavel" size={13}/>Read full critique
250
+ </button>
251
+ )}
252
  <button
253
  className="btn"
254
  title="Open Reviewer 2 in the main chat (history lives there)"
255
+ onClick={() => handoffToChat('Reviewer 2', hasUserText(draft)
256
+ ? `Critique this draft as Reviewer 2: "${draft.trim()}"`
257
+ : 'I want Reviewer 2. I will paste a draft in chat.')}
258
  >
259
  <Icon name="message" size={13}/>Open in chat
260
  </button>
 
265
 
266
  // ---------- Devil's Advocate widget ----------
267
  export function DevilsAdvocateWidget({ state, setState, openModal }) {
268
+ const [claim, setClaim] = useState(state.claim || '');
269
+ const [pushing, setPushing] = useState(false);
270
+ const hasClaim = hasUserText(state.claim);
271
+ const counters = hasClaim ? (state.counters || []) : [];
272
+
273
+ const run = () => {
274
+ if (!hasUserText(claim) || pushing) return;
275
+ runDelayed(setPushing, () => {
276
+ const next = countersForClaim(claim, []);
277
+ setState({ ...state, claim: claim.trim(), counters: next });
278
+ fireToast('Counters ready · ' + next.length, 'critic');
279
+ });
280
+ };
281
+
282
  return (
283
  <>
284
+ <CriticField
285
+ id="devils-advocate-claim"
286
+ label="Enter a claim"
287
+ value={claim}
288
+ onChange={setClaim}
289
+ placeholder="Enter a claim — e.g. I'll cook 5 produce-forward dinners this week."
290
+ />
291
+ {!hasClaim && (
292
+ <div className="critic-empty-hint">
293
+ No counters yet. Enter a claim above and submit — nothing is generated until you do.
294
+ </div>
295
+ )}
296
+ {hasClaim && (
297
+ <>
298
+ <div style={{ fontSize: 11, color: 'var(--canvas-critic)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
299
+ Strongest counters · {counters.length}
300
  </div>
301
+ <div className="devil-list">
302
+ {counters.slice(0, 3).map((c, i) => (
303
+ <div key={i} className="devil-item">
304
+ <div className="lbl">{c.lbl}</div>
305
+ <div>{c.text}</div>
306
+ </div>
307
+ ))}
308
+ </div>
309
+ </>
310
+ )}
311
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignSelf: 'flex-start' }}>
312
  <button
313
  className="btn btn-critic"
314
+ onClick={run}
315
+ disabled={!hasUserText(claim) || pushing}
 
 
 
316
  >
317
+ <Icon name="scale" size={13}/>{pushing ? 'Thinking…' : 'Challenge this claim'}
318
  </button>
319
+ {hasClaim && (
320
+ <button
321
+ className="btn"
322
+ onClick={() => openModal('devils-advocate', {
323
+ claim: state.claim,
324
+ counters: state.counters,
325
+ onUpdate: (next) => setState({ ...state, ...next }),
326
+ })}
327
+ >
328
+ <Icon name="scale" size={13}/>Push harder
329
+ </button>
330
+ )}
331
  <button
332
  className="btn"
333
  title="Open Devil's Advocate in the main chat (history lives there)"
334
  onClick={() => handoffToChat("Devil's Advocate",
335
+ hasUserText(claim)
336
+ ? `Take the position of devil's advocate on my claim: "${claim.trim()}". Be ruthless.`
337
+ : "I want Devil's Advocate. I will enter a claim in chat.")}
338
  >
339
  <Icon name="message" size={13}/>Open in chat
340
  </button>
 
344
  }
345
 
346
  // ---------- Scope realism widget ----------
347
+ export function ScopeRealismWidget({ state, setState, openModal }) {
348
+ const [target, setTarget] = useState(state.target || '');
349
+ const [running, setRunning] = useState(false);
350
+ const hasTarget = hasUserText(state.target);
351
+
352
+ const run = () => {
353
+ if (!hasUserText(target) || running) return;
354
+ runDelayed(setRunning, () => {
355
+ const next = verdictForTarget(target);
356
+ if (!next) return;
357
+ setState({ ...state, ...next });
358
+ fireToast('Feasibility check ready · ' + next.score.toFixed(1), 'critic');
359
+ });
360
+ };
361
+
362
  return (
363
  <div className="realism">
364
+ <CriticField
365
+ id="scope-realism-target"
366
+ label="Set a target"
367
+ value={target}
368
+ onChange={setTarget}
369
+ placeholder="Set a target — e.g. 5 produce-forward dinners/week through April 15."
370
+ minHeight={72}
371
+ />
372
+ {!hasTarget && (
373
+ <div className="critic-empty-hint">
374
+ No verdict yet. Set a target above and submit — the score stays blank until then.
375
  </div>
376
+ )}
377
+ {hasTarget && (
378
+ <>
379
+ <div className="realism-verdict">
380
+ <div className="verdict-head">
381
+ <div className="verdict-score">{Number(state.score || 0).toFixed(1)}</div>
382
+ <div>
383
+ <div className="verdict-label">Feasibility</div>
384
+ <div style={{ fontSize: 12, color: 'var(--canvas-text-2)', marginTop: 2 }}>{state.label}</div>
385
+ </div>
386
+ </div>
387
+ <div style={{ fontSize: 11, color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)' }}>
388
+ target: {state.target}
389
+ </div>
390
  </div>
391
+ <div>
392
+ {(state.factors || []).map((f) => (
393
+ <div key={f.label} className="realism-row">
394
+ <span className="label-cell">{f.label}</span>
395
+ <span className="gauge"><i style={{ width: f.val + '%' }}/></span>
396
+ <span className="val">{f.val}</span>
397
+ </div>
398
+ ))}
399
+ </div>
400
+ </>
401
+ )}
402
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignSelf: 'flex-start' }}>
403
  <button
404
  className="btn btn-critic"
405
+ onClick={run}
406
+ disabled={!hasUserText(target) || running}
407
  >
408
+ <Icon name="bullseye" size={13}/>{running ? 'Checking…' : 'Check feasibility'}
409
  </button>
410
+ {hasTarget && (
411
+ <button
412
+ className="btn"
413
+ onClick={() => openModal('scope-realism', {
414
+ state,
415
+ onUpdate: (next) => setState({ ...state, ...next }),
416
+ })}
417
+ >
418
+ <Icon name="bullseye" size={13}/>Read full verdict
419
+ </button>
420
+ )}
421
  <button
422
  className="btn"
423
  title="Open Scope Realism in the main chat (history lives there)"
424
  onClick={() => handoffToChat('Scope Realism',
425
+ hasUserText(target)
426
+ ? `Run a brutal feasibility check on my goal: "${target.trim()}". Be specific about what's at risk.`
427
+ : 'I want a Scope Realism check. I will set a target in chat.')}
428
  >
429
  <Icon name="message" size={13}/>Open in chat
430
  </button>
 
437
  // Critic modals
438
  // ===================================================================
439
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  export function ReviewerModal({ data, onClose }) {
441
  const [draft, setDraft] = useState(data.initial || '');
442
  const [running, setRunning] = useState(false);
443
  const [review, setReview] = useState(null);
444
 
445
  const run = () => {
446
+ if (!hasUserText(draft)) return;
447
  setRunning(true);
448
  setReview(null);
449
  setTimeout(() => {
450
+ setReview(critiqueDraft(draft));
 
 
451
  setRunning(false);
452
  }, 900);
453
  };
 
476
  <button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
477
  </div>
478
  <div className="modal-body">
479
+ <div className="form-row critic-form">
480
+ <label className="label" htmlFor="reviewer-modal-draft">Paste a draft</label>
481
  <textarea
482
+ id="reviewer-modal-draft"
483
+ className="textarea critic-input"
484
  style={{ minHeight: 120, fontFamily: 'var(--canvas-mono)', fontSize: 12.5 }}
485
  value={draft}
486
+ onChange={(e) => setDraft(e.target.value)}
487
+ onPointerDown={stopFieldPointer}
488
+ onMouseDown={stopFieldPointer}
489
  placeholder="Paste an abstract, a claim, a paragraph…"
490
  />
491
  <div style={{ fontSize: 11, color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)', textAlign: 'right' }}>{draft.length} chars · {draft.trim().split(/\s+/).filter(Boolean).length} words</div>
 
542
  <button className="btn btn-critic" onClick={accept}><Icon name="check" size={13}/>Save to widget</button>
543
  </>
544
  ) : (
545
+ <button className="btn btn-critic" onClick={run} disabled={!hasUserText(draft) || running}>
546
  <Icon name="gavel" size={13}/>{running ? 'Reviewing…' : 'Critique my draft'}
547
  </button>
548
  )}
 
551
  );
552
  }
553
 
 
 
 
 
 
 
 
554
  export function DevilsModal({ data, onClose }) {
555
+ const [claim, setClaim] = useState(data.claim || '');
556
+ const [counters, setCounters] = useState(hasUserText(data.claim) ? (data.counters || []) : []);
557
  const [pushing, setPushing] = useState(false);
558
 
559
+ const run = (harder) => {
560
+ if (!hasUserText(claim)) return;
561
  setPushing(true);
562
  setTimeout(() => {
563
+ const next = countersForClaim(claim, harder ? counters : []);
564
+ if (harder && next.length === counters.length) {
 
 
 
 
 
565
  fireToast('No more counters — your plan is more robust than I thought.');
566
+ } else {
567
+ setCounters(next);
568
+ data.onUpdate?.({ claim: claim.trim(), counters: next });
569
+ fireToast(harder ? 'Stronger counter added' : 'Counters ready · ' + next.length, 'critic');
570
  }
571
  setPushing(false);
572
  }, 800);
 
578
  <div className="modal-icon critic"><Icon name="scale" size={18}/></div>
579
  <div style={{ flex: 1 }}>
580
  <div className="modal-title">Devil's Advocate</div>
581
+ <div className="modal-sub">Enter a claim, then get the strongest counter-arguments ranked by how much they should worry you.</div>
582
  </div>
583
  <button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
584
  </div>
585
  <div className="modal-body">
586
+ <div className="form-row critic-form" style={{ marginBottom: 16 }}>
587
+ <label className="label" htmlFor="devils-modal-claim">Enter a claim</label>
588
+ <textarea
589
+ id="devils-modal-claim"
590
+ className="textarea critic-input"
591
+ value={claim}
592
+ onChange={(e) => setClaim(e.target.value)}
593
+ onPointerDown={stopFieldPointer}
594
+ onMouseDown={stopFieldPointer}
595
+ placeholder="Enter a claim — e.g. I'll cook 5 produce-forward dinners this week."
596
+ />
597
  </div>
598
+ {counters.length === 0 ? (
599
+ <div className="critic-empty-hint">No counters yet. Enter a claim and submit.</div>
600
+ ) : (
601
+ <div className="devil-list">
602
+ {counters.map((c, i) => (
603
+ <div key={i} className="devil-item" style={{ padding: '12px 14px 12px 36px' }}>
604
+ <div className="lbl">{c.lbl}</div>
605
+ <div style={{ fontSize: 13, lineHeight: 1.55 }}>{c.text}</div>
606
+ </div>
607
+ ))}
608
+ </div>
609
+ )}
610
  </div>
611
  <div className="modal-foot">
612
  <button className="btn btn-ghost" onClick={onClose}>Close</button>
613
+ <button
614
+ className="btn btn-critic"
615
+ onClick={() => run(counters.length > 0)}
616
+ disabled={!hasUserText(claim) || pushing}
617
+ >
618
+ {pushing ? <><div className="spinner critic"/>Thinking…</> : <><Icon name="zap" size={13}/>{counters.length ? 'Push harder' : 'Challenge this claim'}</>}
619
  </button>
620
  </div>
621
  </div>
 
623
  }
624
 
625
  export function ScopeModal({ data, onClose }) {
626
+ const initial = data.state || {};
627
+ const [target, setTarget] = useState(initial.target || '');
628
+ const [verdict, setVerdict] = useState(hasUserText(initial.target) ? initial : null);
629
+ const [running, setRunning] = useState(false);
630
+
631
+ const run = () => {
632
+ if (!hasUserText(target)) return;
633
+ setRunning(true);
634
+ setTimeout(() => {
635
+ const next = verdictForTarget(target);
636
+ setVerdict(next);
637
+ data.onUpdate?.(next);
638
+ setRunning(false);
639
+ fireToast('Feasibility check ready · ' + next.score.toFixed(1), 'critic');
640
+ }, 700);
641
+ };
642
+
643
  return (
644
  <div className="canvas-modal wide" onClick={(e) => e.stopPropagation()}>
645
  <div className="modal-head">
646
  <div className="modal-icon critic"><Icon name="bullseye" size={18}/></div>
647
  <div style={{ flex: 1 }}>
648
  <div className="modal-title">Scope Realism Check</div>
649
+ <div className="modal-sub">Set a target, then get a brutal feasibility verdict given your current pace.</div>
650
  </div>
651
  <button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
652
  </div>
653
  <div className="modal-body">
654
+ <div className="form-row critic-form" style={{ marginBottom: 16 }}>
655
+ <label className="label" htmlFor="scope-modal-target">Set a target</label>
656
+ <textarea
657
+ id="scope-modal-target"
658
+ className="textarea critic-input"
659
+ value={target}
660
+ onChange={(e) => setTarget(e.target.value)}
661
+ onPointerDown={stopFieldPointer}
662
+ onMouseDown={stopFieldPointer}
663
+ placeholder="Set a target — e.g. 5 produce-forward dinners/week through April 15."
664
+ style={{ minHeight: 72 }}
665
+ />
666
  </div>
667
 
668
+ {!verdict && (
669
+ <div className="critic-empty-hint">No verdict yet. Set a target and submit.</div>
670
+ )}
671
+
672
+ {verdict && (
673
+ <>
674
+ <div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '14px 16px', background: 'var(--canvas-bg-2)', border: '1px solid rgba(232,100,184,0.3)', borderRadius: 10, marginBottom: 18 }}>
675
+ <div style={{ fontFamily: 'var(--canvas-mono)', fontSize: 36, fontWeight: 600, color: 'var(--canvas-critic)', letterSpacing: '-0.02em', lineHeight: 1 }}>{Number(verdict.score).toFixed(1)}</div>
676
+ <div style={{ flex: 1 }}>
677
+ <div style={{ fontSize: 10.5, color: 'var(--canvas-text-3)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>Verdict</div>
678
+ <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--canvas-text)', marginTop: 2 }}>{verdict.label}</div>
679
+ <div style={{ fontSize: 12, color: 'var(--canvas-text-3)', marginTop: 4 }}>5.0 = neither feasible nor infeasible · &lt;3 = unrealistic · &gt;7 = comfortable</div>
680
+ </div>
681
  </div>
 
 
682
 
683
+ <div style={{ marginBottom: 16 }}>
684
+ <div className="label" style={{ marginBottom: 8 }}>Factor breakdown</div>
685
+ {(verdict.factors || []).map((f) => (
686
+ <div key={f.label} className="realism-row" style={{ padding: '7px 0' }}>
687
+ <span className="label-cell" style={{ width: 140, fontSize: 12.5 }}>{f.label}</span>
688
+ <span className="gauge" style={{ height: 6 }}><i style={{ width: f.val + '%' }}/></span>
689
+ <span className="val" style={{ width: 36 }}>{f.val}</span>
690
+ </div>
691
+ ))}
692
+ </div>
693
 
694
+ <div className="review">
695
+ <span className="review-tag">The actual problem</span>
696
+ {verdict.notes}
697
+ </div>
698
+
699
+ <div style={{ marginTop: 16 }}>
700
+ <div className="label" style={{ marginBottom: 8 }}>Recommended actions</div>
701
+ <ul style={{ margin: 0, paddingLeft: 18, fontSize: 13, color: 'var(--canvas-text-2)', lineHeight: 1.6 }}>
702
+ {(verdict.actions || []).map((a) => (
703
+ <li key={a}>{a}</li>
704
+ ))}
705
+ </ul>
706
+ </div>
707
+ </>
708
+ )}
709
  </div>
710
  <div className="modal-foot">
711
  <button className="btn btn-ghost" onClick={onClose}>Close</button>
712
+ <button className="btn btn-critic" onClick={run} disabled={!hasUserText(target) || running}>
713
+ {running ? <><div className="spinner critic"/>Checking…</> : <><Icon name="zap" size={13}/>{verdict ? 'Re-run with new estimates' : 'Check feasibility'}</>}
714
+ </button>
715
  </div>
716
  </div>
717
  );
phd-advisor-frontend/src/components/canvas/CanvasCriticWidgets.test.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, screen, fireEvent, act } from '@testing-library/react';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import {
5
+ Reviewer2Widget,
6
+ DevilsAdvocateWidget,
7
+ ScopeRealismWidget,
8
+ ReviewerModal,
9
+ DevilsModal,
10
+ ScopeModal,
11
+ critiqueDraft,
12
+ countersForClaim,
13
+ verdictForTarget,
14
+ hasUserText,
15
+ } from './CanvasCriticWidgets';
16
+ import { EMPTY_STATE } from './canvasData';
17
+
18
+ const noop = () => {};
19
+
20
+ beforeEach(() => {
21
+ jest.useFakeTimers();
22
+ });
23
+
24
+ afterEach(() => {
25
+ act(() => {
26
+ jest.runOnlyPendingTimers();
27
+ });
28
+ jest.useRealTimers();
29
+ });
30
+
31
+ test('generators refuse empty input', () => {
32
+ expect(hasUserText('')).toBe(false);
33
+ expect(critiqueDraft('')).toBeNull();
34
+ expect(countersForClaim(' ')).toEqual([]);
35
+ expect(verdictForTarget('')).toBeNull();
36
+ });
37
+
38
+ test('generators return a critique only after real text', () => {
39
+ expect(critiqueDraft('I will cook 5 produce dinners this week.')).toMatchObject({
40
+ severity: expect.any(Number),
41
+ major: expect.any(String),
42
+ });
43
+ expect(countersForClaim('Salad kits count as cooking.')).toHaveLength(3);
44
+ const verdict = verdictForTarget('5 produce-forward dinners/week through April 15');
45
+ expect(verdict.score).toBeGreaterThan(0);
46
+ expect(verdict.target).toMatch(/produce-forward/);
47
+ });
48
+
49
+ test('Reviewer 2 empty state has a paste box and no canned critique', () => {
50
+ render(
51
+ <Reviewer2Widget state={EMPTY_STATE['reviewer-2']} setState={noop} openModal={noop} />
52
+ );
53
+ const field = screen.getByLabelText(/paste a draft/i);
54
+ expect(field.tagName).toBe('TEXTAREA');
55
+ expect(field).not.toHaveAttribute('readonly');
56
+ expect(field).not.toBeDisabled();
57
+ expect(screen.queryByText(/Major:/)).toBeNull();
58
+ expect(screen.getByText(/No critique yet/i)).toBeInTheDocument();
59
+ expect(screen.getByRole('button', { name: /critique my draft/i })).toBeDisabled();
60
+ });
61
+
62
+ test('Reviewer 2 critiques only after a draft is submitted', () => {
63
+ const setState = jest.fn();
64
+ render(
65
+ <Reviewer2Widget state={EMPTY_STATE['reviewer-2']} setState={setState} openModal={noop} />
66
+ );
67
+ fireEvent.change(screen.getByLabelText(/paste a draft/i), {
68
+ target: { value: 'Eat healthier with more vegetables sometime.' },
69
+ });
70
+ fireEvent.click(screen.getByRole('button', { name: /critique my draft/i }));
71
+ expect(setState).not.toHaveBeenCalled();
72
+ act(() => { jest.advanceTimersByTime(700); });
73
+ expect(setState).toHaveBeenCalled();
74
+ expect(setState.mock.calls[0][0].lastReview.major).toBeTruthy();
75
+ });
76
+
77
+ test("Devil's Advocate empty state has a claim box and no counters", () => {
78
+ render(
79
+ <DevilsAdvocateWidget state={EMPTY_STATE['devils-advocate']} setState={noop} openModal={noop} />
80
+ );
81
+ const field = screen.getByLabelText(/enter a claim/i);
82
+ expect(field.tagName).toBe('TEXTAREA');
83
+ expect(field).not.toBeDisabled();
84
+ expect(screen.queryByText(/Strongest counters/i)).toBeNull();
85
+ expect(screen.queryByText(/Reverse causation/i)).toBeNull();
86
+ expect(screen.getByRole('button', { name: /challenge this claim/i })).toBeDisabled();
87
+ });
88
+
89
+ test('Scope Realism empty state has a target box and no score', () => {
90
+ render(
91
+ <ScopeRealismWidget state={EMPTY_STATE['scope-realism']} setState={noop} openModal={noop} />
92
+ );
93
+ const field = screen.getByLabelText(/set a target/i);
94
+ expect(field.tagName).toBe('TEXTAREA');
95
+ expect(field).not.toBeDisabled();
96
+ expect(screen.queryByText('0.0')).toBeNull();
97
+ expect(document.querySelector('.verdict-score')).toBeNull();
98
+ expect(document.querySelector('.realism-verdict')).toBeNull();
99
+ expect(screen.getByRole('button', { name: /check feasibility/i })).toBeDisabled();
100
+ });
101
+
102
+ test('leftover counters stay hidden until a claim exists', () => {
103
+ render(
104
+ <DevilsAdvocateWidget
105
+ state={{ claim: '', counters: [{ lbl: 'Ghost', text: 'Canned.' }] }}
106
+ setState={noop}
107
+ openModal={noop}
108
+ />
109
+ );
110
+ expect(screen.queryByText('Ghost')).toBeNull();
111
+ expect(screen.queryByText('Canned.')).toBeNull();
112
+ });
113
+
114
+ test('critic modals require input before generating', () => {
115
+ render(<ReviewerModal data={{ initial: '', onComplete: noop }} onClose={noop} />);
116
+ expect(screen.getByLabelText(/paste a draft/i)).toBeInTheDocument();
117
+ expect(screen.getByRole('button', { name: /critique my draft/i })).toBeDisabled();
118
+
119
+ render(<DevilsModal data={{ claim: '', counters: [], onUpdate: noop }} onClose={noop} />);
120
+ expect(screen.getByLabelText(/enter a claim/i)).toBeInTheDocument();
121
+ expect(screen.queryByText(/Reverse causation/i)).toBeNull();
122
+
123
+ render(<ScopeModal data={{ state: EMPTY_STATE['scope-realism'], onUpdate: noop }} onClose={noop} />);
124
+ expect(screen.getByLabelText(/set a target/i)).toBeInTheDocument();
125
+ expect(screen.queryByText(/Recommended actions/i)).toBeNull();
126
+ expect(screen.queryByText(/Trying to bulk and cut/i)).toBeNull();
127
+ });
128
+
129
+ test('critic input CSS keeps fields clickable', () => {
130
+ const cssPath = path.join(__dirname, '../../styles/CanvasPage.css');
131
+ const css = fs.readFileSync(cssPath, 'utf8');
132
+ expect(css).toMatch(/\.critic-form[\s\S]*pointer-events:\s*auto/);
133
+ expect(css).toMatch(/textarea\.critic-input[\s\S]*pointer-events:\s*auto/);
134
+ expect(css).toMatch(/\.canvas-modal-backdrop[\s\S]*z-index:\s*400/);
135
+ });
phd-advisor-frontend/src/components/canvas/canvasData.js CHANGED
@@ -290,10 +290,11 @@ export const EMPTY_STATE = {
290
  'devils-advocate': { claim: '', counters: [] },
291
  'scope-realism': {
292
  target: '',
293
- score: 0,
294
- label: 'Set a target',
295
  factors: [],
296
  notes: '',
 
297
  },
298
  outline: { items: [], expanded: {} },
299
  highlights: { items: [] },
 
290
  'devils-advocate': { claim: '', counters: [] },
291
  'scope-realism': {
292
  target: '',
293
+ score: null,
294
+ label: '',
295
  factors: [],
296
  notes: '',
297
+ actions: [],
298
  },
299
  outline: { items: [], expanded: {} },
300
  highlights: { items: [] },
phd-advisor-frontend/src/pages/CanvasPage.js CHANGED
@@ -111,7 +111,14 @@ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver,
111
  <button className="icon-btn" onClick={() => onRemove(widget.id, meta.name)} title="Remove"><Icon name="trash" size={13}/></button>
112
  </div>
113
  </div>
114
- <div className="widget-body">
 
 
 
 
 
 
 
115
  {renderWidget(widget.type, state, setState, openModal, allStates)}
116
  </div>
117
  </div>
 
111
  <button className="icon-btn" onClick={() => onRemove(widget.id, meta.name)} title="Remove"><Icon name="trash" size={13}/></button>
112
  </div>
113
  </div>
114
+ <div
115
+ className="widget-body"
116
+ onPointerDown={(e) => {
117
+ if (e.target.closest('input, textarea, select, button, a, label, .critic-form')) {
118
+ e.stopPropagation();
119
+ }
120
+ }}
121
+ >
122
  {renderWidget(widget.type, state, setState, openModal, allStates)}
123
  </div>
124
  </div>
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
@@ -1095,6 +1095,10 @@
1095
  flex-direction: column;
1096
  gap: 12px;
1097
  min-height: 0;
 
 
 
 
1098
  }
1099
 
1100
  /* Preset picker (shown above the empty workspace) */
@@ -1216,7 +1220,7 @@ body[data-canvas-theme="light"] .canvas-modal-backdrop .select {
1216
  inset: 0;
1217
  background: rgba(17, 24, 39, 0.7);
1218
  backdrop-filter: blur(6px);
1219
- z-index: 100;
1220
  display: grid;
1221
  place-items: center;
1222
  padding: 40px 20px;
@@ -1645,6 +1649,34 @@ body[data-canvas-theme="light"] .canvas-modal-backdrop {
1645
  font-style: italic;
1646
  line-height: 1.5;
1647
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1648
  .canvas-page-with-sidebar .critic-meter {
1649
  display: flex;
1650
  align-items: center;
 
1095
  flex-direction: column;
1096
  gap: 12px;
1097
  min-height: 0;
1098
+ position: relative;
1099
+ z-index: 1;
1100
+ pointer-events: auto;
1101
+ overflow: auto;
1102
  }
1103
 
1104
  /* Preset picker (shown above the empty workspace) */
 
1220
  inset: 0;
1221
  background: rgba(17, 24, 39, 0.7);
1222
  backdrop-filter: blur(6px);
1223
+ z-index: 400;
1224
  display: grid;
1225
  place-items: center;
1226
  padding: 40px 20px;
 
1649
  font-style: italic;
1650
  line-height: 1.5;
1651
  }
1652
+ .canvas-page-with-sidebar .critic-form,
1653
+ .canvas-modal-backdrop .critic-form {
1654
+ display: flex;
1655
+ flex-direction: column;
1656
+ gap: 6px;
1657
+ position: relative;
1658
+ z-index: 2;
1659
+ pointer-events: auto;
1660
+ }
1661
+ .canvas-page-with-sidebar .critic-form .critic-input,
1662
+ .canvas-modal-backdrop .critic-form .critic-input,
1663
+ .canvas-page-with-sidebar textarea.critic-input,
1664
+ .canvas-modal-backdrop textarea.critic-input {
1665
+ pointer-events: auto;
1666
+ user-select: text;
1667
+ -webkit-user-select: text;
1668
+ position: relative;
1669
+ z-index: 3;
1670
+ cursor: text;
1671
+ }
1672
+ .canvas-page-with-sidebar .critic-empty-hint,
1673
+ .canvas-modal-backdrop .critic-empty-hint {
1674
+ font-size: 11.5px;
1675
+ color: var(--canvas-text-3);
1676
+ font-style: italic;
1677
+ padding: 4px 2px;
1678
+ line-height: 1.45;
1679
+ }
1680
  .canvas-page-with-sidebar .critic-meter {
1681
  display: flex;
1682
  align-items: center;