Neon:ryan commited on
Commit
2dc7022
·
1 Parent(s): abcb14f

Refactor Canvas components and enhance user experience

Browse files

- Updated AppHeader to rename "Deliverables" tab to "Documents" for clarity.
- Enhanced Sidebar functionality by adding support for insights sections and improved search capabilities.
- Introduced new PhD Journey and PhD Resources widgets to assist users in managing their academic progress and resources.
- Improved CanvasDeliverables with additional document templates and updated UI elements for better usability.
- Enhanced CSS styles for consistency and improved visual appeal across the Canvas interface.

These changes collectively enhance the usability and functionality of the Canvas application, making it more intuitive for users to navigate and manage their academic tasks.

phd-advisor-frontend/src/components/AppHeader.js CHANGED
@@ -71,7 +71,7 @@ const AppHeader = ({
71
  <button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
72
  <button className={`tab ${tabActive('insights') ? 'active' : ''}`} onClick={() => goToCanvas('insights')}>Insights</button>
73
  <button className={`tab ${tabActive('workspace') ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
74
- <button className={`tab ${tabActive('deliverables') ? 'active' : ''}`} onClick={() => goToCanvas('deliverables')}>Deliverables</button>
75
  </div>
76
  )}
77
 
@@ -89,7 +89,7 @@ const AppHeader = ({
89
  <option value="chat">Chat</option>
90
  <option value="insights">Insights</option>
91
  <option value="workspace">Workspace</option>
92
- <option value="deliverables">Deliverables</option>
93
  </select>
94
  )}
95
 
 
71
  <button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
72
  <button className={`tab ${tabActive('insights') ? 'active' : ''}`} onClick={() => goToCanvas('insights')}>Insights</button>
73
  <button className={`tab ${tabActive('workspace') ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
74
+ <button className={`tab ${tabActive('deliverables') ? 'active' : ''}`} onClick={() => goToCanvas('deliverables')}>Documents</button>
75
  </div>
76
  )}
77
 
 
89
  <option value="chat">Chat</option>
90
  <option value="insights">Insights</option>
91
  <option value="workspace">Workspace</option>
92
+ <option value="deliverables">Documents</option>
93
  </select>
94
  )}
95
 
phd-advisor-frontend/src/components/Sidebar.js CHANGED
@@ -1,12 +1,10 @@
1
  import React, { useState, useEffect } from 'react';
2
  import {
3
  MessageSquare,
4
- Plus,
5
  SquarePen,
6
  Search,
7
  MoreVertical,
8
  Trash2,
9
- Edit3,
10
  LogOut,
11
  User,
12
  Settings,
@@ -28,7 +26,6 @@ const Sidebar = ({
28
  onSidebarToggle,
29
  isMobileOpen = false,
30
  onMobileToggle,
31
- onNavigateToCanvas,
32
  refreshTrigger,
33
  onCurrentSessionDeleted,
34
  pageContext = 'chat',
@@ -36,6 +33,7 @@ const Sidebar = ({
36
  canvasSubview = 'workspace',
37
  widgetGroups = [],
38
  deliverableProjects = [],
 
39
  }) => {
40
  const isOnCanvas = pageContext === 'canvas';
41
  const [expanded, setExpanded] = useState(() => {
@@ -277,7 +275,9 @@ const Sidebar = ({
277
  type="text"
278
  placeholder={
279
  isOnCanvas
280
- ? (canvasSubview === 'deliverables' ? 'Search drafts...' : 'Search widgets...')
 
 
281
  : 'Search chats...'
282
  }
283
  value={searchTerm}
@@ -312,7 +312,7 @@ const Sidebar = ({
312
  if (projects.length === 0) {
313
  return (
314
  <div className="no-sessions">
315
- {searchTerm ? 'No drafts match' : 'No drafts yet — create one in Deliverables'}
316
  </div>
317
  );
318
  }
@@ -400,7 +400,37 @@ const Sidebar = ({
400
  });
401
  }
402
 
403
- // ---------- INSIGHTS / fallback: keep flat list ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  const items = canvasItems.filter(it => !q || it.label.toLowerCase().includes(q));
405
  if (items.length === 0) {
406
  return <div className="no-sessions">{searchTerm ? 'No matches' : 'Nothing here yet'}</div>;
 
1
  import React, { useState, useEffect } from 'react';
2
  import {
3
  MessageSquare,
 
4
  SquarePen,
5
  Search,
6
  MoreVertical,
7
  Trash2,
 
8
  LogOut,
9
  User,
10
  Settings,
 
26
  onSidebarToggle,
27
  isMobileOpen = false,
28
  onMobileToggle,
 
29
  refreshTrigger,
30
  onCurrentSessionDeleted,
31
  pageContext = 'chat',
 
33
  canvasSubview = 'workspace',
34
  widgetGroups = [],
35
  deliverableProjects = [],
36
+ insightSections = [],
37
  }) => {
38
  const isOnCanvas = pageContext === 'canvas';
39
  const [expanded, setExpanded] = useState(() => {
 
275
  type="text"
276
  placeholder={
277
  isOnCanvas
278
+ ? (canvasSubview === 'deliverables' ? 'Search drafts...'
279
+ : canvasSubview === 'insights' ? 'Search sections...'
280
+ : 'Search widgets...')
281
  : 'Search chats...'
282
  }
283
  value={searchTerm}
 
312
  if (projects.length === 0) {
313
  return (
314
  <div className="no-sessions">
315
+ {searchTerm ? 'No drafts match' : 'No drafts yet — create one in Documents'}
316
  </div>
317
  );
318
  }
 
400
  });
401
  }
402
 
403
+ // ---------- INSIGHTS: section list with confidence badges ----------
404
+ if (canvasSubview === 'insights') {
405
+ const sections = insightSections.filter(s => !q || s.name.toLowerCase().includes(q));
406
+ if (sections.length === 0) {
407
+ return <div className="no-sessions">{searchTerm ? 'No sections match' : 'No insights yet'}</div>;
408
+ }
409
+ return (
410
+ <div className="csm-group">
411
+ <div className="csm-group-head" style={{ cursor: 'default' }}>
412
+ <span className="csm-group-name">Sections</span>
413
+ <span className="csm-group-count">{sections.length}</span>
414
+ </div>
415
+ <div className="csm-group-body">
416
+ {sections.map(s => {
417
+ const complete = s.taskCount > 0 && s.doneCount === s.taskCount;
418
+ return (
419
+ <button key={s.id} className={`csm-row ${complete ? 'csm-row-done' : ''}`} onClick={s.onClick}>
420
+ <span className="csm-row-bullet"/>
421
+ <span className="csm-row-label">{s.name}</span>
422
+ {s.taskCount > 0 && (
423
+ <span className="csm-row-meta">{s.doneCount}/{s.taskCount}</span>
424
+ )}
425
+ </button>
426
+ );
427
+ })}
428
+ </div>
429
+ </div>
430
+ );
431
+ }
432
+
433
+ // ---------- Fallback: flat list (legacy) ----------
434
  const items = canvasItems.filter(it => !q || it.label.toLowerCase().includes(q));
435
  if (items.length === 0) {
436
  return <div className="no-sessions">{searchTerm ? 'No matches' : 'Nothing here yet'}</div>;
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
  // ---------- Reviewer 2 widget ----------
8
  export function Reviewer2Widget({ state, setState, openModal }) {
9
  return (
@@ -27,16 +41,26 @@ export function Reviewer2Widget({ state, setState, openModal }) {
27
  <div className="bar"><i style={{ width: '92%' }}/></div>
28
  <span style={{ color: 'var(--canvas-critic)' }}>harsh</span>
29
  </div>
30
- <button
31
- className="btn btn-critic"
32
- style={{ alignSelf: 'flex-start' }}
33
- onClick={() => openModal('reviewer-2', {
34
- initial: state.lastDraft,
35
- onComplete: (review) => setState({ ...state, lastDraft: review.draft, lastReview: review }),
36
- })}
37
- >
38
- <Icon name="gavel" size={13}/>Get critique
39
- </button>
 
 
 
 
 
 
 
 
 
 
40
  </>
41
  );
42
  }
@@ -58,17 +82,26 @@ export function DevilsAdvocateWidget({ state, setState, openModal }) {
58
  </div>
59
  ))}
60
  </div>
61
- <button
62
- className="btn btn-critic"
63
- style={{ alignSelf: 'flex-start' }}
64
- onClick={() => openModal('devils-advocate', {
65
- claim: state.claim,
66
- counters: state.counters,
67
- onUpdate: (next) => setState({ ...state, ...next }),
68
- })}
69
- >
70
- <Icon name="scale" size={13}/>Push harder
71
- </button>
 
 
 
 
 
 
 
 
 
72
  </>
73
  );
74
  }
@@ -98,13 +131,22 @@ export function ScopeRealismWidget({ state, openModal }) {
98
  </div>
99
  ))}
100
  </div>
101
- <button
102
- className="btn btn-critic"
103
- style={{ alignSelf: 'flex-start' }}
104
- onClick={() => openModal('scope-realism', { state })}
105
- >
106
- <Icon name="bullseye" size={13}/>Read full verdict
107
- </button>
 
 
 
 
 
 
 
 
 
108
  </div>
109
  );
110
  }
 
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
10
+ // navigate to chat — the chat page can read `canvas-chat-handoff` from localStorage.
11
+ const handoffToChat = (persona, prompt, context = {}) => {
12
+ try {
13
+ localStorage.setItem('canvas-chat-handoff', JSON.stringify({
14
+ at: Date.now(), persona, prompt, ...context,
15
+ }));
16
+ } catch { /* ignore */ }
17
+ window.dispatchEvent(new CustomEvent('canvas-open-in-chat', { detail: { persona, prompt } }));
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 (
 
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>
63
+ </div>
64
  </>
65
  );
66
  }
 
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 hypothesis'}". Be ruthless.`)}
101
+ >
102
+ <Icon name="message" size={13}/>Open in chat
103
+ </button>
104
+ </div>
105
  </>
106
  );
107
  }
 
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 research scope'}". Be specific about what's at risk.`)}
146
+ >
147
+ <Icon name="message" size={13}/>Open in chat
148
+ </button>
149
+ </div>
150
  </div>
151
  );
152
  }
phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js CHANGED
@@ -11,6 +11,7 @@ import remarkMath from 'remark-math';
11
  import rehypeKatex from 'rehype-katex';
12
  import 'katex/dist/katex.min.css';
13
  import Icon from './CanvasIcon';
 
14
 
15
  // Markdown plugins shared across all rendered blocks. remark-math + rehype-katex
16
  // give us real LaTeX math (`$...$` inline, `$$...$$` block) inside any preview.
@@ -140,6 +141,71 @@ export const TEMPLATES = [
140
  { id: 'close', name: 'Close', target: 60, hint: 'Thanks + next step + signature.', checks: [] },
141
  ],
142
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  {
144
  id: 'research-statement',
145
  name: 'Research Statement',
@@ -397,7 +463,7 @@ const DeliverablesView = ({ allStates }) => {
397
  <>
398
  <div className="page-header">
399
  <div>
400
- <h1 className="page-title">Deliverables</h1>
401
  <div className="page-sub">
402
  Your one-stop deliverable center. Drafts auto-save. Versions kept for rollback.
403
  {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
@@ -858,8 +924,8 @@ const lineWrap = (text, prefix) =>
858
  (text ? text.split('\n').map(line => line ? `${prefix}${line}` : line).join('\n') : `${prefix}`);
859
 
860
  const TOOLBAR = [
861
- { id: 'bold', icon: 'pencil', label: 'Bold (B)', run: (sel) => wrap(sel, '**') },
862
- { id: 'italic', icon: 'pencil', label: 'Italic (I)', run: (sel) => wrap(sel, '*') },
863
  { id: 'code', icon: 'flask', label: 'Inline code', run: (sel) => wrap(sel, '`') },
864
  { id: 'h2', icon: 'list', label: 'Heading', run: (sel) => `## ${sel || 'Heading'}` },
865
  { id: 'h3', icon: 'list', label: 'Subheading', run: (sel) => `### ${sel || 'Subheading'}` },
 
11
  import rehypeKatex from 'rehype-katex';
12
  import 'katex/dist/katex.min.css';
13
  import Icon from './CanvasIcon';
14
+ import { MOD } from './platform';
15
 
16
  // Markdown plugins shared across all rendered blocks. remark-math + rehype-katex
17
  // give us real LaTeX math (`$...$` inline, `$$...$$` block) inside any preview.
 
141
  { id: 'close', name: 'Close', target: 60, hint: 'Thanks + next step + signature.', checks: [] },
142
  ],
143
  },
