Spaces:
Sleeping
Sleeping
Kennedy Johnson Cursor commited on
Commit ·
3dffa48
1
Parent(s): 371ef22
Require user text before critic widgets generate a critique.
Browse filesDevil'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 +464 -176
- phd-advisor-frontend/src/components/canvas/CanvasCriticWidgets.test.js +135 -0
- phd-advisor-frontend/src/components/canvas/canvasData.js +3 -2
- phd-advisor-frontend/src/pages/CanvasPage.js +8 -1
- phd-advisor-frontend/src/styles/CanvasPage.css +33 -1
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 |
-
<
|
| 26 |
-
"
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
| 48 |
-
|
| 49 |
-
onComplete: (review) => setState({ ...state, lastDraft: review.draft, lastReview: review }),
|
| 50 |
-
})}
|
| 51 |
>
|
| 52 |
-
<Icon name="gavel" size={13}/>
|
| 53 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
<button
|
| 55 |
className="btn"
|
| 56 |
title="Open Reviewer 2 in the main chat (history lives there)"
|
| 57 |
-
onClick={() => handoffToChat('Reviewer 2',
|
| 58 |
-
? `Critique this draft as Reviewer 2: "${
|
| 59 |
-
: '
|
| 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 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
<button
|
| 87 |
className="btn btn-critic"
|
| 88 |
-
onClick={
|
| 89 |
-
|
| 90 |
-
counters: state.counters,
|
| 91 |
-
onUpdate: (next) => setState({ ...state, ...next }),
|
| 92 |
-
})}
|
| 93 |
>
|
| 94 |
-
<Icon name="scale" size={13}/>
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
<
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
<
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
<button
|
| 136 |
className="btn btn-critic"
|
| 137 |
-
onClick={
|
|
|
|
| 138 |
>
|
| 139 |
-
<Icon name="bullseye" size={13}/>
|
| 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 |
-
|
|
|
|
|
|
|
| 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 (!
|
| 209 |
setRunning(true);
|
| 210 |
setReview(null);
|
| 211 |
setTimeout(() => {
|
| 212 |
-
|
| 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">
|
| 245 |
<textarea
|
| 246 |
-
|
|
|
|
| 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={!
|
| 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 [
|
|
|
|
| 324 |
const [pushing, setPushing] = useState(false);
|
| 325 |
|
| 326 |
-
const
|
|
|
|
| 327 |
setPushing(true);
|
| 328 |
setTimeout(() => {
|
| 329 |
-
const next =
|
| 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">
|
| 349 |
</div>
|
| 350 |
<button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
|
| 351 |
</div>
|
| 352 |
<div className="modal-body">
|
| 353 |
-
<div
|
| 354 |
-
<
|
| 355 |
-
<
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
</div>
|
| 366 |
<div className="modal-foot">
|
| 367 |
<button className="btn btn-ghost" onClick={onClose}>Close</button>
|
| 368 |
-
<button
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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={{
|
| 390 |
-
<
|
| 391 |
-
<
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
</div>
|
| 397 |
|
| 398 |
-
|
| 399 |
-
<div className="
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
</div>
|
| 406 |
-
))}
|
| 407 |
-
</div>
|
| 408 |
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
<
|
| 418 |
-
|
| 419 |
-
<
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
</div>
|
| 424 |
<div className="modal-foot">
|
| 425 |
<button className="btn btn-ghost" onClick={onClose}>Close</button>
|
| 426 |
-
<button className="btn btn-critic"
|
|
|
|
|
|
|
| 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 · <3 = unrealistic · >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:
|
| 294 |
-
label: '
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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;
|