144
+ {
145
+ id: 'irb-protocol',
146
+ name: 'IRB Protocol',
147
+ desc: 'Standard sections for human-subjects research approval.',
148
+ icon: 'shield',
149
+ mode: 'document',
150
+ sections: [
151
+ { id: 'overview', name: 'Study overview', target: 200, hint: 'One-paragraph summary of purpose and procedures.', checks: [] },
152
+ { id: 'background', name: 'Background & significance', target: 400, hint: 'Why this study? What gap does it close?', checks: ['hasGap', 'hasCitation'] },
153
+ { id: 'aims', name: 'Specific aims & hypotheses', target: 250, hint: '2–3 aims. Each falsifiable.', checks: ['hasHypothesis'] },
154
+ { id: 'participants', name: 'Participants & recruitment', target: 300, hint: 'Inclusion/exclusion criteria, sample size, recruitment plan.', checks: ['hasNumber'] },
155
+ { id: 'procedures', name: 'Procedures', target: 500, hint: 'Step-by-step what subjects experience. Time burden in minutes.', checks: ['hasNumber'] },
156
+ { id: 'risks', name: 'Risks & mitigation', target: 200, hint: 'Anticipated risks (physical, psychological, privacy) + mitigations.', checks: ['hasLimit'] },
157
+ { id: 'benefits', name: 'Benefits', target: 100, hint: 'Direct + societal benefits. Be honest about minimal direct benefits.', checks: [] },
158
+ { id: 'consent', name: 'Consent process', target: 200, hint: 'Who consents, when, written or verbal, capacity considerations.', checks: [] },
159
+ { id: 'data', name: 'Data handling & confidentiality', target: 200, hint: 'Storage, access, identifiers, retention period.', checks: [] },
160
+ ],
161
+ },
162
+ {
163
+ id: 'meeting-prep',
164
+ name: 'Advisor Meeting Prep',
165
+ desc: 'Bring this to your 1:1 — agenda, updates, decisions needed, follow-ups.',
166
+ icon: 'message',
167
+ mode: 'document',
168
+ sections: [
169
+ { id: 'agenda', name: 'Agenda', target: 80, hint: '3–5 bullets ranked by priority.', checks: [] },
170
+ { id: 'progress', name: 'Progress since last meeting', target: 200, hint: 'What you actually did, with numbers when possible.', checks: ['hasNumber'] },
171
+ { id: 'blockers', name: 'Blockers', target: 150, hint: 'What you need from them to move forward.', checks: ['hasLimit'] },
172
+ { id: 'decisions', name: 'Decisions needed', target: 200, hint: 'Frame as A/B options with your recommendation.', checks: [] },
173
+ { id: 'questions', name: 'Questions', target: 150, hint: 'Open questions you genuinely want their take on.', checks: [] },
174
+ { id: 'followup', name: 'Action items (post-meeting)', target: 100, hint: 'Fill in during/after. Owner + due date for each.', checks: [] },
175
+ ],
176
+ },
177
+ {
178
+ id: 'dissertation-formatting',
179
+ name: 'Dissertation Formatting Checklist',
180
+ desc: 'Catch-everything pass before ProQuest submission.',
181
+ icon: 'shield',
182
+ mode: 'document',
183
+ sections: [
184
+ { id: 'frontmatter', name: 'Front matter', target: 0, hint: 'Title page, copyright, abstract, dedication, acknowledgements, ToC, list of figures/tables.', checks: [] },
185
+ { id: 'margins', name: 'Margins & spacing', target: 0, hint: 'Verify school requirements. Most: 1" margins, double-spaced body, single-spaced quotes/captions.', checks: ['hasNumber'] },
186
+ { id: 'fonts', name: 'Fonts & typography', target: 0, hint: 'One body font (Times/Garamond/Cambria) at 12pt. Captions 10–11pt. Headings consistent.', checks: ['hasNumber'] },
187
+ { id: 'pagenumbers', name: 'Page numbering', target: 0, hint: 'Roman for front matter, Arabic from Intro onward. Check section breaks.', checks: [] },
188
+ { id: 'figures', name: 'Figures & tables', target: 0, hint: 'Captions below figures, above tables. Numbered. Cited in text before they appear.', checks: ['hasFigure'] },
189
+ { id: 'citations', name: 'Citations & references', target: 0, hint: 'Consistent style throughout. Every cite has a reference; every reference is cited.', checks: ['hasCitation'] },
190
+ { id: 'appendices', name: 'Appendices', target: 0, hint: 'Lettered (A, B, C). Each cited in the body at least once.', checks: [] },
191
+ { id: 'proquest', name: 'ProQuest submission', target: 0, hint: 'PDF/A format, embedded fonts, no broken links, abstract under word limit.', checks: [] },
192
+ ],
193
+ },
194
+ {
195
+ id: 'faculty-hunt',
196
+ name: 'Faculty / Advisor Hunt',
197
+ desc: 'For prospective PhDs or finding committee members — research the people.',
198
+ icon: 'user',
199
+ mode: 'document',
200
+ sections: [
201
+ { id: 'criteria', name: 'What you\'re looking for', target: 150, hint: 'Research area, methodology, working style, mentorship reputation.', checks: [] },
202
+ { id: 'shortlist', name: 'Shortlist (5–10 names)', target: 400, hint: 'For each: name, institution, 2–3 representative papers, why they fit.', checks: ['hasCitation'] },
203
+ { id: 'pubs', name: 'Recent publications', target: 300, hint: 'What have they published in the last 2 years? Drop @keys from Bibliography.', checks: ['hasCitation'] },
204
+ { id: 'students', name: 'Current/recent students', target: 200, hint: 'Lab size, time-to-defense, where students go after.', checks: ['hasNumber'] },
205
+ { id: 'reachout', name: 'Outreach plan', target: 200, hint: 'When to email, what to send, who to mention.', checks: [] },
206
+ { id: 'notes', name: 'Conversation notes', target: 0, hint: 'After meetings/emails — vibes, fit signals, red flags.', checks: [] },
207
+ ],
208
+ },
209
  {
210
  id: 'research-statement',
211
  name: 'Research Statement',
 
463
  <>
464
  <div className="page-header">
465
  <div>
466
+ <h1 className="page-title">Documents</h1>
467
  <div className="page-sub">
468
  Your one-stop deliverable center. Drafts auto-save. Versions kept for rollback.
469
  {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
 
924
  (text ? text.split('\n').map(line => line ? `${prefix}${line}` : line).join('\n') : `${prefix}`);
925
 
926
  const TOOLBAR = [
927
+ { id: 'bold', icon: 'pencil', label: `Bold (${MOD}+B)`, run: (sel) => wrap(sel, '**') },
928
+ { id: 'italic', icon: 'pencil', label: `Italic (${MOD}+I)`, run: (sel) => wrap(sel, '*') },
929
  { id: 'code', icon: 'flask', label: 'Inline code', run: (sel) => wrap(sel, '`') },
930
  { id: 'h2', icon: 'list', label: 'Heading', run: (sel) => `## ${sel || 'Heading'}` },
931
  { id: 'h3', icon: 'list', label: 'Subheading', run: (sel) => `### ${sel || 'Subheading'}` },
phd-advisor-frontend/src/components/canvas/CanvasModals.js CHANGED
@@ -871,7 +871,8 @@ export function PaletteModal({ data, onClose }) {
871
  <div className="pi-title">
872
  {w.name}
873
  {w.critic && <span className="widget-tag" style={{ fontSize: 8.5 }}>WEDGE</span>}
874
- {w.enhanced && !w.stub && <span className="widget-tag widget-tag-enhanced" style={{ fontSize: 8.5 }}>ENHANCED</span>}
 
875
  {w.stub && <span style={{ fontSize: 9, color: 'var(--canvas-text-4)', fontFamily: 'var(--canvas-mono)', marginLeft: 4 }}>SOON</span>}
876
  </div>
877
  <div className="pi-desc">{w.desc}</div>
 
871
  <div className="pi-title">
872
  {w.name}
873
  {w.critic && <span className="widget-tag" style={{ fontSize: 8.5 }}>WEDGE</span>}
874
+ {w.critic && <span className="widget-tag widget-tag-chat" style={{ fontSize: 8.5 }} title="Real critique happens in the main chat — this widget is a scratchpad">BEST IN CHAT</span>}
875
+ {w.enhanced && !w.stub && !w.critic && <span className="widget-tag widget-tag-enhanced" style={{ fontSize: 8.5 }}>ENHANCED</span>}
876
  {w.stub && <span style={{ fontSize: 9, color: 'var(--canvas-text-4)', fontFamily: 'var(--canvas-mono)', marginLeft: 4 }}>SOON</span>}
877
  </div>
878
  <div className="pi-desc">{w.desc}</div>
phd-advisor-frontend/src/components/canvas/CanvasWelcomeTour.js CHANGED
@@ -1,5 +1,6 @@
1
  import React, { useState, useEffect } from 'react';
2
  import Icon from './CanvasIcon';
 
3
 
4
  const TOUR_KEY = 'canvas-tour-seen-v1';
5
 
@@ -12,7 +13,7 @@ const STEPS = [
12
  {
13
  title: 'Add widgets from the palette',
14
  icon: 'plus',
15
- body: 'Click "Add widget" on the Workspace view, or hit K and search. There are 30+ widgets — bibliography, kanban, pomodoro, writing tracker, plus three "anti-yes-man" widgets that push back on your thinking.',
16
  },
17
  {
18
  title: 'Make it yours',
 
1
  import React, { useState, useEffect } from 'react';
2
  import Icon from './CanvasIcon';
3
+ import { MOD } from './platform';
4
 
5
  const TOUR_KEY = 'canvas-tour-seen-v1';
6
 
 
13
  {
14
  title: 'Add widgets from the palette',
15
  icon: 'plus',
16
+ body: `Click "Add widget" on the Workspace view, or hit ${MOD}+K and search. There are 30+ widgets — bibliography, kanban, pomodoro, writing tracker, plus three "anti-yes-man" widgets that push back on your thinking.`,
17
  },
18
  {
19
  title: 'Make it yours',
phd-advisor-frontend/src/components/canvas/CanvasWidgets.js CHANGED
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react';
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
  import Icon from './CanvasIcon';
 
5
 
6
  const fireToast = (msg, kind = 'success') =>
7
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
@@ -1431,13 +1432,193 @@ export function DocumenterWidget({ state, setState }) {
1431
  </div>
1432
  ))}
1433
  {entries.length === 0 && (
1434
- <EmptyState icon="pencil" title="No entries yet" hint="Drop a line about today. Hit ↵ to log it. Tap Weekly summary anytime."/>
1435
  )}
1436
  </div>
1437
  </>
1438
  );
1439
  }
1440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1441
  // ===== Stub — roadmap preview card =====
1442
  // Shows what's coming for this widget type so adding it from the palette
1443
  // doesn't feel like a dead end.
 
2
  import ReactMarkdown from 'react-markdown';
3
  import remarkGfm from 'remark-gfm';
4
  import Icon from './CanvasIcon';
5
+ import { MOD } from './platform';
6
 
7
  const fireToast = (msg, kind = 'success') =>
8
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
 
1432
  </div>
1433
  ))}
1434
  {entries.length === 0 && (
1435
+ <EmptyState icon="pencil" title="No entries yet" hint={`Drop a line about today. Hit ${MOD}+↵ to log it. Tap Weekly summary anytime.`}/>
1436
  )}
1437
  </div>
1438
  </>
1439
  );
1440
  }
1441
 
1442
+ // ===== PhD Journey — standard milestones with status + notes =====
1443
+ // Captures the "General Journey" list: courses, prelim, lit review, IRB,
1444
+ // committee, topic, comps, defense, ProQuest. Each milestone has a status
1445
+ // (open/in-progress/completed) and an inline note.
1446
+ const PHD_MILESTONES = [
1447
+ { id: 'courses', label: 'Course selection & dissertation credits', hint: 'Plan term-by-term. Check funding constraints.' },
1448
+ { id: 'prelim', label: 'Preliminary exam', hint: 'Department format varies — consult your handbook.' },
1449
+ { id: 'lit-review', label: 'Literature review', hint: 'Coverage + critique. Bibliography widget pairs well here.' },
1450
+ { id: 'topic', label: 'Pick dissertation topic', hint: 'Narrow until your advisor pushes back.' },
1451
+ { id: 'committee', label: 'Select committee', hint: 'Pros/cons of a co-chair: more buy-in, more scheduling.' },
1452
+ { id: 'irb', label: 'IRB approval', hint: 'Allow 6–12 weeks. Pre-fill paperwork early.' },
1453
+ { id: 'data', label: 'Data collection', hint: 'Pilot first. Plan for the inevitable instrument failure.' },
1454
+ { id: 'comps', label: 'Comprehensive exam', hint: 'Department format varies.' },
1455
+ { id: 'analysis', label: 'Data analysis & visualization', hint: 'Make the figures before the prose.' },
1456
+ { id: 'writing', label: 'Write dissertation', hint: 'One chapter at a time. Aim for "good enough to defend".' },
1457
+ { id: 'defense', label: 'Oral defense', hint: 'Slides + practice Q&A. Use the Defense Slides template.' },
1458
+ { id: 'proquest', label: 'Final admin (ProQuest upload)', hint: 'Read the formatting checklist before you start formatting.' },
1459
+ ];
1460
+
1461
+ export function PhdJourneyWidget({ state, setState }) {
1462
+ const statuses = state.statuses || {};
1463
+ const notes = state.notes || {};
1464
+ const [editingId, setEditingId] = useState(null);
1465
+ const completed = PHD_MILESTONES.filter(m => statuses[m.id] === 'completed').length;
1466
+ const total = PHD_MILESTONES.length;
1467
+ const pct = Math.round((completed / total) * 100);
1468
+
1469
+ const cycleStatus = (id) => {
1470
+ const cur = statuses[id] || 'open';
1471
+ const next = cur === 'open' ? 'in-progress' : cur === 'in-progress' ? 'completed' : 'open';
1472
+ setState({ ...state, statuses: { ...statuses, [id]: next } });
1473
+ };
1474
+ const updateNote = (id, text) => setState({ ...state, notes: { ...notes, [id]: text } });
1475
+
1476
+ return (
1477
+ <>
1478
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: 'var(--canvas-text-2)' }}>
1479
+ <span style={{ fontFamily: 'var(--canvas-mono)', fontWeight: 700, fontSize: 16, color: 'var(--canvas-text)' }}>
1480
+ {completed}<span style={{ color: 'var(--canvas-text-3)', fontWeight: 500 }}>/{total}</span>
1481
+ </span>
1482
+ <div className="progress" style={{ flex: 1, height: 6 }}>
1483
+ <i style={{ width: pct + '%' }}/>
1484
+ </div>
1485
+ <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11, color: 'var(--canvas-text-3)' }}>{pct}%</span>
1486
+ </div>
1487
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
1488
+ {PHD_MILESTONES.map(m => {
1489
+ const status = statuses[m.id] || 'open';
1490
+ const note = notes[m.id] || '';
1491
+ return (
1492
+ <div key={m.id} className={`phd-milestone milestone-${status}`}>
1493
+ <button
1494
+ className="phd-milestone-check"
1495
+ onClick={() => cycleStatus(m.id)}
1496
+ title={`Status: ${status} (click to advance)`}
1497
+ >
1498
+ {status === 'completed' && <Icon name="check" size={11}/>}
1499
+ {status === 'in-progress' && <span className="task-check-dot"/>}
1500
+ </button>
1501
+ <div className="phd-milestone-body" onClick={() => setEditingId(editingId === m.id ? null : m.id)}>
1502
+ <div className="phd-milestone-label">{m.label}</div>
1503
+ {(note || editingId === m.id) ? (
1504
+ editingId === m.id ? (
1505
+ <input
1506
+ className="inline-input"
1507
+ autoFocus
1508
+ defaultValue={note}
1509
+ placeholder={m.hint}
1510
+ onBlur={(e) => { updateNote(m.id, e.target.value); setEditingId(null); }}
1511
+ onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') setEditingId(null); }}
1512
+ onClick={(e) => e.stopPropagation()}
1513
+ style={{ marginTop: 2, fontSize: 11.5 }}
1514
+ />
1515
+ ) : (
1516
+ <div className="phd-milestone-note">{note}</div>
1517
+ )
1518
+ ) : (
1519
+ <div className="phd-milestone-hint">{m.hint}</div>
1520
+ )}
1521
+ </div>
1522
+ </div>
1523
+ );
1524
+ })}
1525
+ </div>
1526
+ </>
1527
+ );
1528
+ }
1529
+
1530
+ // ===== PhD Resources — curated links + open-source apps =====
1531
+ // Static curated list of useful PhD tools and resources, plus user-added links.
1532
+ const PHD_RESOURCE_GROUPS = [
1533
+ {
1534
+ label: 'Open-source PhD tools',
1535
+ items: [
1536
+ { name: 'Zotero', href: 'https://www.zotero.org/', desc: 'Reference manager — free and open source' },
1537
+ { name: 'Obsidian', href: 'https://obsidian.md/', desc: 'Local-first knowledge graph for notes' },
1538
+ { name: 'JabRef', href: 'https://www.jabref.org/', desc: 'BibTeX-native reference manager' },
1539
+ { name: 'Pandoc', href: 'https://pandoc.org/', desc: 'Universal document converter' },
1540
+ { name: 'Quarto', href: 'https://quarto.org/', desc: 'Scientific publishing with R/Python/Julia' },
1541
+ ],
1542
+ },
1543
+ {
1544
+ label: 'Writing & formatting',
1545
+ items: [
1546
+ { name: 'Overleaf', href: 'https://www.overleaf.com/', desc: 'Browser LaTeX editor with templates' },
1547
+ { name: 'LaTeX Templates', href: 'https://www.latextemplates.com/', desc: 'Thesis, CV, poster templates' },
1548
+ { name: 'Hemingway Editor', href: 'https://hemingwayapp.com/', desc: 'Plain-language readability check' },
1549
+ ],
1550
+ },
1551
+ {
1552
+ label: 'Community & career',
1553
+ items: [
1554
+ { name: 'Academic Twitter / #PhDChat', href: 'https://twitter.com/search?q=%23PhDChat', desc: 'Peers + advisors discussing the grind' },
1555
+ { name: 'ORCID', href: 'https://orcid.org/', desc: 'Permanent researcher ID for citations + grants' },
1556
+ { name: 'Conferences & CFPs (WikiCFP)', href: 'http://www.wikicfp.com/', desc: 'Upcoming deadlines across fields' },
1557
+ ],
1558
+ },
1559
+ ];
1560
+
1561
+ export function PhdResourcesWidget({ state, setState }) {
1562
+ const customLinks = state.customLinks || [];
1563
+ const [name, setName] = useState('');
1564
+ const [href, setHref] = useState('');
1565
+
1566
+ const addLink = () => {
1567
+ if (!name.trim() || !href.trim()) return;
1568
+ setState({ ...state, customLinks: [...customLinks, { id: 'r' + Date.now(), name: name.trim(), href: href.trim() }] });
1569
+ setName(''); setHref('');
1570
+ fireToast('Resource added');
1571
+ };
1572
+ const removeLink = (id) => setState({ ...state, customLinks: customLinks.filter(l => l.id !== id) });
1573
+
1574
+ return (
1575
+ <>
1576
+ {PHD_RESOURCE_GROUPS.map(g => (
1577
+ <div key={g.label}>
1578
+ <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 6 }}>
1579
+ {g.label}
1580
+ </div>
1581
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}>
1582
+ {g.items.map(it => (
1583
+ <a key={it.name} href={it.href} target="_blank" rel="noopener noreferrer" className="phd-resource-link">
1584
+ <span className="phd-resource-name">{it.name}</span>
1585
+ <span className="phd-resource-desc">{it.desc}</span>
1586
+ </a>
1587
+ ))}
1588
+ </div>
1589
+ </div>
1590
+ ))}
1591
+ {customLinks.length > 0 && (
1592
+ <div>
1593
+ <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 6 }}>
1594
+ Your links
1595
+ </div>
1596
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}>
1597
+ {customLinks.map(l => (
1598
+ <div key={l.id} className="phd-resource-link" style={{ position: 'relative' }}>
1599
+ <a href={l.href} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', flexDirection: 'column', flex: 1, textDecoration: 'none', color: 'inherit' }}>
1600
+ <span className="phd-resource-name">{l.name}</span>
1601
+ <span className="phd-resource-desc">{l.href}</span>
1602
+ </a>
1603
+ <button className="icon-btn" onClick={() => removeLink(l.id)} style={{ position: 'absolute', right: 4, top: 4, width: 20, height: 20 }}>
1604
+ <Icon name="x" size={11}/>
1605
+ </button>
1606
+ </div>
1607
+ ))}
1608
+ </div>
1609
+ </div>
1610
+ )}
1611
+ <div style={{ display: 'flex', gap: 4 }}>
1612
+ <input className="input" placeholder="Resource name" value={name} onChange={e => setName(e.target.value)} style={{ fontSize: 12, padding: '5px 8px' }}/>
1613
+ <input className="input" placeholder="https://…" value={href} onChange={e => setHref(e.target.value)} style={{ fontSize: 12, padding: '5px 8px', flex: 2 }}/>
1614
+ <button className="btn" onClick={addLink} disabled={!name.trim() || !href.trim()}>
1615
+ <Icon name="plus" size={12}/>Add
1616
+ </button>
1617
+ </div>
1618
+ </>
1619
+ );
1620
+ }
1621
+
1622
  // ===== Stub — roadmap preview card =====
1623
  // Shows what's coming for this widget type so adding it from the palette
1624
  // doesn't feel like a dead end.
phd-advisor-frontend/src/components/canvas/canvasData.js CHANGED
@@ -10,6 +10,7 @@ export const INSIGHTS = [
10
  id: 'i-progress',
11
  title: 'Research progress',
12
  icon: 'graph',
 
13
  confidence: 78,
14
  summary: 'Primary recordings from 4 of 6 planned animals are complete. Remaining two scheduled for May 18 and May 25. Analysis pipeline working on existing data; first results draft expected June.',
15
  bullets: [
@@ -18,11 +19,18 @@ export const INSIGHTS = [
18
  '<strong>Risk:</strong> M3 fixation drift suspected; need re-review with adv.',
19
  ],
20
  pinned: true,
 
 
 
 
 
 
21
  },
22
  {
23
  id: 'i-method',
24
  title: 'Methodology',
25
  icon: 'flask',
 
26
  confidence: 64,
27
  summary: 'GLM with spike-history kernel + visual drive is your declared model. You\'ve resisted committing to a specific predictive-coding formulation; this comes up in every advisor meeting.',
28
  bullets: [
@@ -30,11 +38,18 @@ export const INSIGHTS = [
30
  'Open: which predictive-coding variant — Rao & Ballard vs. Bastos top-down',
31
  'Open: how to operationalize "prediction error" from extracellular spikes',
32
  ],
 
 
 
 
 
 
33
  },
34
  {
35
  id: 'i-lit',
36
  title: 'Literature review',
37
  icon: 'book',
 
38
  confidence: 71,
39
  summary: 'Strong on canonical predictive coding (Rao & Ballard 1999, Bastos 2012, Keller & Mrsic-Flogel 2018). Thin on recent feedback-circuit anatomy and on counter-evidence — this is showing up as a critique gap.',
40
  bullets: [
@@ -42,11 +57,18 @@ export const INSIGHTS = [
42
  '<strong>Gap:</strong> sparse on L5b feedback anatomy (Harris/Shepherd lab)',
43
  '<strong>Gap:</strong> no engagement with anti-PC critiques (e.g. Heeger 2017)',
44
  ],
 
 
 
 
 
 
45
  },
46
  {
47
  id: 'i-questions',
48
  title: 'Open research questions',
49
  icon: 'sparkles',
 
50
  confidence: 58,
51
  summary: 'Three live threads. Question 1 (does L2/3 spiking encode prediction error?) is the dissertation core. Q2 and Q3 are scoped to specific aims.',
52
  bullets: [
@@ -54,11 +76,18 @@ export const INSIGHTS = [
54
  '<strong>Q2:</strong> How does this depend on context length (1 vs. 4 vs. 16 trials)?',
55
  '<strong>Q3:</strong> Is the signal sharpened by feedback from V2/RSC?',
56
  ],
 
 
 
 
 
 
57
  },
58
  {
59
  id: 'i-next',
60
  title: 'Next steps',
61
  icon: 'arrow',
 
62
  confidence: 82,
63
  summary: 'Concrete, near-term actions. Two of these have been on the list for 3+ weeks.',
64
  bullets: [
@@ -67,17 +96,30 @@ export const INSIGHTS = [
67
  'Read Heeger 2017 + Aitchison & Lengyel 2017',
68
  'Schedule pilot with M5 (May 18)',
69
  ],
 
 
 
 
 
 
70
  },
71
  {
72
  id: 'i-blockers',
73
  title: 'Blockers & risks',
74
  icon: 'alert',
 
75
  confidence: 70,
76
  summary: 'One technical, one structural. The structural one is more important and you are deferring it.',
77
  bullets: [
78
  '<strong>Technical:</strong> Drift on M3 — may lose 1 animal of data',
79
  '<strong>Structural:</strong> No clear predictive-coding theory commitment yet → hard to define what counts as evidence',
80
  ],
 
 
 
 
 
 
81
  },
82
  ];
83
 
@@ -103,6 +145,8 @@ export const WIDGET_CATALOG = [
103
  { type: 'calendar', name: 'Calendar', desc: 'Month grid with deadlines and writing days', icon: 'calendar', cat: 'project', defaultSize: 'M', enhanced: true },
104
  { type: 'activity', name: 'Activity Feed', desc: 'Chronological log of edits across widgets', icon: 'graph', cat: 'project', defaultSize: 'M', enhanced: true },
105
  { type: 'documenter', name: 'Daily Documenter', desc: 'Date-stamped journal · AI weekly summary (LLM stub)', icon: 'pencil', cat: 'project', defaultSize: 'M', enhanced: true },
 
 
106
 
107
  { type: 'mood', name: 'Mood / Burnout Check-in', desc: 'Daily slider, trend graph', icon: 'smile', cat: 'wellness', defaultSize: 'S', stub: true },
108
  { type: 'sleep', name: 'Sleep & Energy', desc: 'Correlate with productive days', icon: 'heart', cat: 'wellness', defaultSize: 'S', stub: true },
@@ -245,5 +289,14 @@ export const EMPTY_STATE = {
245
  calendar: { viewMonth: new Date().toISOString().slice(0, 7) },
246
  activity: {},
247
  documenter: { entries: [], lastSummary: null },
 
 
 
 
 
 
 
 
 
248
  };
249
 
 
10
  id: 'i-progress',
11
  title: 'Research progress',
12
  icon: 'graph',
13
+ category: 'progress',
14
  confidence: 78,
15
  summary: 'Primary recordings from 4 of 6 planned animals are complete. Remaining two scheduled for May 18 and May 25. Analysis pipeline working on existing data; first results draft expected June.',
16
  bullets: [
 
19
  '<strong>Risk:</strong> M3 fixation drift suspected; need re-review with adv.',
20
  ],
21
  pinned: true,
22
+ sources: 12,
23
+ updatedMinutesAgo: 3,
24
+ quotes: [
25
+ '"Animal M4 recording finished today, sorting completes tomorrow." — May 6 lab notes',
26
+ '"Pipeline is happy with M1, M2; M3 looks drifty." — chat with Reineke advisor',
27
+ ],
28
  },
29
  {
30
  id: 'i-method',
31
  title: 'Methodology',
32
  icon: 'flask',
33
+ category: 'theory',
34
  confidence: 64,
35
  summary: 'GLM with spike-history kernel + visual drive is your declared model. You\'ve resisted committing to a specific predictive-coding formulation; this comes up in every advisor meeting.',
36
  bullets: [
 
38
  'Open: which predictive-coding variant — Rao & Ballard vs. Bastos top-down',
39
  'Open: how to operationalize "prediction error" from extracellular spikes',
40
  ],
41
+ sources: 8,
42
+ updatedMinutesAgo: 12,
43
+ quotes: [
44
+ '"Need to pick a PC formulation by next 1:1." — meeting notes May 2',
45
+ '"Bastos lets you predict laminar profile; Rao&Ballard does not." — methodologist chat',
46
+ ],
47
  },
48
  {
49
  id: 'i-lit',
50
  title: 'Literature review',
51
  icon: 'book',
52
+ category: 'literature',
53
  confidence: 71,
54
  summary: 'Strong on canonical predictive coding (Rao & Ballard 1999, Bastos 2012, Keller & Mrsic-Flogel 2018). Thin on recent feedback-circuit anatomy and on counter-evidence — this is showing up as a critique gap.',
55
  bullets: [
 
57
  '<strong>Gap:</strong> sparse on L5b feedback anatomy (Harris/Shepherd lab)',
58
  '<strong>Gap:</strong> no engagement with anti-PC critiques (e.g. Heeger 2017)',
59
  ],
60
+ sources: 47,
61
+ updatedMinutesAgo: 22,
62
+ quotes: [
63
+ '"Have you read Heeger 2017? It changes a lot." — lit-review aide',
64
+ '"L5b feedback anatomy is your weak spot." — devil\'s advocate',
65
+ ],
66
  },
67
  {
68
  id: 'i-questions',
69
  title: 'Open research questions',
70
  icon: 'sparkles',
71
+ category: 'theory',
72
  confidence: 58,
73
  summary: 'Three live threads. Question 1 (does L2/3 spiking encode prediction error?) is the dissertation core. Q2 and Q3 are scoped to specific aims.',
74
  bullets: [
 
76
  '<strong>Q2:</strong> How does this depend on context length (1 vs. 4 vs. 16 trials)?',
77
  '<strong>Q3:</strong> Is the signal sharpened by feedback from V2/RSC?',
78
  ],
79
+ sources: 6,
80
+ updatedMinutesAgo: 38,
81
+ quotes: [
82
+ '"Q1 is what the whole dissertation rests on." — methodologist',
83
+ '"Q3 is exciting but probably out of scope for the thesis." — Reineke',
84
+ ],
85
  },
86
  {
87
  id: 'i-next',
88
  title: 'Next steps',
89
  icon: 'arrow',
90
+ category: 'action',
91
  confidence: 82,
92
  summary: 'Concrete, near-term actions. Two of these have been on the list for 3+ weeks.',
93
  bullets: [
 
96
  'Read Heeger 2017 + Aitchison & Lengyel 2017',
97
  'Schedule pilot with M5 (May 18)',
98
  ],
99
+ sources: 5,
100
+ updatedMinutesAgo: 8,
101
+ quotes: [
102
+ '"Aim 2 draft has to land by May 22 or quals slip." — Reineke',
103
+ '"M3 review keeps getting punted." — last 3 advisor meetings',
104
+ ],
105
  },
106
  {
107
  id: 'i-blockers',
108
  title: 'Blockers & risks',
109
  icon: 'alert',
110
+ category: 'risk',
111
  confidence: 70,
112
  summary: 'One technical, one structural. The structural one is more important and you are deferring it.',
113
  bullets: [
114
  '<strong>Technical:</strong> Drift on M3 — may lose 1 animal of data',
115
  '<strong>Structural:</strong> No clear predictive-coding theory commitment yet → hard to define what counts as evidence',
116
  ],
117
+ sources: 4,
118
+ updatedMinutesAgo: 18,
119
+ quotes: [
120
+ '"If M3 is unusable you\'re at n=5 — still publishable but tight." — methodologist',
121
+ '"Without a theory commitment you can\'t falsify anything." — devil\'s advocate',
122
+ ],
123
  },
124
  ];
125
 
 
145
  { type: 'calendar', name: 'Calendar', desc: 'Month grid with deadlines and writing days', icon: 'calendar', cat: 'project', defaultSize: 'M', enhanced: true },
146
  { type: 'activity', name: 'Activity Feed', desc: 'Chronological log of edits across widgets', icon: 'graph', cat: 'project', defaultSize: 'M', enhanced: true },
147
  { type: 'documenter', name: 'Daily Documenter', desc: 'Date-stamped journal · AI weekly summary (LLM stub)', icon: 'pencil', cat: 'project', defaultSize: 'M', enhanced: true },
148
+ { type: 'phd-journey', name: 'PhD Journey', desc: 'Standard PhD milestones — courses → defense → ProQuest', icon: 'flag', cat: 'project', defaultSize: 'M', enhanced: true },
149
+ { type: 'phd-resources', name: 'PhD Resources', desc: 'Curated open-source tools, handbooks, and community links', icon: 'star', cat: 'research', defaultSize: 'M', enhanced: true },
150
 
151
  { type: 'mood', name: 'Mood / Burnout Check-in', desc: 'Daily slider, trend graph', icon: 'smile', cat: 'wellness', defaultSize: 'S', stub: true },
152
  { type: 'sleep', name: 'Sleep & Energy', desc: 'Correlate with productive days', icon: 'heart', cat: 'wellness', defaultSize: 'S', stub: true },
 
289
  calendar: { viewMonth: new Date().toISOString().slice(0, 7) },
290
  activity: {},
291
  documenter: { entries: [], lastSummary: null },
292
+ 'phd-journey': {
293
+ // Status per milestone: 'open' | 'in-progress' | 'completed'
294
+ // Milestones come from the standard PhD journey (course selection → ProQuest)
295
+ statuses: {},
296
+ notes: {},
297
+ },
298
+ 'phd-resources': {
299
+ customLinks: [],
300
+ },
301
  };
302
 
phd-advisor-frontend/src/components/canvas/platform.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ // Tiny helper so keyboard shortcut hints adapt to the user's OS.
2
+ // Detect once at module load — we don't expect platform to change mid-session.
3
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
4
+ export const MOD = isMac ? '⌘' : 'Ctrl';
5
+ export const MOD_LABEL = isMac ? 'Cmd' : 'Ctrl';
phd-advisor-frontend/src/pages/CanvasPage.js CHANGED
@@ -14,6 +14,7 @@ import {
14
  HabitsWidget, GoalsWidget, MeetingsWidget,
15
  OutlineWidget, HighlightsWidget, LatexWidget,
16
  CalendarWidget, DocumenterWidget, ActivityWidget,
 
17
  StubWidget,
18
  } from '../components/canvas/CanvasWidgets';
19
  import {
@@ -28,6 +29,7 @@ import {
28
  } from '../components/canvas/CanvasModals';
29
  import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
30
  import DeliverablesView, { TEMPLATES as DELIVERABLE_TEMPLATES } from '../components/canvas/CanvasDeliverables';
 
31
  import '../styles/CanvasPage.css';
32
 
33
  const LAYOUT_KEY = 'canvas-layout-v2';
@@ -57,6 +59,8 @@ function renderWidget(type, state, setState, openModal, allStates) {
57
  case 'calendar': return <CalendarWidget {...props}/>;
58
  case 'documenter': return <DocumenterWidget {...props}/>;
59
  case 'activity': return <ActivityWidget {...props}/>;
 
 
60
  default: {
61
  const meta = WIDGET_CATALOG.find(w => w.type === type);
62
  return <StubWidget meta={meta}/>;
@@ -100,15 +104,108 @@ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver,
100
  );
101
  }
102
 
103
- function InsightsView({ widgetStates, setWidgetStates }) {
104
- const [pinned, setPinned] = useState(new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id)));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  const togglePin = (id) => {
106
- const n = new Set(pinned);
107
- if (n.has(id)) n.delete(id); else n.add(id);
108
- setPinned(n);
 
 
 
 
 
 
 
 
 
109
  };
110
 
111
- // Strip HTML, take first 80 chars for the kanban card title.
112
  const insightToTaskTitle = (ins) => {
113
  const plain = (ins.bullets[0] || ins.summary || ins.title).replace(/<[^>]+>/g, '');
114
  return plain.length > 80 ? plain.slice(0, 77) + '…' : plain;
@@ -128,54 +225,404 @@ function InsightsView({ widgetStates, setWidgetStates }) {
128
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Sent to Kanban (To Do)', kind: 'success' } }));
129
  };
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  return (
132
  <>
133
  <div className="page-header">
134
  <div>
135
  <h1 className="page-title">Insights</h1>
136
- <div className="page-sub">AI-synthesized from your research conversations · {INSIGHTS.length} sections</div>
137
  </div>
138
- <div className="page-meta">
139
- <span className="dot"/>
140
- <span>updated 3 min ago</span>
141
- <button className="icon-btn" title="Refresh"><Icon name="refresh" size={14}/></button>
 
 
 
142
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  </div>
144
- <div className="insight-grid">
145
- {INSIGHTS.map(ins => (
146
- <div key={ins.id} className="insight">
147
- <div className="insight-head">
148
- <div className="insight-icon"><Icon name={ins.icon} size={15}/></div>
149
- <div className="insight-title">{ins.title}</div>
150
- <div className="confidence">
151
- <div className="conf-bar"><i style={{ width: ins.confidence + '%' }}/></div>
152
- <span>{ins.confidence}%</span>
153
- </div>
154
- </div>
155
- <div className="insight-body">
156
- <div>{ins.summary}</div>
157
- <ul>
158
- {ins.bullets.map((b, i) => <li key={i} dangerouslySetInnerHTML={{ __html: b }}/>)}
159
- </ul>
160
- </div>
161
- <div className="insight-actions">
162
- {/* TODO(LLM): wire "Ask follow-up" to chat endpoint with insight context */}
163
- <button className="chip" disabled title="Needs LLM endpoint"><Icon name="message" size={11}/>Ask follow-up</button>
164
- <button className="chip" onClick={() => sendToKanban(ins)}><Icon name="task" size={11}/>To task</button>
165
- {/* TODO(LLM): wire "Cite" to search Bibliography for the source paper */}
166
- <button className="chip" disabled title="Coming soon"><Icon name="cite" size={11}/>Cite</button>
167
- <button className="chip"><Icon name="expand" size={11}/>Expand</button>
168
- <button className={`chip ${pinned.has(ins.id) ? 'pinned' : ''}`} onClick={() => togglePin(ins.id)}>
169
- <Icon name="pin" size={11}/>{pinned.has(ins.id) ? 'Pinned' : 'Pin'}
 
170
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  </div>
173
- ))}
174
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  </>
176
  );
177
  }
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  function PresetPicker({ onPick }) {
180
  return (
181
  <div className="canvas-presets">
@@ -432,6 +879,13 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
432
  openModal('global-search', { states: widgetStates });
433
  }, [openModal, widgetStates]);
434
 
 
 
 
 
 
 
 
435
  // Esc closes modal, ⌘K opens command palette, ⌘/ opens global content search,
436
  // ? opens the welcome tour for help (matches the icon in the topbar).
437
  useEffect(() => {
@@ -484,6 +938,26 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
484
  return order.map(c => groups[c]).filter(Boolean);
485
  }, [layout]);
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  // Deliverables: list of projects with sections + history actions
488
  const deliverableProjects = useMemo(() => {
489
  try {
@@ -538,6 +1012,7 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
538
  canvasSubview={view}
539
  widgetGroups={widgetGroups}
540
  deliverableProjects={deliverableProjects}
 
541
  />
542
  <div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
543
  <div className="canvas-app-shell">
@@ -551,15 +1026,15 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
551
  <button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
552
  <HelpCircle size={18}/>
553
  </button>
554
- <button className="icon-btn" onClick={openGlobalSearch} title="Search canvas content (/)">
555
  <Icon name="search" size={16}/>
556
  </button>
557
- <button className="icon-btn" onClick={openCommandPalette} title="Commands (K)">
558
  <Icon name="zap" size={16}/>
559
  </button>
560
  </AppHeader>
561
  <div className="canvas-content">
562
- {view === 'insights' && <InsightsView widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
563
  {view === 'workspace' && <WorkspaceView openModal={openModal} layout={layout} setLayout={setLayout} widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
564
  {view === 'deliverables' && <DeliverablesView allStates={widgetStates}/>}
565
  </div>
@@ -587,8 +1062,8 @@ function ShortcutHint() {
587
  onMouseEnter={() => setVisible(true)}
588
  onMouseLeave={() => setVisible(false)}
589
  >
590
- <span><kbd></kbd><kbd>K</kbd> commands</span>
591
- <span><kbd></kbd><kbd>/</kbd> search</span>
592
  <span><kbd>?</kbd> help</span>
593
  </div>
594
  );
 
14
  HabitsWidget, GoalsWidget, MeetingsWidget,
15
  OutlineWidget, HighlightsWidget, LatexWidget,
16
  CalendarWidget, DocumenterWidget, ActivityWidget,
17
+ PhdJourneyWidget, PhdResourcesWidget,
18
  StubWidget,
19
  } from '../components/canvas/CanvasWidgets';
20
  import {
 
29
  } from '../components/canvas/CanvasModals';
30
  import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
31
  import DeliverablesView, { TEMPLATES as DELIVERABLE_TEMPLATES } from '../components/canvas/CanvasDeliverables';
32
+ import { MOD } from '../components/canvas/platform';
33
  import '../styles/CanvasPage.css';
34
 
35
  const LAYOUT_KEY = 'canvas-layout-v2';
 
59
  case 'calendar': return <CalendarWidget {...props}/>;
60
  case 'documenter': return <DocumenterWidget {...props}/>;
61
  case 'activity': return <ActivityWidget {...props}/>;
62
+ case 'phd-journey': return <PhdJourneyWidget {...props}/>;
63
+ case 'phd-resources': return <PhdResourcesWidget {...props}/>;
64
  default: {
65
  const meta = WIDGET_CATALOG.find(w => w.type === type);
66
  return <StubWidget meta={meta}/>;
 
104
  );
105
  }
106
 
107
+ // ============================================================================
108
+ // Insights view AI-synthesized highlights with stats bar, filters, and
109
+ // click-to-expand source quotes.
110
+ // ============================================================================
111
+ const INSIGHT_CATEGORIES = [
112
+ { id: 'all', label: 'All' },
113
+ { id: 'open', label: 'Open' },
114
+ { id: 'in-progress', label: 'In progress' },
115
+ { id: 'completed', label: 'Completed' },
116
+ { id: 'abandoned', label: 'Abandoned' },
117
+ { id: 'pinned', label: 'Pinned' },
118
+ { id: 'high', label: 'High confidence' },
119
+ { id: 'progress', label: 'Progress' },
120
+ { id: 'theory', label: 'Theory' },
121
+ { id: 'literature', label: 'Literature' },
122
+ { id: 'action', label: 'Actions' },
123
+ { id: 'risk', label: 'Risks' },
124
+ ];
125
+ const CATEGORY_TINT = {
126
+ progress: 'rgba(16, 185, 129, 0.12)',
127
+ theory: 'rgba(99, 102, 241, 0.12)',
128
+ literature: 'rgba(245, 158, 11, 0.12)',
129
+ action: 'rgba(59, 130, 246, 0.12)',
130
+ risk: 'rgba(220, 38, 38, 0.12)',
131
+ };
132
+ const CATEGORY_FG = {
133
+ progress: '#10B981',
134
+ theory: '#818CF8',
135
+ literature: '#F59E0B',
136
+ action: '#3B82F6',
137
+ risk: '#DC2626',
138
+ };
139
+ const confidenceTier = (c) => c >= 75 ? 'high' : c >= 60 ? 'med' : 'low';
140
+
141
+ // Task statuses live on each individual bullet within an insight, not on the
142
+ // whole card — Daniel's feedback: "each card is a discrete task, not a set of tasks".
143
+ const TASK_STATUSES = [
144
+ { id: 'open', label: 'Open', color: 'var(--canvas-text-3)', icon: 'sparkles' },
145
+ { id: 'in-progress', label: 'In progress', color: '#3B82F6', icon: 'graph' },
146
+ { id: 'completed', label: 'Completed', color: '#10B981', icon: 'check' },
147
+ { id: 'abandoned', label: 'Abandoned', color: 'var(--canvas-text-4)', icon: 'x' },
148
+ ];
149
+ const TASK_STATUS_KEY = 'canvas-task-status-v1';
150
+ const taskKey = (insId, idx) => `${insId}::${idx}`;
151
+
152
+ function InsightsView({ widgetStates, setWidgetStates, onNavigateToChat }) {
153
+ const [pinned, setPinned] = useState(() => new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id)));
154
+ const [taskStatuses, setTaskStatuses] = useState(() => {
155
+ try { return JSON.parse(localStorage.getItem(TASK_STATUS_KEY) || '{}'); } catch { return {}; }
156
+ });
157
+ const [filter, setFilter] = useState('all');
158
+ const [sortBy, setSortBy] = useState('confidence');
159
+ const [expanded, setExpanded] = useState(new Set());
160
+ const [refreshing, setRefreshing] = useState(false);
161
+ const [openStatusMenu, setOpenStatusMenu] = useState(null);
162
+ // 'cards' = current cards-of-tasks layout, 'tasks' = flat task list per Daniel's
163
+ // "Sections in sidebar, Tasks in the main view" suggestion.
164
+ const [viewMode, setViewMode] = useState(() => localStorage.getItem('canvas-insights-view') || 'cards');
165
+ useEffect(() => { localStorage.setItem('canvas-insights-view', viewMode); }, [viewMode]);
166
+
167
+ useEffect(() => {
168
+ localStorage.setItem(TASK_STATUS_KEY, JSON.stringify(taskStatuses));
169
+ }, [taskStatuses]);
170
+
171
+ const taskStatusOf = (insId, idx) => taskStatuses[taskKey(insId, idx)] || 'open';
172
+ const setTaskStatus = (insId, idx, status) => {
173
+ setTaskStatuses(prev => ({ ...prev, [taskKey(insId, idx)]: status }));
174
+ const lbl = TASK_STATUSES.find(s => s.id === status)?.label || status;
175
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `Task marked ${lbl}`, kind: 'success' } }));
176
+ };
177
+
178
+ // Roll up to a card-level status: completed if all tasks done, abandoned if all abandoned,
179
+ // in-progress if any are in-progress, otherwise open.
180
+ const insightRollup = (ins) => {
181
+ const states = ins.bullets.map((_, idx) => taskStatusOf(ins.id, idx));
182
+ const total = states.length;
183
+ if (total === 0) return { state: 'open', done: 0, total: 0, pct: 0 };
184
+ const done = states.filter(s => s === 'completed').length;
185
+ const inProg = states.filter(s => s === 'in-progress').length;
186
+ const abandoned = states.filter(s => s === 'abandoned').length;
187
+ let state = 'open';
188
+ if (done === total) state = 'completed';
189
+ else if (abandoned === total) state = 'abandoned';
190
+ else if (inProg > 0 || done > 0) state = 'in-progress';
191
+ return { state, done, total, inProg, abandoned, pct: Math.round((done / total) * 100) };
192
+ };
193
+
194
  const togglePin = (id) => {
195
+ setPinned(prev => {
196
+ const n = new Set(prev);
197
+ if (n.has(id)) n.delete(id); else n.add(id);
198
+ return n;
199
+ });
200
+ };
201
+ const toggleExpand = (id) => {
202
+ setExpanded(prev => {
203
+ const n = new Set(prev);
204
+ if (n.has(id)) n.delete(id); else n.add(id);
205
+ return n;
206
+ });
207
  };
208
 
 
209
  const insightToTaskTitle = (ins) => {
210
  const plain = (ins.bullets[0] || ins.summary || ins.title).replace(/<[^>]+>/g, '');
211
  return plain.length > 80 ? plain.slice(0, 77) + '…' : plain;
 
225
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Sent to Kanban (To Do)', kind: 'success' } }));
226
  };
227
 
228
+ // TODO(LLM): real refresh hits the orchestrator and re-synthesizes insights.
229
+ const handleRefresh = () => {
230
+ setRefreshing(true);
231
+ setTimeout(() => setRefreshing(false), 900);
232
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Refreshing insights… (stub)', kind: 'success' } }));
233
+ };
234
+
235
+ // "Ask follow-up": stash a draft prompt + insight context that the chat page
236
+ // can pick up on next navigation. Backend hookup TODO: include source-conversation IDs.
237
+ const askFollowUp = (ins) => {
238
+ const plain = (ins.bullets[0] || ins.summary || '').replace(/<[^>]+>/g, '');
239
+ const prompt = `Follow up on the insight "${ins.title}": ${plain}`;
240
+ try {
241
+ localStorage.setItem('canvas-chat-handoff', JSON.stringify({
242
+ at: Date.now(),
243
+ prompt,
244
+ insightId: ins.id,
245
+ insightTitle: ins.title,
246
+ }));
247
+ } catch { /* ignore */ }
248
+ window.dispatchEvent(new CustomEvent('canvas-toast', {
249
+ detail: { msg: `Follow-up drafted: "${ins.title}" — opening chat`, kind: 'success' },
250
+ }));
251
+ // Use the existing navigation prop if present; falls back to the global event.
252
+ if (onNavigateToChat) onNavigateToChat();
253
+ };
254
+
255
+ const filtered = useMemo(() => {
256
+ let r = INSIGHTS;
257
+ if (filter === 'pinned') r = r.filter(i => pinned.has(i.id));
258
+ else if (filter === 'high') r = r.filter(i => i.confidence >= 75);
259
+ else if (['open', 'in-progress', 'completed', 'abandoned'].includes(filter)) r = r.filter(i => insightRollup(i).state === filter);
260
+ else if (filter !== 'all') r = r.filter(i => i.category === filter);
261
+ if (sortBy === 'confidence') r = [...r].sort((a, b) => b.confidence - a.confidence);
262
+ if (sortBy === 'recent') r = [...r].sort((a, b) => (a.updatedMinutesAgo || 0) - (b.updatedMinutesAgo || 0));
263
+ if (sortBy === 'progress') r = [...r].sort((a, b) => insightRollup(b).pct - insightRollup(a).pct);
264
+ return r;
265
+ }, [filter, sortBy, pinned, taskStatuses]); // eslint-disable-line react-hooks/exhaustive-deps
266
+
267
+ // Aggregate stats over individual TASKS, not insights (Daniel's framing)
268
+ const stats = useMemo(() => {
269
+ const allTasks = INSIGHTS.flatMap(i => i.bullets.map((_, idx) => taskStatusOf(i.id, idx)));
270
+ const taskTotal = allTasks.length;
271
+ const completed = allTasks.filter(s => s === 'completed').length;
272
+ const inProgress = allTasks.filter(s => s === 'in-progress').length;
273
+ const abandoned = allTasks.filter(s => s === 'abandoned').length;
274
+ const open = taskTotal - completed - inProgress - abandoned;
275
+ const totalSources = INSIGHTS.reduce((s, i) => s + (i.sources || 0), 0);
276
+ const avgConf = Math.round(INSIGHTS.reduce((s, i) => s + i.confidence, 0) / INSIGHTS.length);
277
+ return {
278
+ sections: INSIGHTS.length, taskTotal, completed, inProgress, abandoned, open,
279
+ totalSources, avgConf, pinnedCount: pinned.size,
280
+ };
281
+ }, [pinned, taskStatuses]); // eslint-disable-line react-hooks/exhaustive-deps
282
+
283
+ const lastUpdated = Math.min(...INSIGHTS.map(i => i.updatedMinutesAgo || 0));
284
+
285
+ // Empty state — defensive (current data is hardcoded but a real backend could send [])
286
+ if (INSIGHTS.length === 0) {
287
+ return (
288
+ <>
289
+ <div className="page-header">
290
+ <div>
291
+ <h1 className="page-title">Insights</h1>
292
+ <div className="page-sub">AI-synthesized from your research conversations.</div>
293
+ </div>
294
+ </div>
295
+ <div className="empty-cell">
296
+ <Icon name="sparkles" size={32} style={{ color: 'var(--canvas-text-4)' }}/>
297
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>No insights yet</div>
298
+ <div>Have a conversation with your advisors and insights will appear here.</div>
299
+ </div>
300
+ </>
301
+ );
302
+ }
303
+
304
  return (
305
  <>
306
  <div className="page-header">
307
  <div>
308
  <h1 className="page-title">Insights</h1>
309
+ <div className="page-sub">AI-synthesized from your research conversations.</div>
310
  </div>
311
+ </div>
312
+
313
+ {/* Stats bar */}
314
+ <div className="insights-stats">
315
+ <div className="insights-stat">
316
+ <span className="insights-stat-value">{stats.completed}/{stats.taskTotal}</span>
317
+ <span className="insights-stat-label">tasks done</span>
318
  </div>
319
+ <div className="insights-stat insights-stat-progress">
320
+ <div className="insights-progress-bar">
321
+ <i className="ip-completed" style={{ width: `${(stats.completed / Math.max(1, stats.taskTotal)) * 100}%` }}/>
322
+ <i className="ip-inprogress" style={{ width: `${(stats.inProgress / Math.max(1, stats.taskTotal)) * 100}%` }}/>
323
+ <i className="ip-abandoned" style={{ width: `${(stats.abandoned / Math.max(1, stats.taskTotal)) * 100}%` }}/>
324
+ </div>
325
+ <span className="insights-stat-label">
326
+ {stats.completed} done · {stats.inProgress} in progress · {stats.open} open
327
+ {stats.abandoned > 0 && ` · ${stats.abandoned} abandoned`}
328
+ </span>
329
+ </div>
330
+ <div className="insights-stat">
331
+ <span className="insights-stat-value">{stats.sections}</span>
332
+ <span className="insights-stat-label">sections</span>
333
+ </div>
334
+ <div className="insights-stat">
335
+ <span className="insights-stat-value">{stats.avgConf}%</span>
336
+ <span className="insights-stat-label">avg confidence</span>
337
+ </div>
338
+ <span style={{ flex: 1 }}/>
339
+ <span className="insights-stat-update">
340
+ <span className="dot"/>
341
+ updated {lastUpdated} min ago
342
+ </span>
343
+ <button className="btn btn-ghost" onClick={handleRefresh} disabled={refreshing}>
344
+ {refreshing ? <div className="spinner"/> : <Icon name="refresh" size={13}/>}
345
+ Refresh
346
+ </button>
347
  </div>
348
+
349
+ {/* View toggle: Cards (sections with their tasks) vs Tasks (flat list) */}
350
+ <div className="insights-view-toggle">
351
+ <button className={viewMode === 'cards' ? 'active' : ''} onClick={() => setViewMode('cards')}>
352
+ <Icon name="layout" size={12}/>Cards
353
+ </button>
354
+ <button className={viewMode === 'tasks' ? 'active' : ''} onClick={() => setViewMode('tasks')}>
355
+ <Icon name="task" size={12}/>Tasks
356
+ </button>
357
+ </div>
358
+
359
+ {/* Filter + sort */}
360
+ <div className="insights-filters">
361
+ <div className="palette-cats" style={{ marginBottom: 0 }}>
362
+ {INSIGHT_CATEGORIES.map(c => {
363
+ const count =
364
+ c.id === 'all' ? INSIGHTS.length :
365
+ c.id === 'pinned' ? pinned.size :
366
+ c.id === 'high' ? INSIGHTS.filter(i => i.confidence >= 75).length :
367
+ ['open', 'in-progress', 'completed', 'abandoned'].includes(c.id) ? INSIGHTS.filter(i => insightRollup(i).state === c.id).length :
368
+ INSIGHTS.filter(i => i.category === c.id).length;
369
+ if (count === 0 && c.id !== 'all') return null;
370
+ return (
371
+ <button key={c.id}
372
+ className={`palette-cat ${filter === c.id ? 'active' : ''}`}
373
+ onClick={() => setFilter(c.id)}>
374
+ {c.label}<span style={{ marginLeft: 6, opacity: 0.6 }}>{count}</span>
375
  </button>
376
+ );
377
+ })}
378
+ </div>
379
+ <select className="select" style={{ width: 'auto', padding: '4px 8px', fontSize: 11, fontFamily: 'var(--canvas-mono)' }}
380
+ value={sortBy} onChange={e => setSortBy(e.target.value)}>
381
+ <option value="confidence">↓ confidence</option>
382
+ <option value="recent">↓ recent</option>
383
+ <option value="progress">↓ progress</option>
384
+ </select>
385
+ </div>
386
+
387
+ {/* Pinned strip — only when there are pins and we're not already filtering by pinned */}
388
+ {pinned.size > 0 && filter !== 'pinned' && (
389
+ <div className="insights-pinned-strip">
390
+ <span style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
391
+ Pinned
392
+ </span>
393
+ {INSIGHTS.filter(i => pinned.has(i.id)).map(ins => (
394
+ <button key={ins.id} className="insights-pinned-pill" onClick={() => {
395
+ const el = document.getElementById(`insight-${ins.id}`);
396
+ if (el) {
397
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
398
+ el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
399
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
400
+ }
401
+ }}>
402
+ <Icon name={ins.icon} size={11}/>{ins.title}
403
+ </button>
404
+ ))}
405
+ </div>
406
+ )}
407
+
408
+ {/* TASKS view — flat list of every bullet across insights */}
409
+ {viewMode === 'tasks' && (() => {
410
+ const allTasks = filtered.flatMap(ins => ins.bullets.map((b, idx) => ({
411
+ ins, idx, text: b, status: taskStatusOf(ins.id, idx),
412
+ })));
413
+ const statusOrder = { open: 0, 'in-progress': 1, completed: 2, abandoned: 3 };
414
+ const sorted = [...allTasks].sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
415
+ if (sorted.length === 0) {
416
+ return (
417
+ <div className="empty-cell">
418
+ <Icon name="task" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
419
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>No tasks match this filter</div>
420
+ <button className="btn btn-ghost" onClick={() => setFilter('all')}>Show all</button>
421
  </div>
422
+ );
423
+ }
424
+ return (
425
+ <div className="insights-tasks-list">
426
+ {sorted.map(({ ins, idx, text, status }) => {
427
+ const meta = TASK_STATUSES.find(s => s.id === status);
428
+ const menuKey = `tv::${ins.id}::${idx}`;
429
+ const menuOpen = openStatusMenu === menuKey;
430
+ return (
431
+ <div key={menuKey} className={`insights-task-row task-${status}`}>
432
+ <button
433
+ className="insight-task-check"
434
+ style={{ color: meta.color, borderColor: meta.color + '60' }}
435
+ onClick={() => setOpenStatusMenu(menuOpen ? null : menuKey)}
436
+ title={`Status: ${meta.label}`}
437
+ >
438
+ {status === 'completed' && <Icon name="check" size={11}/>}
439
+ {status === 'in-progress' && <span className="task-check-dot"/>}
440
+ {status === 'abandoned' && <Icon name="x" size={10}/>}
441
+ </button>
442
+ <div className="insights-task-body">
443
+ <button
444
+ className="insights-task-section"
445
+ onClick={() => {
446
+ setViewMode('cards');
447
+ setTimeout(() => {
448
+ const el = document.getElementById(`insight-${ins.id}`);
449
+ if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
450
+ }, 60);
451
+ }}
452
+ title="Jump to this section"
453
+ >
454
+ <Icon name={ins.icon} size={10}/>{ins.title}
455
+ </button>
456
+ <span className="insights-task-text" dangerouslySetInnerHTML={{ __html: text }}/>
457
+ </div>
458
+ <button className="chip" onClick={() => sendToKanban(ins)} title="Send this section's open tasks to Kanban">
459
+ <Icon name="task" size={11}/>Kanban
460
+ </button>
461
+ {menuOpen && (
462
+ <div className="insight-status-menu" onMouseLeave={() => setOpenStatusMenu(null)}>
463
+ {TASK_STATUSES.map(s => (
464
+ <button key={s.id}
465
+ className={status === s.id ? 'active' : ''}
466
+ onClick={() => { setTaskStatus(ins.id, idx, s.id); setOpenStatusMenu(null); }}>
467
+ <Icon name={s.icon} size={11} style={{ color: s.color }}/>
468
+ {s.label}
469
+ </button>
470
+ ))}
471
+ </div>
472
+ )}
473
+ </div>
474
+ );
475
+ })}
476
  </div>
477
+ );
478
+ })()}
479
+
480
+ {/* CARDS view (default) */}
481
+ {viewMode === 'cards' && (filtered.length === 0 ? (
482
+ <div className="empty-cell">
483
+ <Icon name="search" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
484
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>No insights match this filter</div>
485
+ <button className="btn btn-ghost" onClick={() => setFilter('all')}>Show all</button>
486
+ </div>
487
+ ) : (
488
+ <div className="insight-grid">
489
+ {filtered.map(ins => {
490
+ const isExpanded = expanded.has(ins.id);
491
+ const isPinned = pinned.has(ins.id);
492
+ const tier = confidenceTier(ins.confidence);
493
+ const tint = CATEGORY_TINT[ins.category] || 'var(--canvas-surface-2)';
494
+ const fg = CATEGORY_FG[ins.category] || 'var(--canvas-accent)';
495
+ const rollup = insightRollup(ins);
496
+ return (
497
+ <div
498
+ key={ins.id}
499
+ id={`insight-${ins.id}`}
500
+ className={`insight ${isPinned ? 'is-pinned' : ''} tier-${tier} status-${rollup.state}`}
501
+ >
502
+ <div className="insight-head">
503
+ <div className="insight-icon" style={{ background: tint, color: fg }}>
504
+ <Icon name={ins.icon} size={16}/>
505
+ </div>
506
+ <div className="insight-title">{ins.title}</div>
507
+ <ConfidenceRing value={ins.confidence}/>
508
+ </div>
509
+
510
+ {/* Per-card progress (rolls up the bullet tasks) */}
511
+ {rollup.total > 0 && (
512
+ <div className="insight-progress">
513
+ <div className="insight-progress-meta">
514
+ <span className="insight-progress-count">{rollup.done}/{rollup.total} tasks</span>
515
+ {rollup.state === 'completed' && <span className="insight-progress-badge done">✓ Resolved</span>}
516
+ {rollup.state === 'abandoned' && <span className="insight-progress-badge abandoned">Abandoned</span>}
517
+ {rollup.state === 'in-progress' && <span className="insight-progress-badge inprog">In progress</span>}
518
+ </div>
519
+ <div className="insight-progress-bar">
520
+ <i className="ip-completed" style={{ width: `${(rollup.done / rollup.total) * 100}%` }}/>
521
+ {rollup.inProg > 0 && <i className="ip-inprogress" style={{ width: `${(rollup.inProg / rollup.total) * 100}%` }}/>}
522
+ {rollup.abandoned > 0 && <i className="ip-abandoned" style={{ width: `${(rollup.abandoned / rollup.total) * 100}%` }}/>}
523
+ </div>
524
+ </div>
525
+ )}
526
+
527
+ <div className="insight-body">
528
+ <div>{ins.summary}</div>
529
+ <ul className="insight-tasks">
530
+ {ins.bullets.map((b, idx) => {
531
+ const status = taskStatusOf(ins.id, idx);
532
+ const meta = TASK_STATUSES.find(s => s.id === status);
533
+ const menuOpen = openStatusMenu === `${ins.id}::${idx}`;
534
+ return (
535
+ <li key={idx} className={`insight-task task-${status}`}>
536
+ <button
537
+ className="insight-task-check"
538
+ style={{ color: meta.color, borderColor: meta.color + '60' }}
539
+ onClick={() => setOpenStatusMenu(menuOpen ? null : `${ins.id}::${idx}`)}
540
+ title={`Status: ${meta.label}`}
541
+ >
542
+ {status === 'completed' && <Icon name="check" size={10}/>}
543
+ {status === 'in-progress' && <span className="task-check-dot"/>}
544
+ {status === 'abandoned' && <Icon name="x" size={9}/>}
545
+ </button>
546
+ <span className="insight-task-text" dangerouslySetInnerHTML={{ __html: b }}/>
547
+ {menuOpen && (
548
+ <div className="insight-status-menu" onMouseLeave={() => setOpenStatusMenu(null)}>
549
+ {TASK_STATUSES.map(s => (
550
+ <button key={s.id}
551
+ className={status === s.id ? 'active' : ''}
552
+ onClick={() => { setTaskStatus(ins.id, idx, s.id); setOpenStatusMenu(null); }}>
553
+ <Icon name={s.icon} size={11} style={{ color: s.color }}/>
554
+ {s.label}
555
+ </button>
556
+ ))}
557
+ </div>
558
+ )}
559
+ </li>
560
+ );
561
+ })}
562
+ </ul>
563
+ </div>
564
+
565
+ {/* Detail panel — quotes from sources, only when expanded */}
566
+ {isExpanded && ins.quotes && (
567
+ <div className="insight-detail">
568
+ <div className="insight-detail-head">Source quotes · {ins.sources} {ins.sources === 1 ? 'source' : 'sources'}</div>
569
+ {ins.quotes.map((q, i) => (
570
+ <div key={i} className="insight-quote">{q}</div>
571
+ ))}
572
+ </div>
573
+ )}
574
+
575
+ <div className="insight-foot">
576
+ <span className="insight-foot-meta">
577
+ <Icon name="message" size={10}/> {ins.sources}
578
+ <span className="insight-dot"/>
579
+ updated {ins.updatedMinutesAgo}m ago
580
+ </span>
581
+ </div>
582
+
583
+ <div className="insight-actions">
584
+ <button className="chip" onClick={() => askFollowUp(ins)} title="Open this insight in a new chat session">
585
+ <Icon name="message" size={11}/>Ask follow-up
586
+ </button>
587
+ <button className="chip" onClick={() => sendToKanban(ins)} title="Add all open tasks from this insight to your Kanban (To Do)">
588
+ <Icon name="task" size={11}/>Add to Kanban
589
+ </button>
590
+ <button className="chip" onClick={() => toggleExpand(ins.id)}>
591
+ <Icon name="expand" size={11}/>{isExpanded ? 'Collapse' : 'Source quotes'}
592
+ </button>
593
+ <button className={`chip ${isPinned ? 'pinned' : ''}`} onClick={() => togglePin(ins.id)}>
594
+ <Icon name="pin" size={11}/>{isPinned ? 'Pinned' : 'Pin'}
595
+ </button>
596
+ </div>
597
+ </div>
598
+ );
599
+ })}
600
+ </div>
601
+ ))}
602
  </>
603
  );
604
  }
605
 
606
+ // Small SVG ring used for the confidence indicator
607
+ function ConfidenceRing({ value }) {
608
+ const r = 14;
609
+ const c = 2 * Math.PI * r;
610
+ const dash = c * (1 - value / 100);
611
+ const tier = confidenceTier(value);
612
+ const color = tier === 'high' ? '#10B981' : tier === 'med' ? '#F59E0B' : '#DC2626';
613
+ return (
614
+ <div className={`confidence-ring tier-${tier}`} title={`${value}% confidence (${tier})`}>
615
+ <svg width="32" height="32" viewBox="0 0 32 32">
616
+ <circle cx="16" cy="16" r={r} fill="none" stroke="var(--canvas-surface-3)" strokeWidth="3"/>
617
+ <circle cx="16" cy="16" r={r} fill="none" stroke={color} strokeWidth="3"
618
+ strokeDasharray={c} strokeDashoffset={dash} strokeLinecap="round"
619
+ transform="rotate(-90 16 16)"/>
620
+ </svg>
621
+ <span style={{ color }}>{value}</span>
622
+ </div>
623
+ );
624
+ }
625
+
626
  function PresetPicker({ onPick }) {
627
  return (
628
  <div className="canvas-presets">
 
879
  openModal('global-search', { states: widgetStates });
880
  }, [openModal, widgetStates]);
881
 
882
+ // Critic widgets dispatch `canvas-open-in-chat` when the user wants real LLM history.
883
+ useEffect(() => {
884
+ const handler = () => onNavigateToChat && onNavigateToChat();
885
+ window.addEventListener('canvas-open-in-chat', handler);
886
+ return () => window.removeEventListener('canvas-open-in-chat', handler);
887
+ }, [onNavigateToChat]);
888
+
889
  // Esc closes modal, ⌘K opens command palette, ⌘/ opens global content search,
890
  // ? opens the welcome tour for help (matches the icon in the topbar).
891
  useEffect(() => {
 
938
  return order.map(c => groups[c]).filter(Boolean);
939
  }, [layout]);
940
 
941
+ // Insights: list of sections — Daniel's feedback said sidebar should show sections here
942
+ const insightSections = useMemo(() => {
943
+ let taskMap = {};
944
+ try { taskMap = JSON.parse(localStorage.getItem(TASK_STATUS_KEY) || '{}'); } catch { /* ignore */ }
945
+ return INSIGHTS.map(ins => {
946
+ const states = ins.bullets.map((_, idx) => taskMap[taskKey(ins.id, idx)] || 'open');
947
+ const done = states.filter(s => s === 'completed').length;
948
+ return {
949
+ id: ins.id,
950
+ name: ins.title,
951
+ icon: ins.icon,
952
+ category: ins.category,
953
+ confidence: ins.confidence,
954
+ taskCount: ins.bullets.length,
955
+ doneCount: done,
956
+ onClick: () => flashScrollTo(`#insight-${ins.id}`),
957
+ };
958
+ });
959
+ }, [view, layout, widgetStates]); // eslint-disable-line react-hooks/exhaustive-deps
960
+
961
  // Deliverables: list of projects with sections + history actions
962
  const deliverableProjects = useMemo(() => {
963
  try {
 
1012
  canvasSubview={view}
1013
  widgetGroups={widgetGroups}
1014
  deliverableProjects={deliverableProjects}
1015
+ insightSections={insightSections}
1016
  />
1017
  <div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
1018
  <div className="canvas-app-shell">
 
1026
  <button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
1027
  <HelpCircle size={18}/>
1028
  </button>
1029
+ <button className="icon-btn" onClick={openGlobalSearch} title={`Search canvas content (${MOD}+/)`}>
1030
  <Icon name="search" size={16}/>
1031
  </button>
1032
+ <button className="icon-btn" onClick={openCommandPalette} title={`Commands (${MOD}+K)`}>
1033
  <Icon name="zap" size={16}/>
1034
  </button>
1035
  </AppHeader>
1036
  <div className="canvas-content">
1037
+ {view === 'insights' && <InsightsView widgetStates={widgetStates} setWidgetStates={setWidgetStates} onNavigateToChat={onNavigateToChat}/>}
1038
  {view === 'workspace' && <WorkspaceView openModal={openModal} layout={layout} setLayout={setLayout} widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
1039
  {view === 'deliverables' && <DeliverablesView allStates={widgetStates}/>}
1040
  </div>
 
1062
  onMouseEnter={() => setVisible(true)}
1063
  onMouseLeave={() => setVisible(false)}
1064
  >
1065
+ <span><kbd>{MOD}</kbd><kbd>K</kbd> commands</span>
1066
+ <span><kbd>{MOD}</kbd><kbd>/</kbd> search</span>
1067
  <span><kbd>?</kbd> help</span>
1068
  </div>
1069
  );
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
@@ -398,6 +398,483 @@
398
  box-shadow: 0 0 8px var(--canvas-ok);
399
  }
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  /* Insights */
402
  .canvas-page-with-sidebar .insight-grid {
403
  display: grid;
@@ -590,6 +1067,10 @@
590
  background: rgba(16, 185, 129, 0.15);
591
  color: #10B981;
592
  }
 
 
 
 
593
  .canvas-page-with-sidebar .widget-actions { display: flex; gap: 1px; opacity: 0; transition: opacity .12s; }
594
  .canvas-page-with-sidebar .widget:hover .widget-actions { opacity: 1; }
595
  .canvas-page-with-sidebar .widget-actions .icon-btn { width: 24px; height: 24px; }
@@ -2454,6 +2935,87 @@ body[data-canvas-theme="light"] .canvas-modal-backdrop .critic-meter .bar { back
2454
  background: rgba(0, 0, 0, 0.04);
2455
  }
2456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2457
  /* "Coming soon" stub widget — roadmap-preview card, not a dead end */
2458
  .canvas-page-with-sidebar .widget-stub {
2459
  flex: 1;
 
398
  box-shadow: 0 0 8px var(--canvas-ok);
399
  }
400
 
401
+ /* ----- Insights view ----- */
402
+ .canvas-page-with-sidebar .insights-stats {
403
+ display: flex;
404
+ gap: 18px;
405
+ align-items: center;
406
+ padding: 14px 18px;
407
+ background: var(--canvas-surface);
408
+ border: 1px solid var(--canvas-border);
409
+ border-radius: 10px;
410
+ margin-bottom: 14px;
411
+ flex-wrap: wrap;
412
+ }
413
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .insights-stats {
414
+ background: #ffffff;
415
+ border-color: rgba(15, 15, 15, 0.06);
416
+ }
417
+ .canvas-page-with-sidebar .insights-stat {
418
+ display: flex;
419
+ flex-direction: column;
420
+ align-items: flex-start;
421
+ gap: 1px;
422
+ min-width: 70px;
423
+ }
424
+ .canvas-page-with-sidebar .insights-stat-value {
425
+ font-family: var(--canvas-mono);
426
+ font-size: 22px;
427
+ font-weight: 700;
428
+ color: var(--canvas-text);
429
+ letter-spacing: -0.02em;
430
+ line-height: 1;
431
+ }
432
+ .canvas-page-with-sidebar .insights-stat-label {
433
+ font-size: 11px;
434
+ color: var(--canvas-text-3);
435
+ text-transform: uppercase;
436
+ letter-spacing: 0.06em;
437
+ font-weight: 500;
438
+ }
439
+ .canvas-page-with-sidebar .insights-stat-update {
440
+ font-size: 11px;
441
+ color: var(--canvas-text-3);
442
+ font-family: var(--canvas-mono);
443
+ display: inline-flex;
444
+ align-items: center;
445
+ gap: 6px;
446
+ }
447
+
448
+ .canvas-page-with-sidebar .insights-filters {
449
+ display: flex;
450
+ gap: 12px;
451
+ align-items: center;
452
+ margin-bottom: 14px;
453
+ flex-wrap: wrap;
454
+ }
455
+
456
+ .canvas-page-with-sidebar .insights-pinned-strip {
457
+ display: flex;
458
+ align-items: center;
459
+ gap: 8px;
460
+ padding: 10px 14px;
461
+ margin-bottom: 14px;
462
+ background: var(--canvas-accent-glow);
463
+ border: 1px solid rgba(99, 102, 241, 0.18);
464
+ border-radius: 8px;
465
+ flex-wrap: wrap;
466
+ }
467
+ .canvas-page-with-sidebar .insights-pinned-pill {
468
+ display: inline-flex;
469
+ align-items: center;
470
+ gap: 5px;
471
+ padding: 4px 10px;
472
+ background: var(--canvas-surface);
473
+ border: 1px solid var(--canvas-border);
474
+ border-radius: 999px;
475
+ font-family: inherit;
476
+ font-size: 12px;
477
+ color: var(--canvas-text);
478
+ cursor: pointer;
479
+ transition: all .12s;
480
+ }
481
+ .canvas-page-with-sidebar .insights-pinned-pill:hover {
482
+ border-color: var(--canvas-accent);
483
+ color: var(--canvas-accent);
484
+ }
485
+
486
+ /* Cards vs Tasks view toggle on the Insights view */
487
+ .canvas-page-with-sidebar .insights-view-toggle {
488
+ display: inline-flex;
489
+ align-items: center;
490
+ gap: 2px;
491
+ margin-bottom: 12px;
492
+ background: var(--canvas-surface);
493
+ border: 1px solid var(--canvas-border);
494
+ border-radius: 7px;
495
+ padding: 3px;
496
+ }
497
+ .canvas-page-with-sidebar .insights-view-toggle button {
498
+ background: transparent;
499
+ border: none;
500
+ padding: 5px 12px;
501
+ border-radius: 5px;
502
+ font-family: inherit;
503
+ font-size: 12px;
504
+ color: var(--canvas-text-3);
505
+ display: inline-flex;
506
+ align-items: center;
507
+ gap: 6px;
508
+ cursor: pointer;
509
+ transition: background .12s, color .12s;
510
+ }
511
+ .canvas-page-with-sidebar .insights-view-toggle button:hover { color: var(--canvas-text); }
512
+ .canvas-page-with-sidebar .insights-view-toggle button.active {
513
+ background: var(--canvas-surface-3);
514
+ color: var(--canvas-text);
515
+ box-shadow: inset 0 0 0 1px var(--canvas-border-2);
516
+ }
517
+
518
+ /* Flat task list view */
519
+ .canvas-page-with-sidebar .insights-tasks-list {
520
+ display: flex;
521
+ flex-direction: column;
522
+ gap: 4px;
523
+ }
524
+ .canvas-page-with-sidebar .insights-task-row {
525
+ display: flex;
526
+ align-items: flex-start;
527
+ gap: 10px;
528
+ padding: 10px 12px;
529
+ background: var(--canvas-surface);
530
+ border: 1px solid var(--canvas-border);
531
+ border-radius: 7px;
532
+ position: relative;
533
+ transition: background .12s, border-color .12s;
534
+ }
535
+ .canvas-page-with-sidebar .insights-task-row:hover {
536
+ border-color: var(--canvas-border-2);
537
+ }
538
+ .canvas-page-with-sidebar .insights-task-row.task-completed { opacity: 0.7; }
539
+ .canvas-page-with-sidebar .insights-task-row.task-abandoned { opacity: 0.5; }
540
+ .canvas-page-with-sidebar .insights-task-row.task-completed:hover,
541
+ .canvas-page-with-sidebar .insights-task-row.task-abandoned:hover { opacity: 0.95; }
542
+ .canvas-page-with-sidebar .insights-task-row .insight-task-check { margin-top: 4px; }
543
+ .canvas-page-with-sidebar .insights-task-body {
544
+ flex: 1;
545
+ min-width: 0;
546
+ display: flex;
547
+ flex-direction: column;
548
+ gap: 4px;
549
+ }
550
+ .canvas-page-with-sidebar .insights-task-section {
551
+ background: transparent;
552
+ border: none;
553
+ color: var(--canvas-text-3);
554
+ font-family: var(--canvas-mono);
555
+ font-size: 10.5px;
556
+ text-transform: uppercase;
557
+ letter-spacing: 0.06em;
558
+ padding: 0;
559
+ display: inline-flex;
560
+ align-items: center;
561
+ gap: 5px;
562
+ cursor: pointer;
563
+ align-self: flex-start;
564
+ }
565
+ .canvas-page-with-sidebar .insights-task-section:hover { color: var(--canvas-accent); }
566
+ .canvas-page-with-sidebar .insights-task-text {
567
+ font-size: 13px;
568
+ color: var(--canvas-text);
569
+ line-height: 1.5;
570
+ }
571
+ .canvas-page-with-sidebar .insights-task-row.task-completed .insights-task-text,
572
+ .canvas-page-with-sidebar .insights-task-row.task-abandoned .insights-task-text {
573
+ text-decoration: line-through;
574
+ color: var(--canvas-text-3);
575
+ }
576
+ .canvas-page-with-sidebar .insights-task-text strong { color: var(--canvas-text); }
577
+
578
+ /* Per-card progress bar — replaces the old single status pill */
579
+ .canvas-page-with-sidebar .insight-progress {
580
+ display: flex;
581
+ flex-direction: column;
582
+ gap: 4px;
583
+ padding: 0 0 4px;
584
+ }
585
+ .canvas-page-with-sidebar .insight-progress-meta {
586
+ display: flex;
587
+ align-items: center;
588
+ gap: 8px;
589
+ font-family: var(--canvas-mono);
590
+ font-size: 10.5px;
591
+ color: var(--canvas-text-3);
592
+ }
593
+ .canvas-page-with-sidebar .insight-progress-count { font-weight: 600; }
594
+ .canvas-page-with-sidebar .insight-progress-badge {
595
+ font-family: var(--canvas-mono);
596
+ font-size: 9px;
597
+ text-transform: uppercase;
598
+ letter-spacing: 0.08em;
599
+ font-weight: 700;
600
+ padding: 1px 6px;
601
+ border-radius: 3px;
602
+ }
603
+ .canvas-page-with-sidebar .insight-progress-badge.done { background: rgba(16,185,129,0.15); color: #10B981; }
604
+ .canvas-page-with-sidebar .insight-progress-badge.inprog { background: rgba(59,130,246,0.15); color: #3B82F6; }
605
+ .canvas-page-with-sidebar .insight-progress-badge.abandoned { background: var(--canvas-surface-2); color: var(--canvas-text-4); }
606
+
607
+ .canvas-page-with-sidebar .insight-progress-bar {
608
+ height: 5px;
609
+ background: var(--canvas-surface-2);
610
+ border-radius: 3px;
611
+ overflow: hidden;
612
+ display: flex;
613
+ }
614
+ .canvas-page-with-sidebar .insight-progress-bar > i {
615
+ display: block;
616
+ height: 100%;
617
+ transition: width .25s;
618
+ }
619
+ .canvas-page-with-sidebar .insight-progress-bar .ip-completed { background: #10B981; }
620
+ .canvas-page-with-sidebar .insight-progress-bar .ip-inprogress { background: #3B82F6; }
621
+ .canvas-page-with-sidebar .insight-progress-bar .ip-abandoned { background: var(--canvas-text-4); }
622
+
623
+ /* Per-bullet tasks inside the body */
624
+ .canvas-page-with-sidebar .insight-tasks {
625
+ list-style: none;
626
+ padding: 0;
627
+ margin: 6px 0 0;
628
+ display: flex;
629
+ flex-direction: column;
630
+ gap: 6px;
631
+ }
632
+ .canvas-page-with-sidebar .insight-task {
633
+ display: flex;
634
+ align-items: flex-start;
635
+ gap: 8px;
636
+ padding: 2px 0;
637
+ font-size: 13px;
638
+ color: var(--canvas-text-2);
639
+ line-height: 1.5;
640
+ position: relative;
641
+ }
642
+ .canvas-page-with-sidebar .insight-task.task-completed .insight-task-text {
643
+ text-decoration: line-through;
644
+ color: var(--canvas-text-3);
645
+ opacity: 0.7;
646
+ }
647
+ .canvas-page-with-sidebar .insight-task.task-abandoned .insight-task-text {
648
+ text-decoration: line-through;
649
+ color: var(--canvas-text-4);
650
+ opacity: 0.5;
651
+ }
652
+ .canvas-page-with-sidebar .insight-task.task-in-progress .insight-task-text {
653
+ color: var(--canvas-text);
654
+ font-weight: 500;
655
+ }
656
+ .canvas-page-with-sidebar .insight-task-text { flex: 1; }
657
+ .canvas-page-with-sidebar .insight-task-text strong { color: var(--canvas-text); }
658
+ .canvas-page-with-sidebar .insight-task-check {
659
+ flex-shrink: 0;
660
+ width: 18px;
661
+ height: 18px;
662
+ border-radius: 4px;
663
+ border: 1.5px solid currentColor;
664
+ background: transparent;
665
+ cursor: pointer;
666
+ display: grid;
667
+ place-items: center;
668
+ margin-top: 2px;
669
+ transition: background .12s;
670
+ }
671
+ .canvas-page-with-sidebar .insight-task-check:hover {
672
+ background: var(--canvas-surface-2);
673
+ }
674
+ .canvas-page-with-sidebar .insight-task.task-completed .insight-task-check {
675
+ background: #10B981;
676
+ color: #fff;
677
+ }
678
+ .canvas-page-with-sidebar .insight-task.task-abandoned .insight-task-check {
679
+ background: var(--canvas-text-4);
680
+ color: #fff;
681
+ }
682
+ .canvas-page-with-sidebar .task-check-dot {
683
+ width: 6px;
684
+ height: 6px;
685
+ border-radius: 50%;
686
+ background: currentColor;
687
+ }
688
+
689
+ /* Sidebar row for a fully-done section gets struck through */
690
+ .sidebar .csm-row-done .csm-row-label {
691
+ text-decoration: line-through;
692
+ color: var(--text-tertiary, #9CA3AF);
693
+ }
694
+ .sidebar .csm-row-done .csm-row-meta {
695
+ color: #10B981;
696
+ font-weight: 600;
697
+ }
698
+
699
+ /* Insight status pill + menu */
700
+ .canvas-page-with-sidebar .insight-status-wrap {
701
+ position: relative;
702
+ }
703
+ .canvas-page-with-sidebar .insight-status-pill {
704
+ display: inline-flex;
705
+ align-items: center;
706
+ gap: 5px;
707
+ padding: 3px 9px;
708
+ border-radius: 999px;
709
+ border: 1px solid transparent;
710
+ background: transparent;
711
+ font-family: inherit;
712
+ font-size: 10.5px;
713
+ font-weight: 600;
714
+ text-transform: uppercase;
715
+ letter-spacing: 0.04em;
716
+ cursor: pointer;
717
+ transition: background .12s;
718
+ }
719
+ .canvas-page-with-sidebar .insight-status-pill:hover {
720
+ background: var(--canvas-surface-2);
721
+ }
722
+ .canvas-page-with-sidebar .insight-status-menu {
723
+ position: absolute;
724
+ top: calc(100% + 4px);
725
+ right: 0;
726
+ z-index: 5;
727
+ background: var(--canvas-surface);
728
+ border: 1px solid var(--canvas-border-2);
729
+ border-radius: 7px;
730
+ box-shadow: var(--canvas-shadow-lg);
731
+ padding: 4px;
732
+ min-width: 150px;
733
+ display: flex;
734
+ flex-direction: column;
735
+ }
736
+ .canvas-page-with-sidebar .insight-status-menu button {
737
+ display: flex;
738
+ align-items: center;
739
+ gap: 8px;
740
+ background: transparent;
741
+ border: none;
742
+ padding: 6px 10px;
743
+ border-radius: 4px;
744
+ font-family: inherit;
745
+ font-size: 12px;
746
+ color: var(--canvas-text);
747
+ cursor: pointer;
748
+ text-align: left;
749
+ }
750
+ .canvas-page-with-sidebar .insight-status-menu button:hover {
751
+ background: var(--canvas-surface-2);
752
+ }
753
+ .canvas-page-with-sidebar .insight-status-menu button.active {
754
+ background: var(--canvas-accent-glow);
755
+ color: var(--canvas-accent);
756
+ font-weight: 600;
757
+ }
758
+
759
+ /* Card-level status mutes the body when completed/abandoned */
760
+ .canvas-page-with-sidebar .insight.status-completed .insight-body,
761
+ .canvas-page-with-sidebar .insight.status-completed .insight-foot {
762
+ opacity: 0.65;
763
+ }
764
+ .canvas-page-with-sidebar .insight.status-abandoned {
765
+ opacity: 0.5;
766
+ }
767
+ .canvas-page-with-sidebar .insight.status-abandoned:hover { opacity: 0.85; }
768
+
769
+ /* Progress bar in stats — segmented for status mix */
770
+ .canvas-page-with-sidebar .insights-stat-progress {
771
+ flex: 1;
772
+ min-width: 180px;
773
+ max-width: 280px;
774
+ gap: 4px;
775
+ }
776
+ .canvas-page-with-sidebar .insights-progress-bar {
777
+ height: 6px;
778
+ width: 100%;
779
+ background: var(--canvas-surface-2);
780
+ border-radius: 3px;
781
+ overflow: hidden;
782
+ display: flex;
783
+ }
784
+ .canvas-page-with-sidebar .insights-progress-bar > i {
785
+ display: block;
786
+ height: 100%;
787
+ transition: width .3s;
788
+ }
789
+ .canvas-page-with-sidebar .insights-progress-bar .ip-completed { background: #10B981; }
790
+ .canvas-page-with-sidebar .insights-progress-bar .ip-inprogress { background: #3B82F6; }
791
+ .canvas-page-with-sidebar .insights-progress-bar .ip-abandoned { background: var(--canvas-text-4); }
792
+
793
+ /* Confidence ring */
794
+ .canvas-page-with-sidebar .confidence-ring {
795
+ position: relative;
796
+ width: 32px;
797
+ height: 32px;
798
+ display: grid;
799
+ place-items: center;
800
+ flex-shrink: 0;
801
+ }
802
+ .canvas-page-with-sidebar .confidence-ring span {
803
+ position: absolute;
804
+ font-family: var(--canvas-mono);
805
+ font-size: 9.5px;
806
+ font-weight: 700;
807
+ }
808
+
809
+ /* Insight card refinements */
810
+ .canvas-page-with-sidebar .insight {
811
+ transition: border-color .15s, transform .15s, box-shadow .15s;
812
+ }
813
+ .canvas-page-with-sidebar .insight:hover {
814
+ transform: translateY(-1px);
815
+ box-shadow: 0 6px 18px rgba(0,0,0,0.08);
816
+ }
817
+ .canvas-page-with-sidebar .insight.is-pinned {
818
+ border-color: var(--canvas-accent);
819
+ background: var(--canvas-surface);
820
+ box-shadow: 0 0 0 1px var(--canvas-accent-glow);
821
+ }
822
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .insight.is-pinned {
823
+ background: #ffffff;
824
+ }
825
+
826
+ /* Insight footer (source count + updated time) */
827
+ .canvas-page-with-sidebar .insight-foot {
828
+ display: flex;
829
+ justify-content: space-between;
830
+ align-items: center;
831
+ padding: 6px 0 0;
832
+ font-size: 11px;
833
+ color: var(--canvas-text-3);
834
+ }
835
+ .canvas-page-with-sidebar .insight-foot-meta {
836
+ display: inline-flex;
837
+ align-items: center;
838
+ gap: 6px;
839
+ font-family: var(--canvas-mono);
840
+ }
841
+ .canvas-page-with-sidebar .insight-dot {
842
+ width: 3px;
843
+ height: 3px;
844
+ border-radius: 50%;
845
+ background: var(--canvas-text-4);
846
+ }
847
+
848
+ /* Expanded source-quotes panel */
849
+ .canvas-page-with-sidebar .insight-detail {
850
+ margin-top: 8px;
851
+ padding: 10px 12px;
852
+ background: var(--canvas-bg-2);
853
+ border-left: 2px solid var(--canvas-accent);
854
+ border-radius: 0 6px 6px 0;
855
+ display: flex;
856
+ flex-direction: column;
857
+ gap: 6px;
858
+ animation: canvas-view-in 180ms var(--canvas-ease);
859
+ }
860
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .insight-detail {
861
+ background: rgba(99, 102, 241, 0.04);
862
+ }
863
+ .canvas-page-with-sidebar .insight-detail-head {
864
+ font-size: 10.5px;
865
+ font-family: var(--canvas-mono);
866
+ text-transform: uppercase;
867
+ letter-spacing: 0.06em;
868
+ color: var(--canvas-accent);
869
+ font-weight: 600;
870
+ }
871
+ .canvas-page-with-sidebar .insight-quote {
872
+ font-size: 12.5px;
873
+ color: var(--canvas-text-2);
874
+ line-height: 1.5;
875
+ font-style: italic;
876
+ }
877
+
878
  /* Insights */
879
  .canvas-page-with-sidebar .insight-grid {
880
  display: grid;
 
1067
  background: rgba(16, 185, 129, 0.15);
1068
  color: #10B981;
1069
  }
1070
+ .canvas-modal-backdrop .widget-tag-chat {
1071
+ background: rgba(59, 130, 246, 0.15);
1072
+ color: #3B82F6;
1073
+ }
1074
  .canvas-page-with-sidebar .widget-actions { display: flex; gap: 1px; opacity: 0; transition: opacity .12s; }
1075
  .canvas-page-with-sidebar .widget:hover .widget-actions { opacity: 1; }
1076
  .canvas-page-with-sidebar .widget-actions .icon-btn { width: 24px; height: 24px; }
 
2935
  background: rgba(0, 0, 0, 0.04);
2936
  }
2937
 
2938
+ /* PhD Journey milestone rows */
2939
+ .canvas-page-with-sidebar .phd-milestone {
2940
+ display: flex;
2941
+ align-items: flex-start;
2942
+ gap: 10px;
2943
+ padding: 8px 4px;
2944
+ border-radius: 6px;
2945
+ cursor: pointer;
2946
+ transition: background .12s;
2947
+ }
2948
+ .canvas-page-with-sidebar .phd-milestone:hover { background: var(--canvas-surface-2); }
2949
+ .canvas-page-with-sidebar .phd-milestone-check {
2950
+ flex-shrink: 0;
2951
+ width: 18px;
2952
+ height: 18px;
2953
+ border-radius: 4px;
2954
+ border: 1.5px solid var(--canvas-text-4);
2955
+ background: transparent;
2956
+ cursor: pointer;
2957
+ display: grid;
2958
+ place-items: center;
2959
+ margin-top: 2px;
2960
+ color: #fff;
2961
+ transition: background .12s, border-color .12s;
2962
+ }
2963
+ .canvas-page-with-sidebar .phd-milestone.milestone-in-progress .phd-milestone-check {
2964
+ background: #3B82F6;
2965
+ border-color: #3B82F6;
2966
+ }
2967
+ .canvas-page-with-sidebar .phd-milestone.milestone-completed .phd-milestone-check {
2968
+ background: #10B981;
2969
+ border-color: #10B981;
2970
+ }
2971
+ .canvas-page-with-sidebar .phd-milestone-body { flex: 1; min-width: 0; }
2972
+ .canvas-page-with-sidebar .phd-milestone-label {
2973
+ font-size: 13px;
2974
+ color: var(--canvas-text);
2975
+ font-weight: 500;
2976
+ line-height: 1.4;
2977
+ }
2978
+ .canvas-page-with-sidebar .phd-milestone.milestone-completed .phd-milestone-label {
2979
+ text-decoration: line-through;
2980
+ color: var(--canvas-text-3);
2981
+ }
2982
+ .canvas-page-with-sidebar .phd-milestone-hint {
2983
+ font-size: 11.5px;
2984
+ color: var(--canvas-text-4);
2985
+ font-style: italic;
2986
+ margin-top: 2px;
2987
+ }
2988
+ .canvas-page-with-sidebar .phd-milestone-note {
2989
+ font-size: 11.5px;
2990
+ color: var(--canvas-text-2);
2991
+ margin-top: 2px;
2992
+ }
2993
+
2994
+ /* PhD Resources link rows */
2995
+ .canvas-page-with-sidebar .phd-resource-link {
2996
+ display: flex;
2997
+ flex-direction: column;
2998
+ gap: 1px;
2999
+ padding: 6px 8px;
3000
+ background: transparent;
3001
+ border-radius: 5px;
3002
+ text-decoration: none;
3003
+ color: var(--canvas-text);
3004
+ transition: background .12s;
3005
+ }
3006
+ .canvas-page-with-sidebar .phd-resource-link:hover {
3007
+ background: var(--canvas-surface-2);
3008
+ }
3009
+ .canvas-page-with-sidebar .phd-resource-name {
3010
+ font-size: 12.5px;
3011
+ font-weight: 600;
3012
+ color: var(--canvas-accent);
3013
+ }
3014
+ .canvas-page-with-sidebar .phd-resource-desc {
3015
+ font-size: 11px;
3016
+ color: var(--canvas-text-3);
3017
+ }
3018
+
3019
  /* "Coming soon" stub widget — roadmap-preview card, not a dead end */
3020
  .canvas-page-with-sidebar .widget-stub {
3021
  flex: 1;