Neon:ryan commited on
Commit
03d90a3
·
1 Parent(s): e6ba1a2
phd-advisor-frontend/src/components/AppHeader.js CHANGED
@@ -28,9 +28,13 @@ const AppHeader = ({
28
  if (onNavigateToCanvas) onNavigateToCanvas(view);
29
  };
30
 
 
 
31
  const isOnHome = currentPage === 'home';
32
  const isOnChat = currentPage === 'chat';
33
- const isOnCanvas = currentPage === 'canvas';
 
 
34
 
35
  return (
36
  <header className="floating-header app-header">
@@ -62,8 +66,9 @@ const AppHeader = ({
62
 
63
  <div className="canvas-tabs chat-view-tabs">
64
  <button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
65
- <button className={`tab ${isOnCanvas ? 'active' : ''}`} onClick={() => goToCanvas('insights')}>Insights</button>
66
- <button className={`tab ${isOnCanvas ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
 
67
  </div>
68
 
69
  <div className="header-right">
 
28
  if (onNavigateToCanvas) onNavigateToCanvas(view);
29
  };
30
 
31
+ // Accept either 'canvas' (all canvas tabs highlight equally) or a more specific
32
+ // 'canvas-<subview>' from CanvasPage so only the active one highlights.
33
  const isOnHome = currentPage === 'home';
34
  const isOnChat = currentPage === 'chat';
35
+ const isOnCanvas = currentPage === 'canvas' || currentPage.startsWith('canvas-');
36
+ const canvasSub = currentPage.startsWith('canvas-') ? currentPage.slice(7) : null;
37
+ const tabActive = (sub) => isOnCanvas && (canvasSub === null ? false : canvasSub === sub);
38
 
39
  return (
40
  <header className="floating-header app-header">
 
66
 
67
  <div className="canvas-tabs chat-view-tabs">
68
  <button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
69
+ <button className={`tab ${tabActive('insights') ? 'active' : ''}`} onClick={() => goToCanvas('insights')}>Insights</button>
70
+ <button className={`tab ${tabActive('workspace') ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
71
+ <button className={`tab ${tabActive('deliverables') ? 'active' : ''}`} onClick={() => goToCanvas('deliverables')}>Deliverables</button>
72
  </div>
73
 
74
  <div className="header-right">
phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Deliverables view — pick a template, fill in structured sections, export.
2
+ // Static "missing" checks run locally. AI checks are stubbed (need LLM endpoint).
3
+ import React, { useState, useMemo, useEffect, useRef } from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+ import Icon from './CanvasIcon';
7
+
8
+ const fireToast = (msg, kind = 'success') =>
9
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
10
+
11
+ const STORE_KEY = 'canvas-deliverables-v1';
12
+
13
+ // ---------- Template definitions ----------
14
+ const TEMPLATES = [
15
+ {
16
+ id: 'research-paper',
17
+ name: 'Research Paper',
18
+ desc: 'Abstract → Introduction → Methods → Results → Discussion → References',
19
+ icon: 'book',
20
+ mode: 'paper',
21
+ sections: [
22
+ { id: 'abstract', name: 'Abstract', target: 250, hint: 'One paragraph: question, method, finding, implication.', checks: ['hasNumber', 'hasFinding'] },
23
+ { id: 'intro', name: 'Introduction', target: 1000, hint: 'Frame the problem, state the gap, name your contribution.', checks: ['hasGap', 'hasCitation'] },
24
+ { id: 'methods', name: 'Methods', target: 800, hint: 'Reproducibility-first: subjects, materials, procedure, analysis.', checks: ['hasCitation', 'hasNumber'] },
25
+ { id: 'results', name: 'Results', target: 800, hint: 'Lead with the effect. Numbers + figure refs. No interpretation here.', checks: ['hasNumber', 'hasFigure'] },
26
+ { id: 'discussion', name: 'Discussion', target: 1000, hint: 'What it means, what it doesn\'t, limits, future work.', checks: ['hasLimit', 'hasCitation'] },
27
+ { id: 'refs', name: 'References', target: 0, hint: 'Bibliography list. Drop @keys here from the Bibliography widget.', checks: [] },
28
+ ],
29
+ },
30
+ {
31
+ id: 'nsf-grfp',
32
+ name: 'NSF GRFP',
33
+ desc: 'Personal Statement (3 pages) + Research Plan (2 pages)',
34
+ icon: 'award',
35
+ mode: 'document',
36
+ sections: [
37
+ { id: 'personal', name: 'Personal Statement', target: 1500, hint: 'Background, experiences, broader impacts. Write as a story.', checks: ['hasBroaderImpacts'] },
38
+ { id: 'research', name: 'Research Plan', target: 1000, hint: 'Question, hypothesis, approach, intellectual merit.', checks: ['hasHypothesis', 'hasMerit'] },
39
+ ],
40
+ },
41
+ {
42
+ id: 'conference-abstract',
43
+ name: 'Conference Abstract',
44
+ desc: 'Single section, 250 words. Lead with the result.',
45
+ icon: 'send',
46
+ mode: 'document',
47
+ sections: [
48
+ { id: 'abs', name: 'Abstract', target: 250, hint: 'One paragraph. Lead with finding, end with implication.', checks: ['hasFinding', 'hasNumber'] },
49
+ ],
50
+ },
51
+ {
52
+ id: 'defense-slides',
53
+ name: 'Defense Slides',
54
+ desc: 'Title → Outline → Background → Question → Methods → Results → Discussion → Q&A',
55
+ icon: 'kanban',
56
+ mode: 'slides',
57
+ sections: [
58
+ { id: 'title', name: 'Title slide', target: 30, hint: 'Title, your name, advisor, date.', checks: [] },
59
+ { id: 'outline', name: 'Outline', target: 60, hint: '5–7 bullet points covering the talk arc.', checks: [] },
60
+ { id: 'background', name: 'Background', target: 200, hint: 'Just enough context to follow the question.', checks: ['hasCitation'] },
61
+ { id: 'question', name: 'Question', target: 80, hint: 'Single sentence, falsifiable.', checks: [] },
62
+ { id: 'methods', name: 'Methods', target: 200, hint: 'High-level. Save details for backup slides.', checks: ['hasNumber'] },
63
+ { id: 'results', name: 'Results', target: 300, hint: 'One slide per finding. Lead with the headline.', checks: ['hasFigure', 'hasNumber'] },
64
+ { id: 'discussion', name: 'Discussion', target: 200, hint: 'Implications + limits + next steps.', checks: ['hasLimit'] },
65
+ { id: 'qa', name: 'Anticipated Q&A', target: 300, hint: 'Hardest 5 questions and your answers.', checks: [] },
66
+ ],
67
+ },
68
+ {
69
+ id: 'thesis-chapter',
70
+ name: 'Thesis Chapter',
71
+ desc: 'Standard chapter scaffolding for a dissertation.',
72
+ icon: 'book',
73
+ mode: 'paper',
74
+ sections: [
75
+ { id: 'overview', name: 'Overview', target: 200, hint: 'What this chapter does and why it\'s here.', checks: [] },
76
+ { id: 'background', name: 'Background', target: 1500, hint: 'Lit review focused on this chapter\'s question.', checks: ['hasCitation'] },
77
+ { id: 'methods', name: 'Methods', target: 1500, hint: 'Reproducibility-first.', checks: ['hasCitation', 'hasNumber'] },
78
+ { id: 'results', name: 'Results', target: 2000, hint: 'Findings + figures.', checks: ['hasFigure', 'hasNumber'] },
79
+ { id: 'discussion', name: 'Discussion', target: 1500, hint: 'How it fits the larger thesis.', checks: ['hasLimit'] },
80
+ ],
81
+ },
82
+ ];
83
+
84
+ // ---------- Static check rules ----------
85
+ const CHECKS = {
86
+ hasNumber: { test: (s) => /\d/.test(s), label: 'Mentions at least one number' },
87
+ hasCitation: { test: (s) => /@\w+/.test(s), label: 'Cites at least one source (@key)' },
88
+ hasFinding: { test: (s) => /\b(we (find|show|report|demonstrate)|finding|result)/i.test(s), label: 'States a finding' },
89
+ hasGap: { test: (s) => /\b(gap|lack|missing|unknown|unclear|despite)/i.test(s), label: 'Names a gap' },
90
+ hasFigure: { test: (s) => /\b(fig(ure)?|table)\.?\s*\d/i.test(s), label: 'References a figure or table' },
91
+ hasLimit: { test: (s) => /\b(limit|caveat|however|future work|did not|cannot)/i.test(s), label: 'Acknowledges a limit' },
92
+ hasBroaderImpacts: { test: (s) => /\b(broader impact|outreach|community|underrepresented|access)/i.test(s), label: 'Addresses broader impacts' },
93
+ hasHypothesis: { test: (s) => /\b(hypothes|predict)/i.test(s), label: 'States a hypothesis or prediction' },
94
+ hasMerit: { test: (s) => /\b(intellectual merit|novel|advances|contribut)/i.test(s), label: 'Frames intellectual merit' },
95
+ };
96
+
97
+ const wordCount = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length;
98
+
99
+ // ---------- Exporters ----------
100
+ const exportMarkdown = (template, sections) => {
101
+ return [
102
+ `# ${template.name}\n`,
103
+ ...template.sections.map(s => `## ${s.name}\n\n${sections[s.id] || ''}\n`),
104
+ ].join('\n');
105
+ };
106
+ const exportLatex = (template, sections) => {
107
+ return [
108
+ '\\documentclass{article}',
109
+ `\\title{${template.name}}`,
110
+ '\\begin{document}',
111
+ '\\maketitle',
112
+ ...template.sections.map(s => `\n\\section{${s.name}}\n${sections[s.id] || ''}\n`),
113
+ '\\end{document}',
114
+ ].join('\n');
115
+ };
116
+ const exportHtml = (template, sections) => {
117
+ return [
118
+ '<!doctype html>',
119
+ `<html><head><title>${template.name}</title></head><body>`,
120
+ `<h1>${template.name}</h1>`,
121
+ ...template.sections.map(s => `<section><h2>${s.name}</h2><p>${(sections[s.id] || '').replace(/\n/g, '<br>')}</p></section>`),
122
+ '</body></html>',
123
+ ].join('\n');
124
+ };
125
+
126
+ const downloadFile = (filename, mime, contents) => {
127
+ const blob = new Blob([contents], { type: mime });
128
+ const a = document.createElement('a');
129
+ a.href = URL.createObjectURL(blob);
130
+ a.download = filename;
131
+ a.click();
132
+ };
133
+
134
+ // ---------- Component ----------
135
+ const DeliverablesView = ({ allStates }) => {
136
+ const [store, setStore] = useState(() => {
137
+ try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch { return {}; }
138
+ });
139
+ useEffect(() => { localStorage.setItem(STORE_KEY, JSON.stringify(store)); }, [store]);
140
+
141
+ const activeId = store.activeTemplateId;
142
+ const template = TEMPLATES.find(t => t.id === activeId);
143
+ const sections = (store.templates && store.templates[activeId]) || {};
144
+ const [activeSectionId, setActiveSectionId] = useState(template?.sections[0]?.id);
145
+ const [generatingAi, setGeneratingAi] = useState(false);
146
+
147
+ // Keep activeSectionId valid when template changes (re-mount from localStorage,
148
+ // template switch, etc.). Falls back to the first section.
149
+ useEffect(() => {
150
+ if (!template) return;
151
+ const valid = template.sections.some(s => s.id === activeSectionId);
152
+ if (!valid) setActiveSectionId(template.sections[0].id);
153
+ }, [activeId, template, activeSectionId]);
154
+
155
+ // TODO(LLM): wire `runAiPass` to backend that returns per-section "missing" notes.
156
+ // const runAiPass = async () => {
157
+ // const res = await fetch(`${process.env.REACT_APP_API_URL}/api/canvas/deliverable-check`, {
158
+ // method: 'POST',
159
+ // body: JSON.stringify({ template: template.id, sections, canvas: allStates }),
160
+ // });
161
+ // const { notes } = await res.json();
162
+ // setStore({ ...store, templates: { ...store.templates, [activeId]: { ...sections, _aiNotes: notes } } });
163
+ // };
164
+ const runAiPass = () => {
165
+ setGeneratingAi(true);
166
+ setTimeout(() => {
167
+ const notes = template.sections.map(s => {
168
+ const text = sections[s.id] || '';
169
+ const wc = wordCount(text);
170
+ if (wc === 0) return { sectionId: s.id, msg: `Empty — start with: "${s.hint}"` };
171
+ if (wc < s.target * 0.3) return { sectionId: s.id, msg: `Thin (${wc} words). Target ${s.target}.` };
172
+ return { sectionId: s.id, msg: `Looks reasonable for length (${wc} words). LLM-pass would suggest specifics here.` };
173
+ });
174
+ setStore(prev => ({
175
+ ...prev,
176
+ templates: { ...prev.templates, [activeId]: { ...sections, _aiNotes: notes } },
177
+ }));
178
+ setGeneratingAi(false);
179
+ fireToast('AI pass complete (stub)');
180
+ }, 700);
181
+ };
182
+
183
+ const pickTemplate = (id) => {
184
+ setStore({ ...store, activeTemplateId: id });
185
+ setActiveSectionId(TEMPLATES.find(t => t.id === id).sections[0].id);
186
+ };
187
+
188
+ const updateSection = (id, value) => {
189
+ setStore(prev => ({
190
+ ...prev,
191
+ templates: {
192
+ ...prev.templates,
193
+ [activeId]: { ...(prev.templates?.[activeId] || {}), [id]: value },
194
+ },
195
+ }));
196
+ };
197
+
198
+ const exportAs = (format) => {
199
+ const filename = `${template.name.replace(/\s+/g, '_')}.${format === 'latex' ? 'tex' : format === 'markdown' ? 'md' : 'html'}`;
200
+ const mime = format === 'html' ? 'text/html' : 'text/plain';
201
+ const contents = format === 'markdown' ? exportMarkdown(template, sections)
202
+ : format === 'latex' ? exportLatex(template, sections)
203
+ : exportHtml(template, sections);
204
+ downloadFile(filename, mime, contents);
205
+ fireToast(`Exported ${filename}`);
206
+ };
207
+
208
+ // Aggregated word count
209
+ const totalWords = template ? template.sections.reduce((sum, s) => sum + wordCount(sections[s.id]), 0) : 0;
210
+ const totalTarget = template ? template.sections.reduce((sum, s) => sum + s.target, 0) : 0;
211
+
212
+ // Insertable elements from the canvas (citations, quotes, outline nodes, chapter drafts)
213
+ const insertables = useMemo(() => {
214
+ const items = [];
215
+ (allStates?.bibliography?.entries || []).forEach(e => items.push({ kind: 'cite', label: `${e.title}`, snippet: ` (${e.authors}, ${e.year}; @${e.key})` }));
216
+ (allStates?.highlights?.items || []).forEach(h => items.push({ kind: 'quote', label: h.text.slice(0, 60), snippet: `"${h.text}"${h.citeKey ? ` (@${h.citeKey})` : ''}` }));
217
+ (allStates?.outline?.items || []).forEach(o => items.push({ kind: 'outline', label: o.text || '(empty)', snippet: '\n' + ' '.repeat(o.depth) + '- ' + (o.text || '') }));
218
+ (allStates?.writing?.chapters || []).forEach(c => items.push({ kind: 'draft', label: c.name, snippet: c.draft || '' }));
219
+ return items;
220
+ }, [allStates]);
221
+
222
+ const insertIntoActive = (snippet) => {
223
+ if (!activeSectionId) return;
224
+ const cur = sections[activeSectionId] || '';
225
+ updateSection(activeSectionId, cur + (cur && !cur.endsWith('\n') ? ' ' : '') + snippet);
226
+ fireToast('Inserted into ' + template.sections.find(s => s.id === activeSectionId)?.name);
227
+ };
228
+
229
+ // Empty state — pick a template
230
+ if (!template) {
231
+ return (
232
+ <>
233
+ <div className="page-header">
234
+ <div>
235
+ <h1 className="page-title">Deliverables</h1>
236
+ <div className="page-sub">Pick a template; bring elements in from your canvas; export when ready.</div>
237
+ </div>
238
+ </div>
239
+ <div className="canvas-presets-grid">
240
+ {TEMPLATES.map(t => (
241
+ <button key={t.id} className="canvas-preset-card" onClick={() => pickTemplate(t.id)}>
242
+ <div className="canvas-preset-icon"><Icon name={t.icon} size={18}/></div>
243
+ <div className="canvas-preset-content">
244
+ <div className="canvas-preset-name">{t.name}</div>
245
+ <div className="canvas-preset-desc">{t.desc}</div>
246
+ <div className="canvas-preset-meta">{t.sections.length} sections</div>
247
+ </div>
248
+ </button>
249
+ ))}
250
+ </div>
251
+ {/* TODO(LLM): "Upload project brief → AI generates a custom outline" button.
252
+ Wire to backend that returns a synthesized template:
253
+ const onUpload = async (file) => {
254
+ const text = await file.text();
255
+ const res = await fetch(`${API}/api/canvas/outline-from-brief`, { method:'POST', body: text });
256
+ const { template, sections } = await res.json();
257
+ // Inject as a new template into TEMPLATES at runtime, then pickTemplate(template.id);
258
+ };
259
+ */}
260
+ <div className="canvas-presets" style={{ marginTop: 18 }}>
261
+ <div className="canvas-presets-head">
262
+ <div className="canvas-presets-title">From a project brief</div>
263
+ <div className="canvas-presets-sub">Upload your brief and the AI will draft a custom outline. <em>(Needs LLM endpoint — coming soon.)</em></div>
264
+ </div>
265
+ <button className="btn" disabled title="Needs LLM endpoint">
266
+ <Icon name="download" size={13} style={{ transform: 'rotate(180deg)' }}/>Upload project brief
267
+ </button>
268
+ </div>
269
+ </>
270
+ );
271
+ }
272
+
273
+ const aiNotes = sections._aiNotes;
274
+
275
+ // Shared header + insertables panel — used by all editor modes.
276
+ const Header = (
277
+ <div className="page-header">
278
+ <div>
279
+ <button className="btn btn-ghost" style={{ padding: '4px 8px', fontSize: 12, marginBottom: 4, color: 'var(--canvas-text-3)' }} onClick={() => setStore({ ...store, activeTemplateId: undefined })}>
280
+ <Icon name="back" size={12}/>Templates
281
+ </button>
282
+ <h1 className="page-title">{template.name}</h1>
283
+ <div className="page-sub">
284
+ {totalWords} / {totalTarget} words · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
285
+ </div>
286
+ </div>
287
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'flex-start' }}>
288
+ <button className="btn btn-ghost" onClick={runAiPass} disabled={generatingAi} title="AI check (stub)">
289
+ {generatingAi ? <><div className="spinner"/></> : <Icon name="sparkles" size={13}/>}
290
+ AI check
291
+ </button>
292
+ <div style={{ position: 'relative' }}>
293
+ <details className="canvas-export-menu">
294
+ <summary className="btn btn-primary"><Icon name="download" size={13}/>Export</summary>
295
+ <div className="canvas-export-menu-list">
296
+ <button onClick={() => exportAs('markdown')}>Markdown (.md)</button>
297
+ <button onClick={() => exportAs('latex')}>LaTeX (.tex)</button>
298
+ <button onClick={() => exportAs('html')}>HTML (.html)</button>
299
+ </div>
300
+ </details>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ );
305
+
306
+ const InsertPanel = (
307
+ <div className="deliverable-insertables">
308
+ <div style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 8 }}>
309
+ From canvas · {insertables.length}
310
+ </div>
311
+ {insertables.length === 0 && (
312
+ <div style={{ padding: 12, fontSize: 11.5, color: 'var(--canvas-text-3)', background: 'var(--canvas-surface)', border: '1px dashed var(--canvas-border-2)', borderRadius: 7 }}>
313
+ Add a Bibliography, Highlights, Outline, or Writing widget to your canvas; their content shows up here for one-click insert.
314
+ </div>
315
+ )}
316
+ {insertables.map((it, i) => (
317
+ <button key={i} onClick={() => insertIntoActive(it.snippet)} className="canvas-insert-row">
318
+ <span className="tag-pill">{it.kind}</span>
319
+ <span style={{ flex: 1, fontSize: 11.5, color: 'var(--canvas-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</span>
320
+ <Icon name="plus" size={12} style={{ color: 'var(--canvas-text-3)' }}/>
321
+ </button>
322
+ ))}
323
+ </div>
324
+ );
325
+
326
+ // ---------- SLIDES MODE — PowerPoint / Google Slides feel ----------
327
+ if (template.mode === 'slides') {
328
+ const activeIdx = template.sections.findIndex(s => s.id === activeSectionId);
329
+ const active = template.sections[activeIdx] || template.sections[0];
330
+ const text = sections[active.id] || '';
331
+ const aiForSlide = aiNotes && aiNotes.find(n => n.sectionId === active.id);
332
+ // Render body as bullet points if it contains line breaks; otherwise as a paragraph.
333
+ const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
334
+
335
+ return (
336
+ <>
337
+ {Header}
338
+ <div className="deliverable-slides-grid">
339
+ {/* Slide thumbnails */}
340
+ <div className="slide-thumbs">
341
+ <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, padding: '0 4px 6px' }}>
342
+ {template.sections.length} slides
343
+ </div>
344
+ {template.sections.map((s, i) => {
345
+ const t = sections[s.id] || '';
346
+ const tLines = t.split(/\n+/).map(l => l.trim()).filter(Boolean);
347
+ return (
348
+ <button key={s.id}
349
+ className={`slide-thumb ${s.id === active.id ? 'active' : ''}`}
350
+ onClick={() => setActiveSectionId(s.id)}
351
+ title={s.name}>
352
+ <div className="slide-thumb-num">{i + 1}</div>
353
+ <div className="slide-thumb-canvas">
354
+ <div className="slide-thumb-title">{s.name}</div>
355
+ <div className="slide-thumb-body">
356
+ {tLines.slice(0, 3).map((l, j) => <div key={j}>• {l.slice(0, 30)}</div>)}
357
+ </div>
358
+ </div>
359
+ </button>
360
+ );
361
+ })}
362
+ </div>
363
+
364
+ {/* Big slide canvas */}
365
+ <div>
366
+ <div className="slide-canvas-wrap">
367
+ <div className="slide-canvas">
368
+ <div className="slide-canvas-title">{active.name}</div>
369
+ <div className="slide-canvas-body">
370
+ {lines.length === 0 ? (
371
+ <div className="slide-placeholder">{active.hint}</div>
372
+ ) : lines.length === 1 ? (
373
+ <div className="slide-paragraph">{lines[0]}</div>
374
+ ) : (
375
+ <ul>{lines.map((l, j) => <li key={j}>{l}</li>)}</ul>
376
+ )}
377
+ </div>
378
+ <div className="slide-canvas-footer">{activeIdx + 1} / {template.sections.length}</div>
379
+ </div>
380
+ </div>
381
+
382
+ {/* Edit pane below the canvas (so the slide stays the focus) */}
383
+ <div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
384
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
385
+ <span style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
386
+ Slide content
387
+ </span>
388
+ <span style={{ fontSize: 11, color: 'var(--canvas-text-3)' }}>· One bullet per line</span>
389
+ <span style={{ flex: 1 }}/>
390
+ <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)' }}>
391
+ {wordCount(text)}{active.target ? `/${active.target}` : ''} words
392
+ </span>
393
+ </div>
394
+ <textarea
395
+ className="textarea"
396
+ value={text}
397
+ onChange={e => updateSection(active.id, e.target.value)}
398
+ placeholder={active.hint}
399
+ style={{ minHeight: 110, fontSize: 13, lineHeight: 1.6 }}
400
+ />
401
+ {(active.checks || []).length > 0 && (
402
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
403
+ {active.checks.map(c => {
404
+ const check = CHECKS[c];
405
+ if (!check) return null;
406
+ const passed = check.test(text);
407
+ return (
408
+ <span key={c} className="check-pill" data-passed={passed}>
409
+ {passed ? <Icon name="check" size={10}/> : <span style={{ width: 10, height: 10, borderRadius: 2, border: '1px solid currentColor' }}/>}
410
+ {check.label}
411
+ </span>
412
+ );
413
+ })}
414
+ </div>
415
+ )}
416
+ {aiForSlide && (
417
+ <div className="review" style={{ borderLeftColor: 'var(--canvas-accent)' }}>
418
+ <span className="review-tag" style={{ color: 'var(--canvas-accent)' }}>AI suggestion · stub</span>
419
+ {aiForSlide.msg}
420
+ </div>
421
+ )}
422
+ </div>
423
+ </div>
424
+
425
+ {InsertPanel}
426
+ </div>
427
+ </>
428
+ );
429
+ }
430
+
431
+ // ---------- PAPER / DOCUMENT MODE — Notion-style single-surface page ----------
432
+ const paperLike = template.mode === 'paper';
433
+ return (
434
+ <>
435
+ {Header}
436
+ <div className="notion-deliverable-grid">
437
+ {/* TOC sidebar — subtle, no boxes */}
438
+ <div className="notion-toc">
439
+ <div className="notion-toc-label">On this page</div>
440
+ {template.sections.map(s => {
441
+ const wc = wordCount(sections[s.id]);
442
+ return (
443
+ <button key={s.id}
444
+ className={`notion-toc-link ${activeSectionId === s.id ? 'active' : ''}`}
445
+ onClick={() => {
446
+ setActiveSectionId(s.id);
447
+ const el = document.getElementById(`notion-section-${s.id}`);
448
+ if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
449
+ }}>
450
+ <span className="notion-toc-link-text">{s.name}</span>
451
+ {wc > 0 && <span className="notion-toc-link-count">{wc}</span>}
452
+ </button>
453
+ );
454
+ })}
455
+ </div>
456
+
457
+ {/* The page itself — what you see is what you edit */}
458
+ <div className={`notion-page-wrap ${paperLike ? 'paper' : ''}`}>
459
+ <div className={`notion-page ${paperLike ? 'serif' : ''}`}>
460
+ <h1 className="notion-page-title">{template.name}</h1>
461
+ <div className="notion-page-meta">
462
+ {totalWords} words · {template.sections.length} sections{paperLike ? ' · academic paper' : ''}
463
+ </div>
464
+ {template.sections.map(s => {
465
+ const text = sections[s.id] || '';
466
+ const aiForSection = aiNotes ? aiNotes.find(n => n.sectionId === s.id) : null;
467
+ const failed = (s.checks || []).filter(c => CHECKS[c] && !CHECKS[c].test(text));
468
+ return (
469
+ <div key={s.id} id={`notion-section-${s.id}`} className="notion-block">
470
+ <h2 className={`notion-h2 ${paperLike ? 'serif' : ''}`}>{s.name}</h2>
471
+ {!text && <div className="notion-hint">{s.hint}</div>}
472
+ <NotionTextarea
473
+ value={text}
474
+ onChange={(v) => updateSection(s.id, v)}
475
+ placeholder={`Start writing ${s.name.toLowerCase()}…`}
476
+ serif={paperLike}
477
+ />
478
+ {(s.checks || []).length > 0 && (
479
+ <div className="notion-block-meta">
480
+ {s.checks.map(c => {
481
+ const check = CHECKS[c];
482
+ if (!check) return null;
483
+ const passed = check.test(text);
484
+ return (
485
+ <span key={c} className="check-pill" data-passed={passed}>
486
+ {passed ? <Icon name="check" size={10}/> : <span style={{ width: 10, height: 10, borderRadius: 2, border: '1px solid currentColor' }}/>}
487
+ {check.label}
488
+ </span>
489
+ );
490
+ })}
491
+ {s.target > 0 && (
492
+ <span className="check-pill" data-passed={wordCount(text) >= s.target * 0.7}>
493
+ {wordCount(text)} / {s.target} words
494
+ </span>
495
+ )}
496
+ </div>
497
+ )}
498
+ {aiForSection && (
499
+ <div className="notion-callout">
500
+ <Icon name="sparkles" size={14}/>
501
+ <div>
502
+ <div className="notion-callout-label">AI suggestion · stub</div>
503
+ <div>{aiForSection.msg}</div>
504
+ </div>
505
+ </div>
506
+ )}
507
+ </div>
508
+ );
509
+ })}
510
+ </div>
511
+ </div>
512
+
513
+ {InsertPanel}
514
+ </div>
515
+ </>
516
+ );
517
+ };
518
+
519
+ // Auto-growing textarea styled to be invisible on the Notion page —
520
+ // looks like body text until the user clicks in.
521
+ function NotionTextarea({ value, onChange, placeholder, serif }) {
522
+ const ref = useRef(null);
523
+ useEffect(() => {
524
+ if (!ref.current) return;
525
+ ref.current.style.height = 'auto';
526
+ ref.current.style.height = ref.current.scrollHeight + 'px';
527
+ }, [value]);
528
+ return (
529
+ <textarea
530
+ ref={ref}
531
+ className={`notion-text ${serif ? 'serif' : ''}`}
532
+ value={value}
533
+ onChange={(e) => onChange(e.target.value)}
534
+ placeholder={placeholder}
535
+ rows={1}
536
+ />
537
+ );
538
+ }
539
+
540
+ export default DeliverablesView;
phd-advisor-frontend/src/components/canvas/CanvasModals.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo } from 'react';
2
  import Icon from './CanvasIcon';
3
  import { WIDGET_CATALOG, CATEGORIES } from './canvasData';
4
 
@@ -506,14 +506,72 @@ export function BudgetItemModal({ data, onClose }) {
506
  );
507
  }
508
 
509
- // ---------- Note ----------
510
  export function NoteModal({ data, onClose }) {
511
  const init = data.initial || {};
512
  const [text, setT] = useState(init.text || '');
513
  const [tag, setG] = useState(init.tag || '');
514
  const [linkTo, setL] = useState(init.linkTo || '');
 
 
 
515
  const editing = !!init.id;
516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  const submit = () => {
518
  if (!text.trim()) return;
519
  data.onSave({ text: text.trim(), tag: tag.trim(), linkTo: linkTo.trim() });
@@ -532,8 +590,39 @@ export function NoteModal({ data, onClose }) {
532
  </div>
533
  <div className="modal-body">
534
  <div className="form-grid">
535
- <div className="form-row">
536
- <textarea className="textarea" autoFocus style={{ minHeight: 140, fontFamily: 'var(--canvas-mono)', fontSize: 12.5 }} value={text} onChange={e => setT(e.target.value)} placeholder="What's the thought? Markdown welcome."/>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  </div>
538
  <div className="form-grid two">
539
  <div className="form-row">
@@ -802,6 +891,126 @@ export function PaletteModal({ data, onClose }) {
802
  );
803
  }
804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  // ---------- Command palette ----------
806
  export function CommandPaletteModal({ data, onClose }) {
807
  const [q, setQ] = useState('');
 
1
+ import React, { useState, useMemo, useRef } from 'react';
2
  import Icon from './CanvasIcon';
3
  import { WIDGET_CATALOG, CATEGORIES } from './canvasData';
4
 
 
506
  );
507
  }
508
 
509
+ // ---------- Note (with @mention autocomplete) ----------
510
  export function NoteModal({ data, onClose }) {
511
  const init = data.initial || {};
512
  const [text, setT] = useState(init.text || '');
513
  const [tag, setG] = useState(init.tag || '');
514
  const [linkTo, setL] = useState(init.linkTo || '');
515
+ const [mention, setMention] = useState(null); // { start, query, choices }
516
+ const [mentionIdx, setMentionIdx] = useState(0);
517
+ const taRef = useRef(null);
518
  const editing = !!init.id;
519
 
520
+ // Pull mention sources from the persisted canvas state so the modal stays self-contained.
521
+ const mentionSources = useMemo(() => {
522
+ try {
523
+ const all = JSON.parse(localStorage.getItem('canvas-states-v2') || '{}');
524
+ const out = [];
525
+ (all.bibliography?.entries || []).forEach(e => out.push({ key: '@' + e.key, label: e.title, kind: 'cite' }));
526
+ (all.writing?.chapters || []).forEach(c => out.push({ key: '@' + c.name.replace(/\s+/g, '-'), label: c.name, kind: 'chapter' }));
527
+ (all.kanban?.cards || []).forEach(c => out.push({ key: '@' + c.title.slice(0, 30).replace(/\s+/g, '-'), label: c.title, kind: 'task' }));
528
+ return out;
529
+ } catch { return []; }
530
+ }, []);
531
+
532
+ const onTextChange = (e) => {
533
+ const val = e.target.value;
534
+ const cursor = e.target.selectionStart;
535
+ setT(val);
536
+ // Look back from cursor for an @mention being typed
537
+ const before = val.slice(0, cursor);
538
+ const m = before.match(/@(\S*)$/);
539
+ if (m) {
540
+ const q = m[1].toLowerCase();
541
+ const choices = mentionSources
542
+ .filter(s => s.label.toLowerCase().includes(q) || s.key.toLowerCase().includes('@' + q))
543
+ .slice(0, 6);
544
+ setMention({ start: cursor - m[0].length, query: m[1], choices });
545
+ setMentionIdx(0);
546
+ } else {
547
+ setMention(null);
548
+ }
549
+ };
550
+
551
+ const insertMention = (item) => {
552
+ if (!mention) return;
553
+ const before = text.slice(0, mention.start);
554
+ const after = text.slice(mention.start + 1 + mention.query.length); // +1 for the @
555
+ const inserted = item.key + ' ';
556
+ setT(before + inserted + after);
557
+ setMention(null);
558
+ setTimeout(() => {
559
+ if (taRef.current) {
560
+ const pos = before.length + inserted.length;
561
+ taRef.current.setSelectionRange(pos, pos);
562
+ taRef.current.focus();
563
+ }
564
+ }, 0);
565
+ };
566
+
567
+ const onKeyDown = (e) => {
568
+ if (!mention || mention.choices.length === 0) return;
569
+ if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIdx(i => Math.min(mention.choices.length - 1, i + 1)); }
570
+ if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIdx(i => Math.max(0, i - 1)); }
571
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertMention(mention.choices[mentionIdx]); }
572
+ if (e.key === 'Escape') setMention(null);
573
+ };
574
+
575
  const submit = () => {
576
  if (!text.trim()) return;
577
  data.onSave({ text: text.trim(), tag: tag.trim(), linkTo: linkTo.trim() });
 
590
  </div>
591
  <div className="modal-body">
592
  <div className="form-grid">
593
+ <div className="form-row" style={{ position: 'relative' }}>
594
+ <textarea ref={taRef} className="textarea" autoFocus
595
+ style={{ minHeight: 140, fontFamily: 'var(--canvas-mono)', fontSize: 12.5 }}
596
+ value={text} onChange={onTextChange} onKeyDown={onKeyDown}
597
+ placeholder="What's the thought? Markdown welcome. Type @ to mention citations, chapters, tasks."/>
598
+ {mention && mention.choices.length > 0 && (
599
+ <div style={{
600
+ position: 'absolute',
601
+ top: '100%', left: 0, right: 0,
602
+ background: 'var(--canvas-surface)',
603
+ border: '1px solid var(--canvas-border-2)',
604
+ borderRadius: 7,
605
+ marginTop: 4,
606
+ boxShadow: 'var(--canvas-shadow-lg)',
607
+ zIndex: 10,
608
+ maxHeight: 200,
609
+ overflowY: 'auto',
610
+ }}>
611
+ {mention.choices.map((c, i) => (
612
+ <button key={c.key + i} onMouseEnter={() => setMentionIdx(i)} onClick={() => insertMention(c)}
613
+ style={{
614
+ width: '100%', display: 'flex', alignItems: 'center', gap: 8,
615
+ padding: '6px 10px', textAlign: 'left',
616
+ background: i === mentionIdx ? 'var(--canvas-surface-2)' : 'transparent',
617
+ color: 'var(--canvas-text)', border: 'none', cursor: 'pointer', fontSize: 12,
618
+ }}>
619
+ <span className="tag-pill">{c.kind}</span>
620
+ <span style={{ fontFamily: 'var(--canvas-mono)', color: 'var(--canvas-accent)' }}>{c.key}</span>
621
+ <span style={{ flex: 1, color: 'var(--canvas-text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
622
+ </button>
623
+ ))}
624
+ </div>
625
+ )}
626
  </div>
627
  <div className="form-grid two">
628
  <div className="form-row">
 
891
  );
892
  }
893
 
894
+ // ---------- Global content search (notes / quotes / citations / kanban / deadlines / outline) ----------
895
+ export function GlobalSearchModal({ data, onClose }) {
896
+ const [q, setQ] = useState('');
897
+ const [idx, setIdx] = useState(0);
898
+ const states = data.states || {};
899
+
900
+ const items = useMemo(() => {
901
+ if (!q.trim()) return [];
902
+ const ql = q.toLowerCase();
903
+ const out = [];
904
+ // Notes
905
+ (states.notes?.items || []).forEach(n => {
906
+ if ((n.text || '').toLowerCase().includes(ql) || (n.tag || '').toLowerCase().includes(ql) || (n.linkTo || '').toLowerCase().includes(ql)) {
907
+ out.push({ kind: 'Note', label: (n.text || '').slice(0, 80), sub: n.tag ? `#${n.tag}` : '', icon: 'notes', widgetType: 'notes' });
908
+ }
909
+ });
910
+ // Citations
911
+ (states.bibliography?.entries || []).forEach(e => {
912
+ const blob = `${e.title} ${e.authors} ${e.journal} ${e.key}`.toLowerCase();
913
+ if (blob.includes(ql)) out.push({ kind: 'Citation', label: e.title, sub: `${e.authors} (${e.year})`, icon: 'book', widgetType: 'bibliography' });
914
+ });
915
+ // Kanban
916
+ (states.kanban?.cards || []).forEach(c => {
917
+ if (`${c.title} ${c.meta || ''}`.toLowerCase().includes(ql)) {
918
+ out.push({ kind: 'Task', label: c.title, sub: `${c.priority?.toUpperCase()} · ${c.meta || ''}`, icon: 'kanban', widgetType: 'kanban' });
919
+ }
920
+ });
921
+ // Deadlines
922
+ (states.deadlines || []).forEach(d => {
923
+ if (`${d.title} ${d.tag}`.toLowerCase().includes(ql)) {
924
+ out.push({ kind: 'Deadline', label: d.title, sub: `${d.date} · ${d.tag}`, icon: 'calendar', widgetType: 'deadlines' });
925
+ }
926
+ });
927
+ // Highlights
928
+ (states.highlights?.items || []).forEach(h => {
929
+ if (`${h.text} ${h.citeKey}`.toLowerCase().includes(ql)) {
930
+ out.push({ kind: 'Quote', label: `"${h.text}"`.slice(0, 80), sub: h.citeKey ? `@${h.citeKey}` : '', icon: 'cite', widgetType: 'highlights' });
931
+ }
932
+ });
933
+ // Outline
934
+ (states.outline?.items || []).forEach(o => {
935
+ if ((o.text || '').toLowerCase().includes(ql)) {
936
+ out.push({ kind: 'Outline', label: o.text, sub: `depth ${o.depth}`, icon: 'list', widgetType: 'outline' });
937
+ }
938
+ });
939
+ // Documenter
940
+ (states.documenter?.entries || []).forEach(e => {
941
+ if ((e.text || '').toLowerCase().includes(ql)) {
942
+ out.push({ kind: 'Journal', label: e.text.slice(0, 80), sub: e.date, icon: 'pencil', widgetType: 'documenter' });
943
+ }
944
+ });
945
+ return out.slice(0, 30);
946
+ }, [q, states]);
947
+
948
+ const jumpTo = (it) => {
949
+ const el = document.querySelector(`[data-widget-id^="w-"][data-widget-type="${it.widgetType}"]`)
950
+ || document.querySelector(`.widget`); // fallback: scroll to first
951
+ if (el) {
952
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
953
+ el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
954
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
955
+ }
956
+ onClose();
957
+ };
958
+
959
+ return (
960
+ <div className="canvas-modal" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
961
+ <div style={{ padding: '16px 18px 8px', borderBottom: '1px solid var(--canvas-border)', display: 'flex', alignItems: 'center', gap: 10 }}>
962
+ <Icon name="search" size={16} style={{ color: 'var(--canvas-text-3)' }}/>
963
+ <input
964
+ autoFocus
965
+ value={q}
966
+ onChange={e => { setQ(e.target.value); setIdx(0); }}
967
+ onKeyDown={(e) => {
968
+ if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }
969
+ if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
970
+ if (e.key === 'Enter' && items[idx]) jumpTo(items[idx]);
971
+ }}
972
+ placeholder="Search across notes, citations, tasks, quotes, deadlines…"
973
+ style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: 'var(--canvas-text)', fontSize: 15, padding: '4px 0' }}
974
+ />
975
+ <kbd style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)', background: 'var(--canvas-surface-2)', padding: '2px 6px', borderRadius: 4 }}>esc</kbd>
976
+ </div>
977
+ <div style={{ maxHeight: 420, overflowY: 'auto', padding: 6 }}>
978
+ {!q.trim() && (
979
+ <div style={{ padding: 30, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 13 }}>
980
+ Type to search across your canvas content.
981
+ </div>
982
+ )}
983
+ {q.trim() && items.length === 0 && (
984
+ <div style={{ padding: 30, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 13 }}>
985
+ No matches for "{q}".
986
+ </div>
987
+ )}
988
+ {items.map((it, i) => (
989
+ <button key={i} onClick={() => jumpTo(it)} onMouseEnter={() => setIdx(i)}
990
+ style={{
991
+ width: '100%', display: 'flex', alignItems: 'center', gap: 12,
992
+ padding: '8px 12px', borderRadius: 7, textAlign: 'left',
993
+ background: idx === i ? 'var(--canvas-surface-2)' : 'transparent',
994
+ color: 'var(--canvas-text)', border: 'none', cursor: 'pointer',
995
+ }}>
996
+ <div style={{ width: 24, height: 24, borderRadius: 5, background: 'var(--canvas-surface-2)', display: 'grid', placeItems: 'center', color: 'var(--canvas-accent)' }}>
997
+ <Icon name={it.icon} size={13}/>
998
+ </div>
999
+ <div style={{ flex: 1, minWidth: 0 }}>
1000
+ <div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</div>
1001
+ <div style={{ fontSize: 11, color: 'var(--canvas-text-3)' }}>{it.sub}</div>
1002
+ </div>
1003
+ <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 9.5, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>{it.kind}</span>
1004
+ </button>
1005
+ ))}
1006
+ </div>
1007
+ <div style={{ padding: '8px 14px', borderTop: '1px solid var(--canvas-border)', display: 'flex', gap: 14, fontSize: 10.5, color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)' }}>
1008
+ <span>↑↓ navigate</span><span>↵ jump</span><span>esc close</span>
1009
+ </div>
1010
+ </div>
1011
+ );
1012
+ }
1013
+
1014
  // ---------- Command palette ----------
1015
  export function CommandPaletteModal({ data, onClose }) {
1016
  const [q, setQ] = useState('');
phd-advisor-frontend/src/components/canvas/CanvasWidgets.js CHANGED
@@ -5,12 +5,64 @@ import Icon from './CanvasIcon';
5
 
6
  const fireToast = (msg, kind = 'success') =>
7
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  // ===== Bibliography =====
10
  export function BibliographyWidget({ state, setState, openModal }) {
11
  const formats = ['APA', 'MLA', 'Chicago', 'BibTeX'];
12
  const fmt = state.format || 'APA';
13
  const [sortBy, setSortBy] = useState('year');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  const setFmt = (f) => setState({ ...state, format: f });
15
 
16
  const formatEntry = (e) => {
@@ -46,7 +98,19 @@ export function BibliographyWidget({ state, setState, openModal }) {
46
  });
47
 
48
  return (
49
- <>
 
 
 
 
 
 
 
 
 
 
 
 
50
  <div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
51
  <div className="format-tabs">
52
  {formats.map(f => (
@@ -78,10 +142,12 @@ export function BibliographyWidget({ state, setState, openModal }) {
78
  </div>
79
  ))}
80
  {sorted.length === 0 && (
81
- <div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>No citations yet.</div>
 
 
82
  )}
83
  </div>
84
- </>
85
  );
86
  }
87
 
@@ -313,6 +379,7 @@ export function WritingWidget({ state, setState }) {
313
  _sessionDelta: 0,
314
  });
315
  fireToast(`Saved · ${words} words in ${active.name}`);
 
316
  };
317
 
318
  const addChapter = () => {
@@ -606,11 +673,15 @@ export function ReadingQueueWidget({ state, setState, openModal }) {
606
  <>
607
  <div className="dl-list">
608
  {state.map((p, i) => (
609
- <div key={i} className="dl-row" style={{ padding: '7px 9px', position: 'relative' }}>
 
 
 
 
610
  <span style={{ width: 4, height: 28, borderRadius: 2, background: colors[p.priority], flexShrink: 0 }}/>
611
  <div className="dl-info" onClick={() => edit(p, i)} style={{ cursor: 'pointer' }}>
612
  <div className="dl-title" style={{ fontSize: 12 }}>{p.title}</div>
613
- <div className="dl-sub">{p.priority} · ~{p.time}</div>
614
  </div>
615
  <button className="icon-btn" style={{width:24,height:24}} title="Mark read" onClick={() => remove(i, p)}>
616
  <Icon name="check" size={13}/>
@@ -1100,6 +1171,257 @@ export function LatexWidget({ state, setState }) {
1100
  );
1101
  }
1102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1103
  // ===== Stub =====
1104
  export function StubWidget({ meta }) {
1105
  return (
 
5
 
6
  const fireToast = (msg, kind = 'success') =>
7
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
8
+ const fireActivity = (source, msg) =>
9
+ window.dispatchEvent(new CustomEvent('canvas-activity', { detail: { source, msg } }));
10
+
11
+ // Cross-widget drag-drop helpers — one mime type, JSON payload tagged by `kind`.
12
+ const X_MIME = 'application/x-canvas-item';
13
+ const setDragPayload = (e, kind, payload) => {
14
+ e.dataTransfer.setData(X_MIME, JSON.stringify({ kind, payload }));
15
+ e.dataTransfer.effectAllowed = 'copy';
16
+ };
17
+ const readDragPayload = (e) => {
18
+ try { return JSON.parse(e.dataTransfer.getData(X_MIME)); } catch { return null; }
19
+ };
20
 
21
  // ===== Bibliography =====
22
  export function BibliographyWidget({ state, setState, openModal }) {
23
  const formats = ['APA', 'MLA', 'Chicago', 'BibTeX'];
24
  const fmt = state.format || 'APA';
25
  const [sortBy, setSortBy] = useState('year');
26
+ const [dropOver, setDropOver] = useState(false);
27
+
28
+ const onDrop = async (e) => {
29
+ e.preventDefault();
30
+ setDropOver(false);
31
+ const data = readDragPayload(e);
32
+ if (!data || data.kind !== 'paper') return;
33
+ const p = data.payload;
34
+ // If we have a DOI, hit CrossRef and build a real citation; else stub from title.
35
+ const titleStr = p.title || 'Untitled';
36
+ let entry = {
37
+ key: 'cite' + Date.now(),
38
+ authors: 'Unknown',
39
+ title: titleStr.replace(/^.*?— /, ''),
40
+ journal: '',
41
+ year: new Date().getFullYear(),
42
+ cited: 0,
43
+ doi: p.doi || '',
44
+ };
45
+ if (p.doi) {
46
+ try {
47
+ const cleaned = p.doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
48
+ const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
49
+ if (res.ok) {
50
+ const json = await res.json();
51
+ const w = json.message;
52
+ entry.authors = (w.author || []).map(a => `${a.family || ''}, ${(a.given || '').charAt(0)}.`).join('; ') || entry.authors;
53
+ entry.title = (w.title && w.title[0]) || entry.title;
54
+ entry.journal = (w['container-title'] && w['container-title'][0]) || '';
55
+ entry.year = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || entry.year;
56
+ const firstAuthor = (entry.authors.split(',')[0] || 'cite').toLowerCase().replace(/[^a-z]/g, '');
57
+ entry.key = firstAuthor + entry.year;
58
+ }
59
+ } catch { /* fall through with stub */ }
60
+ }
61
+ setState({ ...state, entries: [...state.entries, entry] });
62
+ fireToast(`@${entry.key} added from Reading Queue`);
63
+ fireActivity('Bibliography', `Added @${entry.key} (from Reading Queue)`);
64
+ };
65
+
66
  const setFmt = (f) => setState({ ...state, format: f });
67
 
68
  const formatEntry = (e) => {
 
98
  });
99
 
100
  return (
101
+ <div
102
+ onDragOver={(e) => { if (e.dataTransfer.types.includes(X_MIME)) { e.preventDefault(); setDropOver(true); } }}
103
+ onDragLeave={() => setDropOver(false)}
104
+ onDrop={onDrop}
105
+ style={{ display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', flex: 1 }}
106
+ className={dropOver ? 'canvas-drop-active' : ''}
107
+ >
108
+ {dropOver && (
109
+ <div className="canvas-drop-overlay">
110
+ <Icon name="plus" size={18}/>
111
+ <span>Drop to add citation</span>
112
+ </div>
113
+ )}
114
  <div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
115
  <div className="format-tabs">
116
  {formats.map(f => (
 
142
  </div>
143
  ))}
144
  {sorted.length === 0 && (
145
+ <div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
146
+ No citations yet. Add one or drag a paper from the Reading Queue.
147
+ </div>
148
  )}
149
  </div>
150
+ </div>
151
  );
152
  }
153
 
 
379
  _sessionDelta: 0,
380
  });
381
  fireToast(`Saved · ${words} words in ${active.name}`);
382
+ fireActivity('Writing', `Saved ${words} words to "${active.name}"`);
383
  };
384
 
385
  const addChapter = () => {
 
673
  <>
674
  <div className="dl-list">
675
  {state.map((p, i) => (
676
+ <div key={i} className="dl-row"
677
+ draggable
678
+ onDragStart={(e) => setDragPayload(e, 'paper', p)}
679
+ title="Drag to Bibliography to add as citation"
680
+ style={{ padding: '7px 9px', position: 'relative', cursor: 'grab' }}>
681
  <span style={{ width: 4, height: 28, borderRadius: 2, background: colors[p.priority], flexShrink: 0 }}/>
682
  <div className="dl-info" onClick={() => edit(p, i)} style={{ cursor: 'pointer' }}>
683
  <div className="dl-title" style={{ fontSize: 12 }}>{p.title}</div>
684
+ <div className="dl-sub">{p.priority} · ~{p.time}{p.doi ? ' · ' + p.doi : ''}</div>
685
  </div>
686
  <button className="icon-btn" style={{width:24,height:24}} title="Mark read" onClick={() => remove(i, p)}>
687
  <Icon name="check" size={13}/>
 
1171
  );
1172
  }
1173
 
1174
+ // ===== Calendar — month grid with deadlines (red) and writing days (green) =====
1175
+ export function CalendarWidget({ state, setState, allStates = {} }) {
1176
+ const monthStr = state.viewMonth || new Date().toISOString().slice(0, 7);
1177
+ const [year, month] = monthStr.split('-').map(Number);
1178
+ const first = new Date(year, month - 1, 1);
1179
+ const startWeekday = first.getDay(); // Sunday-indexed
1180
+ const daysInMonth = new Date(year, month, 0).getDate();
1181
+
1182
+ // Source data from cross-widget state
1183
+ const deadlines = (allStates.deadlines || []).reduce((m, d) => {
1184
+ const k = d.date.slice(0, 10);
1185
+ (m[k] ||= []).push({ kind: 'deadline', ...d });
1186
+ return m;
1187
+ }, {});
1188
+ const writing = ((allStates.writing && allStates.writing.dailyTotals) || {});
1189
+ const kanbanCards = (allStates.kanban && allStates.kanban.cards) || [];
1190
+
1191
+ const cells = [];
1192
+ for (let i = 0; i < startWeekday; i++) cells.push(null);
1193
+ for (let d = 1; d <= daysInMonth; d++) {
1194
+ const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
1195
+ cells.push({ d, dateStr });
1196
+ }
1197
+ while (cells.length % 7) cells.push(null);
1198
+
1199
+ const shiftMonth = (delta) => {
1200
+ const next = new Date(year, month - 1 + delta, 1);
1201
+ setState({ ...state, viewMonth: next.toISOString().slice(0, 7) });
1202
+ };
1203
+ const monthLabel = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
1204
+ const today = new Date().toISOString().slice(0, 10);
1205
+
1206
+ return (
1207
+ <>
1208
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
1209
+ <button className="icon-btn" onClick={() => shiftMonth(-1)}><Icon name="chevron" size={14} style={{ transform: 'rotate(180deg)' }}/></button>
1210
+ <div style={{ flex: 1, fontWeight: 600, fontSize: 13 }}>{monthLabel}</div>
1211
+ <button className="icon-btn" onClick={() => shiftMonth(1)}><Icon name="chevron" size={14}/></button>
1212
+ </div>
1213
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3, fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.06em', textAlign: 'center', fontFamily: 'var(--canvas-mono)' }}>
1214
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => <div key={i}>{d}</div>)}
1215
+ </div>
1216
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
1217
+ {cells.map((c, i) => {
1218
+ if (!c) return <div key={i} style={{ aspectRatio: '1' }}/>;
1219
+ const dls = deadlines[c.dateStr] || [];
1220
+ const words = writing[c.dateStr] || 0;
1221
+ const dueCards = kanbanCards.filter(card => (card.meta || '').includes(c.dateStr));
1222
+ const isToday = c.dateStr === today;
1223
+ return (
1224
+ <div key={i}
1225
+ title={[
1226
+ ...dls.map(d => `Deadline: ${d.title}`),
1227
+ ...(words ? [`${words} words written`] : []),
1228
+ ...dueCards.map(d => `Task: ${d.title}`),
1229
+ ].join('\n') || c.dateStr}
1230
+ style={{
1231
+ aspectRatio: '1',
1232
+ border: `1px solid ${isToday ? 'var(--canvas-accent)' : 'var(--canvas-border)'}`,
1233
+ background: isToday ? 'var(--canvas-accent-glow)' : 'var(--canvas-bg-2)',
1234
+ borderRadius: 4,
1235
+ padding: 3,
1236
+ display: 'flex',
1237
+ flexDirection: 'column',
1238
+ gap: 1,
1239
+ fontSize: 10,
1240
+ position: 'relative',
1241
+ }}
1242
+ >
1243
+ <span style={{ fontFamily: 'var(--canvas-mono)', color: isToday ? 'var(--canvas-accent)' : 'var(--canvas-text-2)', fontWeight: isToday ? 700 : 500 }}>
1244
+ {c.d}
1245
+ </span>
1246
+ <div style={{ display: 'flex', gap: 2, flexWrap: 'wrap', marginTop: 'auto' }}>
1247
+ {dls.length > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-danger)' }}/>}
1248
+ {words > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-ok)' }}/>}
1249
+ {dueCards.length > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-warn)' }}/>}
1250
+ </div>
1251
+ </div>
1252
+ );
1253
+ })}
1254
+ </div>
1255
+ <div style={{ display: 'flex', gap: 12, fontSize: 10, color: 'var(--canvas-text-3)', flexWrap: 'wrap' }}>
1256
+ <span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-danger)', marginRight: 4 }}/>Deadlines</span>
1257
+ <span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-ok)', marginRight: 4 }}/>Writing</span>
1258
+ <span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-warn)', marginRight: 4 }}/>Tasks</span>
1259
+ </div>
1260
+ </>
1261
+ );
1262
+ }
1263
+
1264
+ // ===== Activity Feed — surfaces recent edits across all widgets =====
1265
+ // Activity entries are written by other widgets via window.dispatchEvent('canvas-activity').
1266
+ // We persist a rolling buffer of the last 100 events in this widget's own state.
1267
+ export function ActivityWidget({ state, setState }) {
1268
+ const events = state.events || [];
1269
+ useEffect(() => {
1270
+ const handler = (e) => {
1271
+ const entry = { id: 'a' + Date.now() + Math.random(), at: Date.now(), ...e.detail };
1272
+ setState(prev => {
1273
+ const next = { ...(prev || {}), events: [entry, ...((prev && prev.events) || [])].slice(0, 100) };
1274
+ return next;
1275
+ });
1276
+ };
1277
+ window.addEventListener('canvas-activity', handler);
1278
+ return () => window.removeEventListener('canvas-activity', handler);
1279
+ // setState ref is stable
1280
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1281
+ }, []);
1282
+
1283
+ const fmt = (t) => {
1284
+ const d = Date.now() - t;
1285
+ if (d < 60_000) return 'just now';
1286
+ if (d < 3600_000) return Math.floor(d / 60_000) + 'm ago';
1287
+ if (d < 86400_000) return Math.floor(d / 3600_000) + 'h ago';
1288
+ return Math.floor(d / 86400_000) + 'd ago';
1289
+ };
1290
+
1291
+ return (
1292
+ <>
1293
+ <div className="note-list">
1294
+ {events.length === 0 && (
1295
+ <div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
1296
+ No activity yet. Edits across widgets will show up here.
1297
+ </div>
1298
+ )}
1299
+ {events.map(e => (
1300
+ <div key={e.id} className="note-row" style={{ padding: '7px 9px' }}>
1301
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
1302
+ <span className="tag-pill">{e.source}</span>
1303
+ <span style={{ fontSize: 12, flex: 1 }}>{e.msg}</span>
1304
+ <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)' }}>{fmt(e.at)}</span>
1305
+ </div>
1306
+ </div>
1307
+ ))}
1308
+ </div>
1309
+ {events.length > 0 && (
1310
+ <button className="add-tiny" onClick={() => setState({ ...state, events: [] })}>Clear feed</button>
1311
+ )}
1312
+ </>
1313
+ );
1314
+ }
1315
+
1316
+ // ===== Daily Documenter — date-stamped journal with stub AI weekly summary =====
1317
+ export function DocumenterWidget({ state, setState }) {
1318
+ const entries = state.entries || [];
1319
+ const [draft, setDraft] = useState('');
1320
+ const today = new Date().toISOString().slice(0, 10);
1321
+ const [generating, setGenerating] = useState(false);
1322
+
1323
+ const append = () => {
1324
+ if (!draft.trim()) return;
1325
+ const entry = { id: 'doc' + Date.now(), date: today, text: draft.trim(), at: Date.now() };
1326
+ setState({ ...state, entries: [entry, ...entries] });
1327
+ setDraft('');
1328
+ window.dispatchEvent(new CustomEvent('canvas-activity', {
1329
+ detail: { source: 'Documenter', msg: `Logged: ${draft.trim().slice(0, 50)}…` },
1330
+ }));
1331
+ fireToast('Entry saved');
1332
+ };
1333
+
1334
+ const remove = (id) => setState({ ...state, entries: entries.filter(e => e.id !== id) });
1335
+
1336
+ // TODO(LLM): wire to /api/summarize-week with last 7 days of entries.
1337
+ // Backend call shape (commented because endpoint isn't ready):
1338
+ // const generateSummary = async () => {
1339
+ // const last7 = entries.filter(e => Date.now() - e.at < 7*86400_000);
1340
+ // const res = await fetch(`${process.env.REACT_APP_API_URL}/api/canvas/summarize`, {
1341
+ // method: 'POST',
1342
+ // body: JSON.stringify({ entries: last7 }),
1343
+ // });
1344
+ // const { summary } = await res.json();
1345
+ // setState({ ...state, lastSummary: { at: Date.now(), text: summary } });
1346
+ // };
1347
+ const generateSummary = () => {
1348
+ // Stub: echo back a structured summary built from the local entries so the UI
1349
+ // shape is real even though no LLM is hooked up yet.
1350
+ setGenerating(true);
1351
+ setTimeout(() => {
1352
+ const last7 = entries.filter(e => Date.now() - e.at < 7 * 86400_000);
1353
+ const text = last7.length === 0
1354
+ ? 'No entries in the last 7 days.'
1355
+ : `**This week** · ${last7.length} entries logged.\n\n` +
1356
+ '_LLM summary will replace this once the backend endpoint is wired._\n\n' +
1357
+ last7.slice(0, 3).map(e => `- ${e.date}: ${e.text.slice(0, 80)}${e.text.length > 80 ? '…' : ''}`).join('\n');
1358
+ setState({ ...state, lastSummary: { at: Date.now(), text } });
1359
+ setGenerating(false);
1360
+ fireToast('Weekly summary generated (stub)');
1361
+ }, 600);
1362
+ };
1363
+
1364
+ // Group entries by date
1365
+ const grouped = entries.reduce((m, e) => { (m[e.date] ||= []).push(e); return m; }, {});
1366
+ const dates = Object.keys(grouped).sort().reverse();
1367
+
1368
+ return (
1369
+ <>
1370
+ <div className="form-grid">
1371
+ <textarea
1372
+ className="textarea"
1373
+ placeholder={`What did you do today? (${today})`}
1374
+ rows={3}
1375
+ style={{ minHeight: 50, fontSize: 12.5 }}
1376
+ value={draft}
1377
+ onChange={e => setDraft(e.target.value)}
1378
+ onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) append(); }}
1379
+ />
1380
+ <div style={{ display: 'flex', gap: 6 }}>
1381
+ <button className="btn btn-primary" onClick={append} disabled={!draft.trim()}>
1382
+ <Icon name="plus" size={13}/>Log entry
1383
+ </button>
1384
+ <button className="btn" onClick={generateSummary} disabled={entries.length === 0 || generating}>
1385
+ {generating ? <><div className="spinner"/>Summarizing</> : <><Icon name="sparkles" size={13}/>Weekly summary</>}
1386
+ </button>
1387
+ </div>
1388
+ </div>
1389
+ {state.lastSummary && (
1390
+ <div className="review" style={{ borderLeftColor: 'var(--canvas-accent)' }}>
1391
+ <span className="review-tag" style={{ color: 'var(--canvas-accent)' }}>
1392
+ Generated {new Date(state.lastSummary.at).toLocaleString()} · stub
1393
+ </span>
1394
+ <div className="canvas-md">
1395
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{state.lastSummary.text}</ReactMarkdown>
1396
+ </div>
1397
+ </div>
1398
+ )}
1399
+ <div className="note-list" style={{ maxHeight: 260 }}>
1400
+ {dates.map(date => (
1401
+ <div key={date}>
1402
+ <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, fontFamily: 'var(--canvas-mono)', padding: '6px 2px 2px' }}>
1403
+ {new Date(date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
1404
+ </div>
1405
+ {grouped[date].map(e => (
1406
+ <div key={e.id} className="note-row" style={{ position: 'relative' }}>
1407
+ <div className="note-text">{e.text}</div>
1408
+ <div className="row-actions">
1409
+ <button className="icon-btn" onClick={() => remove(e.id)}><Icon name="trash" size={11}/></button>
1410
+ </div>
1411
+ </div>
1412
+ ))}
1413
+ </div>
1414
+ ))}
1415
+ {entries.length === 0 && (
1416
+ <div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
1417
+ No entries yet. Drop a quick line about today.
1418
+ </div>
1419
+ )}
1420
+ </div>
1421
+ </>
1422
+ );
1423
+ }
1424
+
1425
  // ===== Stub =====
1426
  export function StubWidget({ meta }) {
1427
  return (
phd-advisor-frontend/src/components/canvas/canvasData.js CHANGED
@@ -100,6 +100,9 @@ export const WIDGET_CATALOG = [
100
  { type: 'gantt', name: 'Milestone Timeline', desc: 'Proposal → IRB → defense', icon: 'flag', cat: 'project', defaultSize: 'L', stub: true },
101
  { type: 'meeting-log', name: 'Meeting Log', desc: 'Per-stakeholder, last contact, actions', icon: 'message', cat: 'project', defaultSize: 'M' },
102
  { type: 'goals', name: 'Goals / OKRs', desc: 'Quarterly milestones with progress sliders', icon: 'bullseye', cat: 'project', defaultSize: 'M' },
 
 
 
103
 
104
  { type: 'mood', name: 'Mood / Burnout Check-in', desc: 'Daily slider, trend graph', icon: 'smile', cat: 'wellness', defaultSize: 'S', stub: true },
105
  { type: 'sleep', name: 'Sleep & Energy', desc: 'Correlate with productive days', icon: 'heart', cat: 'wellness', defaultSize: 'S', stub: true },
@@ -137,9 +140,70 @@ export const CATEGORIES = [
137
  { id: 'critic', label: 'Anti-yes-man', critic: true },
138
  ];
139
 
140
- // Workspace starts empty — users add widgets from the palette.
141
  export const DEFAULT_LAYOUT = [];
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  // Initial state when a widget is first added — minimal scaffolding, no demo content.
144
  export const EMPTY_STATE = {
145
  bibliography: { format: 'APA', entries: [] },
@@ -178,5 +242,8 @@ export const EMPTY_STATE = {
178
  outline: { items: [], expanded: {} },
179
  highlights: { items: [] },
180
  latex: { source: '', displayMode: true },
 
 
 
181
  };
182
 
 
100
  { type: 'gantt', name: 'Milestone Timeline', desc: 'Proposal → IRB → defense', icon: 'flag', cat: 'project', defaultSize: 'L', stub: true },
101
  { type: 'meeting-log', name: 'Meeting Log', desc: 'Per-stakeholder, last contact, actions', icon: 'message', cat: 'project', defaultSize: 'M' },
102
  { type: 'goals', name: 'Goals / OKRs', desc: 'Quarterly milestones with progress sliders', icon: 'bullseye', cat: 'project', defaultSize: 'M' },
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 },
 
140
  { id: 'critic', label: 'Anti-yes-man', critic: true },
141
  ];
142
 
143
+ // Workspace starts empty — users add widgets from the palette or pick a preset.
144
  export const DEFAULT_LAYOUT = [];
145
 
146
+ // Curated starter layouts. Each preset assigns its own widget IDs so reseeding
147
+ // won't collide with manually-added widgets.
148
+ const presetIds = (types) => types.map((t, i) => ({ id: `pre-${t.type}-${i}`, ...t }));
149
+ export const WORKSPACE_PRESETS = [
150
+ {
151
+ id: 'day1-phd',
152
+ name: 'Day-1 PhD',
153
+ desc: 'Get oriented: reading queue, bibliography, notes, deadlines, kanban, pomodoro.',
154
+ icon: 'sparkles',
155
+ layout: presetIds([
156
+ { type: 'reading-queue', size: 'M' },
157
+ { type: 'bibliography', size: 'M' },
158
+ { type: 'notes', size: 'M' },
159
+ { type: 'deadlines', size: 'S' },
160
+ { type: 'pomodoro', size: 'S' },
161
+ { type: 'kanban', size: 'L' },
162
+ ]),
163
+ },
164
+ {
165
+ id: 'writing-sprint',
166
+ name: 'Writing Sprint',
167
+ desc: 'Focus mode for drafting: writing pad, outline, LaTeX, highlights, pomodoro.',
168
+ icon: 'pencil',
169
+ layout: presetIds([
170
+ { type: 'writing', size: 'M' },
171
+ { type: 'outline', size: 'M' },
172
+ { type: 'pomodoro', size: 'S' },
173
+ { type: 'latex', size: 'M' },
174
+ { type: 'highlights', size: 'M' },
175
+ { type: 'bibliography', size: 'M' },
176
+ ]),
177
+ },
178
+ {
179
+ id: 'quals-prep',
180
+ name: 'Quals Prep',
181
+ desc: 'Lit-review heavy: bibliography, reading queue, notes, highlights, kanban.',
182
+ icon: 'book',
183
+ layout: presetIds([
184
+ { type: 'bibliography', size: 'L' },
185
+ { type: 'reading-queue', size: 'M' },
186
+ { type: 'notes', size: 'M' },
187
+ { type: 'highlights', size: 'M' },
188
+ { type: 'kanban', size: 'M' },
189
+ ]),
190
+ },
191
+ {
192
+ id: 'defense-mode',
193
+ name: 'Defense Mode',
194
+ desc: 'Final stretch: writing, outline, anti-yes-man critics, deadlines.',
195
+ icon: 'gavel',
196
+ layout: presetIds([
197
+ { type: 'writing', size: 'M' },
198
+ { type: 'outline', size: 'M' },
199
+ { type: 'reviewer-2', size: 'M', critic: true },
200
+ { type: 'devils-advocate', size: 'M', critic: true },
201
+ { type: 'scope-realism', size: 'M', critic: true },
202
+ { type: 'deadlines', size: 'S' },
203
+ ]),
204
+ },
205
+ ];
206
+
207
  // Initial state when a widget is first added — minimal scaffolding, no demo content.
208
  export const EMPTY_STATE = {
209
  bibliography: { format: 'APA', entries: [] },
 
242
  outline: { items: [], expanded: {} },
243
  highlights: { items: [] },
244
  latex: { source: '', displayMode: true },
245
+ calendar: { viewMonth: new Date().toISOString().slice(0, 7) },
246
+ activity: {},
247
+ documenter: { entries: [], lastSummary: null },
248
  };
249
 
phd-advisor-frontend/src/pages/CanvasPage.js CHANGED
@@ -6,13 +6,14 @@ import Sidebar from '../components/Sidebar';
6
  import AppHeader from '../components/AppHeader';
7
  import Icon from '../components/canvas/CanvasIcon';
8
  import {
9
- INSIGHTS, WIDGET_CATALOG, DEFAULT_LAYOUT, EMPTY_STATE,
10
  } from '../components/canvas/canvasData';
11
  import {
12
  BibliographyWidget, KanbanWidget, PomodoroWidget, WritingWidget,
13
  DeadlinesWidget, BudgetWidget, ReadingQueueWidget, NotesWidget,
14
  HabitsWidget, GoalsWidget, MeetingsWidget,
15
  OutlineWidget, HighlightsWidget, LatexWidget,
 
16
  StubWidget,
17
  } from '../components/canvas/CanvasWidgets';
18
  import {
@@ -23,17 +24,18 @@ import {
23
  AddCitationModal, AddTaskModal, AddDeadlineModal, LogWordsModal,
24
  ConfirmRemoveModal, ReadingPaperModal, BudgetItemModal,
25
  NoteModal, HabitModal, GoalModal, MeetingModal,
26
- PaletteModal, CommandPaletteModal,
27
  } from '../components/canvas/CanvasModals';
28
  import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
 
29
  import '../styles/CanvasPage.css';
30
 
31
  const LAYOUT_KEY = 'canvas-layout-v2';
32
  const STATES_KEY = 'canvas-states-v2';
33
  const VIEW_KEY = 'canvas-view-v2';
34
 
35
- function renderWidget(type, state, setState, openModal) {
36
- const props = { state, setState, openModal };
37
  switch (type) {
38
  case 'bibliography': return <BibliographyWidget {...props}/>;
39
  case 'kanban': return <KanbanWidget {...props}/>;
@@ -52,6 +54,9 @@ function renderWidget(type, state, setState, openModal) {
52
  case 'outline': return <OutlineWidget {...props}/>;
53
  case 'highlights': return <HighlightsWidget {...props}/>;
54
  case 'latex': return <LatexWidget {...props}/>;
 
 
 
55
  default: {
56
  const meta = WIDGET_CATALOG.find(w => w.type === type);
57
  return <StubWidget meta={meta}/>;
@@ -59,7 +64,7 @@ function renderWidget(type, state, setState, openModal) {
59
  }
60
  }
61
 
62
- function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver, onDragEnd, onDrop, state, setState, onRemove, onResize, openModal }) {
63
  const meta = WIDGET_CATALOG.find(w => w.type === widget.type);
64
  if (!meta) return null;
65
  const sizes = ['S', 'M', 'L'];
@@ -69,6 +74,7 @@ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver,
69
  <div
70
  className={`widget size-${widget.size} ${meta.critic ? 'critic' : ''} ${isDragging ? 'dragging' : ''} ${isDragOver ? 'drag-over' : ''}`}
71
  data-widget-id={widget.id}
 
72
  onDragOver={(e) => { e.preventDefault(); onDragOver(widget.id); }}
73
  onDrop={(e) => { e.preventDefault(); onDrop(widget.id); }}
74
  >
@@ -88,13 +94,13 @@ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver,
88
  </div>
89
  </div>
90
  <div className="widget-body">
91
- {renderWidget(widget.type, state, setState, openModal)}
92
  </div>
93
  </div>
94
  );
95
  }
96
 
97
- function InsightsView() {
98
  const [pinned, setPinned] = useState(new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id)));
99
  const togglePin = (id) => {
100
  const n = new Set(pinned);
@@ -102,6 +108,26 @@ function InsightsView() {
102
  setPinned(n);
103
  };
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  return (
106
  <>
107
  <div className="page-header">
@@ -133,9 +159,11 @@ function InsightsView() {
133
  </ul>
134
  </div>
135
  <div className="insight-actions">
136
- <button className="chip"><Icon name="message" size={11}/>Ask follow-up</button>
137
- <button className="chip"><Icon name="task" size={11}/>To task</button>
138
- <button className="chip"><Icon name="cite" size={11}/>Cite</button>
 
 
139
  <button className="chip"><Icon name="expand" size={11}/>Expand</button>
140
  <button className={`chip ${pinned.has(ins.id) ? 'pinned' : ''}`} onClick={() => togglePin(ins.id)}>
141
  <Icon name="pin" size={11}/>{pinned.has(ins.id) ? 'Pinned' : 'Pin'}
@@ -148,6 +176,29 @@ function InsightsView() {
148
  );
149
  }
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetStates }) {
152
  const [dragId, setDragId] = useState(null);
153
  const [dragOverId, setDragOverId] = useState(null);
@@ -166,8 +217,11 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
166
  onDragEnd();
167
  };
168
 
169
- const setWState = (type) => (newState) => {
170
- setWidgetStates(s => ({ ...s, [type]: newState }));
 
 
 
171
  };
172
 
173
  const removeWidget = (id, label) => {
@@ -187,11 +241,23 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
187
  const id = 'w-' + Date.now();
188
  setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]);
189
  if (EMPTY_STATE[meta.type]) {
190
- // Always reset to a fresh empty state when a widget is added (even if previously removed)
191
  setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) }));
192
  }
193
  };
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  const reset = () => {
196
  if (!window.confirm('Reset workspace? All widgets and content will be cleared.')) return;
197
  setLayout([]);
@@ -214,12 +280,14 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
214
  })}><Icon name="plus" size={13}/>Add widget</button>
215
  </div>
216
  </div>
 
 
 
217
  <div className="workspace">
218
  {layout.length === 0 && (
219
  <div className="empty-cell">
220
- <Icon name="layout" size={32} style={{ color: 'var(--canvas-text-4)' }}/>
221
- <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>Empty workspace</div>
222
- <div>Add widgets from the palette to start composing your canvas.</div>
223
  <button className="btn btn-primary" onClick={() => openModal('palette', { layout, onAdd: addWidget })} style={{ marginTop: 6 }}>
224
  <Icon name="plus" size={13}/>Add your first widget
225
  </button>
@@ -240,6 +308,7 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
240
  onRemove={removeWidget}
241
  onResize={resizeWidget}
242
  openModal={openModal}
 
243
  />
244
  ))}
245
  </div>
@@ -268,6 +337,7 @@ function ModalRouter({ modal, onClose }) {
268
  case 'goal': content = <GoalModal data={modal.data} onClose={onClose}/>; break;
269
  case 'meeting': content = <MeetingModal data={modal.data} onClose={onClose}/>; break;
270
  case 'command': content = <CommandPaletteModal data={modal.data} onClose={onClose}/>; break;
 
271
  default: return null;
272
  }
273
  return <div className="canvas-modal-backdrop" onClick={handleBackdropClick}>{content}</div>;
@@ -358,7 +428,11 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
358
  });
359
  }, [openModal, layout, toggleTheme, exportWorkspace]);
360
 
361
- // Esc closes modal, ⌘K opens command palette
 
 
 
 
362
  useEffect(() => {
363
  const k = (e) => {
364
  if (e.key === 'Escape') closeModal();
@@ -366,10 +440,14 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
366
  e.preventDefault();
367
  openCommandPalette();
368
  }
 
 
 
 
369
  };
370
  window.addEventListener('keydown', k);
371
  return () => window.removeEventListener('keydown', k);
372
- }, [closeModal, openCommandPalette]);
373
 
374
  const canvasSidebarItems = layout.map(w => {
375
  const meta = WIDGET_CATALOG.find(m => m.type === w.type);
@@ -406,7 +484,7 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
406
  <div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
407
  <div className="canvas-app-shell">
408
  <AppHeader
409
- currentPage="canvas"
410
  onNavigateToHome={onNavigateToHome}
411
  onNavigateToChat={onNavigateToChat}
412
  onNavigateToCanvas={(v) => setView(v || 'workspace')}
@@ -415,14 +493,17 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
415
  <button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
416
  <HelpCircle size={18}/>
417
  </button>
418
- <button className="icon-btn" onClick={openCommandPalette} title="Search & commands (⌘K)">
419
  <Icon name="search" size={16}/>
420
  </button>
 
 
 
421
  </AppHeader>
422
  <div className="canvas-content">
423
- {view === 'insights'
424
- ? <InsightsView/>
425
- : <WorkspaceView openModal={openModal} layout={layout} setLayout={setLayout} widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
426
  </div>
427
  </div>
428
  </div>
 
6
  import AppHeader from '../components/AppHeader';
7
  import Icon from '../components/canvas/CanvasIcon';
8
  import {
9
+ INSIGHTS, WIDGET_CATALOG, DEFAULT_LAYOUT, EMPTY_STATE, WORKSPACE_PRESETS,
10
  } from '../components/canvas/canvasData';
11
  import {
12
  BibliographyWidget, KanbanWidget, PomodoroWidget, WritingWidget,
13
  DeadlinesWidget, BudgetWidget, ReadingQueueWidget, NotesWidget,
14
  HabitsWidget, GoalsWidget, MeetingsWidget,
15
  OutlineWidget, HighlightsWidget, LatexWidget,
16
+ CalendarWidget, DocumenterWidget, ActivityWidget,
17
  StubWidget,
18
  } from '../components/canvas/CanvasWidgets';
19
  import {
 
24
  AddCitationModal, AddTaskModal, AddDeadlineModal, LogWordsModal,
25
  ConfirmRemoveModal, ReadingPaperModal, BudgetItemModal,
26
  NoteModal, HabitModal, GoalModal, MeetingModal,
27
+ PaletteModal, CommandPaletteModal, GlobalSearchModal,
28
  } from '../components/canvas/CanvasModals';
29
  import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
30
+ import DeliverablesView from '../components/canvas/CanvasDeliverables';
31
  import '../styles/CanvasPage.css';
32
 
33
  const LAYOUT_KEY = 'canvas-layout-v2';
34
  const STATES_KEY = 'canvas-states-v2';
35
  const VIEW_KEY = 'canvas-view-v2';
36
 
37
+ function renderWidget(type, state, setState, openModal, allStates) {
38
+ const props = { state, setState, openModal, allStates };
39
  switch (type) {
40
  case 'bibliography': return <BibliographyWidget {...props}/>;
41
  case 'kanban': return <KanbanWidget {...props}/>;
 
54
  case 'outline': return <OutlineWidget {...props}/>;
55
  case 'highlights': return <HighlightsWidget {...props}/>;
56
  case 'latex': return <LatexWidget {...props}/>;
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}/>;
 
64
  }
65
  }
66
 
67
+ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver, onDragEnd, onDrop, state, setState, onRemove, onResize, openModal, allStates }) {
68
  const meta = WIDGET_CATALOG.find(w => w.type === widget.type);
69
  if (!meta) return null;
70
  const sizes = ['S', 'M', 'L'];
 
74
  <div
75
  className={`widget size-${widget.size} ${meta.critic ? 'critic' : ''} ${isDragging ? 'dragging' : ''} ${isDragOver ? 'drag-over' : ''}`}
76
  data-widget-id={widget.id}
77
+ data-widget-type={widget.type}
78
  onDragOver={(e) => { e.preventDefault(); onDragOver(widget.id); }}
79
  onDrop={(e) => { e.preventDefault(); onDrop(widget.id); }}
80
  >
 
94
  </div>
95
  </div>
96
  <div className="widget-body">
97
+ {renderWidget(widget.type, state, setState, openModal, allStates)}
98
  </div>
99
  </div>
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);
 
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;
115
+ };
116
+
117
+ const sendToKanban = (ins) => {
118
+ if (!setWidgetStates) return;
119
+ const kanban = widgetStates.kanban || EMPTY_STATE.kanban;
120
+ const card = {
121
+ id: 'k' + Date.now(),
122
+ col: 'todo',
123
+ title: insightToTaskTitle(ins),
124
+ priority: 'med',
125
+ meta: `from Insights · ${ins.title}`,
126
+ };
127
+ setWidgetStates(s => ({ ...s, kanban: { ...kanban, cards: [...kanban.cards, card] } }));
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">
 
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'}
 
176
  );
177
  }
178
 
179
+ function PresetPicker({ onPick }) {
180
+ return (
181
+ <div className="canvas-presets">
182
+ <div className="canvas-presets-head">
183
+ <div className="canvas-presets-title">Start from a preset</div>
184
+ <div className="canvas-presets-sub">Or skip and add widgets one at a time.</div>
185
+ </div>
186
+ <div className="canvas-presets-grid">
187
+ {WORKSPACE_PRESETS.map(p => (
188
+ <button key={p.id} className="canvas-preset-card" onClick={() => onPick(p)}>
189
+ <div className="canvas-preset-icon"><Icon name={p.icon} size={18}/></div>
190
+ <div className="canvas-preset-content">
191
+ <div className="canvas-preset-name">{p.name}</div>
192
+ <div className="canvas-preset-desc">{p.desc}</div>
193
+ <div className="canvas-preset-meta">{p.layout.length} widgets</div>
194
+ </div>
195
+ </button>
196
+ ))}
197
+ </div>
198
+ </div>
199
+ );
200
+ }
201
+
202
  function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetStates }) {
203
  const [dragId, setDragId] = useState(null);
204
  const [dragOverId, setDragOverId] = useState(null);
 
217
  onDragEnd();
218
  };
219
 
220
+ const setWState = (type) => (updater) => {
221
+ setWidgetStates(s => ({
222
+ ...s,
223
+ [type]: typeof updater === 'function' ? updater(s[type]) : updater,
224
+ }));
225
  };
226
 
227
  const removeWidget = (id, label) => {
 
241
  const id = 'w-' + Date.now();
242
  setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]);
243
  if (EMPTY_STATE[meta.type]) {
 
244
  setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) }));
245
  }
246
  };
247
 
248
+ const applyPreset = (preset) => {
249
+ setLayout(preset.layout.map(w => ({ ...w })));
250
+ // Seed empty state for any widget types not already present
251
+ const seeds = {};
252
+ preset.layout.forEach(w => {
253
+ if (!widgetStates[w.type] && EMPTY_STATE[w.type]) {
254
+ seeds[w.type] = JSON.parse(JSON.stringify(EMPTY_STATE[w.type]));
255
+ }
256
+ });
257
+ if (Object.keys(seeds).length) setWidgetStates(s => ({ ...s, ...seeds }));
258
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `${preset.name} preset loaded`, kind: 'success' } }));
259
+ };
260
+
261
  const reset = () => {
262
  if (!window.confirm('Reset workspace? All widgets and content will be cleared.')) return;
263
  setLayout([]);
 
280
  })}><Icon name="plus" size={13}/>Add widget</button>
281
  </div>
282
  </div>
283
+ {layout.length === 0 && (
284
+ <PresetPicker onPick={applyPreset}/>
285
+ )}
286
  <div className="workspace">
287
  {layout.length === 0 && (
288
  <div className="empty-cell">
289
+ <Icon name="layout" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
290
+ <div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>Or build from scratch</div>
 
291
  <button className="btn btn-primary" onClick={() => openModal('palette', { layout, onAdd: addWidget })} style={{ marginTop: 6 }}>
292
  <Icon name="plus" size={13}/>Add your first widget
293
  </button>
 
308
  onRemove={removeWidget}
309
  onResize={resizeWidget}
310
  openModal={openModal}
311
+ allStates={widgetStates}
312
  />
313
  ))}
314
  </div>
 
337
  case 'goal': content = <GoalModal data={modal.data} onClose={onClose}/>; break;
338
  case 'meeting': content = <MeetingModal data={modal.data} onClose={onClose}/>; break;
339
  case 'command': content = <CommandPaletteModal data={modal.data} onClose={onClose}/>; break;
340
+ case 'global-search': content = <GlobalSearchModal data={modal.data} onClose={onClose}/>; break;
341
  default: return null;
342
  }
343
  return <div className="canvas-modal-backdrop" onClick={handleBackdropClick}>{content}</div>;
 
428
  });
429
  }, [openModal, layout, toggleTheme, exportWorkspace]);
430
 
431
+ const openGlobalSearch = useCallback(() => {
432
+ openModal('global-search', { states: widgetStates });
433
+ }, [openModal, widgetStates]);
434
+
435
+ // Esc closes modal, ⌘K opens command palette, ⌘/ opens global content search
436
  useEffect(() => {
437
  const k = (e) => {
438
  if (e.key === 'Escape') closeModal();
 
440
  e.preventDefault();
441
  openCommandPalette();
442
  }
443
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') {
444
+ e.preventDefault();
445
+ openGlobalSearch();
446
+ }
447
  };
448
  window.addEventListener('keydown', k);
449
  return () => window.removeEventListener('keydown', k);
450
+ }, [closeModal, openCommandPalette, openGlobalSearch]);
451
 
452
  const canvasSidebarItems = layout.map(w => {
453
  const meta = WIDGET_CATALOG.find(m => m.type === w.type);
 
484
  <div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
485
  <div className="canvas-app-shell">
486
  <AppHeader
487
+ currentPage={`canvas-${view}`}
488
  onNavigateToHome={onNavigateToHome}
489
  onNavigateToChat={onNavigateToChat}
490
  onNavigateToCanvas={(v) => setView(v || 'workspace')}
 
493
  <button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
494
  <HelpCircle size={18}/>
495
  </button>
496
+ <button className="icon-btn" onClick={openGlobalSearch} title="Search canvas content (⌘/)">
497
  <Icon name="search" size={16}/>
498
  </button>
499
+ <button className="icon-btn" onClick={openCommandPalette} title="Commands (⌘K)">
500
+ <Icon name="zap" size={16}/>
501
+ </button>
502
  </AppHeader>
503
  <div className="canvas-content">
504
+ {view === 'insights' && <InsightsView widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
505
+ {view === 'workspace' && <WorkspaceView openModal={openModal} layout={layout} setLayout={setLayout} widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
506
+ {view === 'deliverables' && <DeliverablesView allStates={widgetStates}/>}
507
  </div>
508
  </div>
509
  </div>
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
@@ -321,8 +321,8 @@
321
  gap: 24px;
322
  margin-bottom: 22px;
323
  }
324
- .canvas-page-with-sidebar .page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.015em; margin: 0; color: var(--canvas-text); }
325
- .canvas-page-with-sidebar .page-sub { color: var(--canvas-text-3); font-size: 13px; margin-top: 4px; }
326
  .canvas-page-with-sidebar .page-meta {
327
  font-family: var(--canvas-mono);
328
  font-size: 11px;
@@ -435,37 +435,43 @@
435
  .canvas-page-with-sidebar .workspace {
436
  display: grid;
437
  grid-template-columns: repeat(6, minmax(0, 1fr));
438
- gap: 14px;
439
  align-items: start;
440
  }
 
 
441
  .canvas-page-with-sidebar .widget {
442
  background: var(--canvas-surface);
443
- border: 1px solid var(--canvas-border);
444
- border-radius: var(--canvas-r-md);
445
  display: flex;
446
  flex-direction: column;
447
  min-height: 200px;
448
  position: relative;
449
  overflow: hidden;
450
- transition: border-color .15s, transform .15s, box-shadow .15s;
 
 
 
 
451
  }
452
  .canvas-page-with-sidebar .widget.size-S { grid-column: span 2; }
453
  .canvas-page-with-sidebar .widget.size-M { grid-column: span 3; }
454
  .canvas-page-with-sidebar .widget.size-L { grid-column: span 6; }
455
  .canvas-page-with-sidebar .widget.dragging { opacity: 0.4; }
456
- .canvas-page-with-sidebar .widget.drag-over { box-shadow: 0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow); }
457
- .canvas-page-with-sidebar .widget:hover { border-color: var(--canvas-border-2); }
458
- .canvas-page-with-sidebar .widget.critic { border-color: var(--canvas-critic-glow); }
459
- .canvas-page-with-sidebar .widget.critic:hover { border-color: var(--canvas-critic); }
460
- .canvas-page-with-sidebar .widget.critic::before {
461
- content: '';
462
- position: absolute;
463
- top: 0; left: 0; right: 0;
464
- height: 1px;
465
- background: linear-gradient(90deg, transparent, var(--canvas-critic), transparent);
466
- opacity: 0.7;
467
  }
468
- .canvas-page-with-sidebar[data-canvas-theme="light"] .widget { box-shadow: 0 1px 2px rgba(20,30,50,0.03); }
 
 
469
 
470
  /* Preserve 3-S / 2-M / 1-L per row at every desktop width.
471
  On phones (≤768px) just stack everything full-width for readability. */
@@ -476,11 +482,12 @@
476
  .canvas-page-with-sidebar .widget.size-L { grid-column: 1 / -1; }
477
  }
478
 
 
479
  .canvas-page-with-sidebar .widget-head {
480
  display: flex;
481
  align-items: center;
482
  gap: 8px;
483
- padding: 12px 14px 10px;
484
  cursor: grab;
485
  }
486
  .canvas-page-with-sidebar .widget-head:active { cursor: grabbing; }
@@ -488,18 +495,26 @@
488
  color: var(--canvas-text-4);
489
  display: grid;
490
  place-items: center;
491
- width: 16px;
 
 
492
  }
493
- .canvas-page-with-sidebar .widget-head:hover .drag-grip { color: var(--canvas-text-2); }
494
  .canvas-page-with-sidebar .widget-icon {
495
- width: 22px;
496
- height: 22px;
497
  display: grid;
498
  place-items: center;
499
- color: var(--canvas-accent);
500
  }
501
  .canvas-page-with-sidebar .widget.critic .widget-icon { color: var(--canvas-critic); }
502
- .canvas-page-with-sidebar .widget-title { font-weight: 600; font-size: 13px; flex: 1; letter-spacing: -0.005em; color: var(--canvas-text); }
 
 
 
 
 
 
503
  .canvas-page-with-sidebar .widget-tag,
504
  .canvas-modal-backdrop .widget-tag {
505
  font-size: 9.5px;
@@ -533,14 +548,82 @@
533
  .canvas-page-with-sidebar .size-pill:hover { background: var(--canvas-surface-3); color: var(--canvas-text); }
534
 
535
  .canvas-page-with-sidebar .widget-body {
536
- padding: 4px 14px 14px;
537
  flex: 1;
538
  display: flex;
539
  flex-direction: column;
540
- gap: 10px;
541
  min-height: 0;
542
  }
543
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  .canvas-page-with-sidebar .empty-cell {
545
  border: 1.5px dashed var(--canvas-border-2);
546
  border-radius: var(--canvas-r-md);
@@ -1184,6 +1267,597 @@ body[data-canvas-theme="light"] .canvas-modal-backdrop .critic-meter .bar { back
1184
  .canvas-page-with-sidebar .row-actions .icon-btn svg { width: 12px; height: 12px; }
1185
  .canvas-page-with-sidebar .tag-pill { display: inline-block; font-family: var(--canvas-mono); font-size: 9.5px; padding: 1px 6px; border-radius: 3px; background: var(--canvas-accent-glow); color: var(--canvas-accent); text-transform: uppercase; letter-spacing: 0.06em; }
1186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1187
  /* Compact markdown rendering inside note rows */
1188
  .canvas-page-with-sidebar .canvas-md p { margin: 0 0 4px; }
1189
  .canvas-page-with-sidebar .canvas-md p:last-child { margin-bottom: 0; }
 
321
  gap: 24px;
322
  margin-bottom: 22px;
323
  }
324
+ .canvas-page-with-sidebar .page-title { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; margin: 0; color: var(--canvas-text); }
325
+ .canvas-page-with-sidebar .page-sub { color: var(--canvas-text-3); font-size: 13px; margin-top: 6px; }
326
  .canvas-page-with-sidebar .page-meta {
327
  font-family: var(--canvas-mono);
328
  font-size: 11px;
 
435
  .canvas-page-with-sidebar .workspace {
436
  display: grid;
437
  grid-template-columns: repeat(6, minmax(0, 1fr));
438
+ gap: 18px;
439
  align-items: start;
440
  }
441
+ /* Notion-inspired widget treatment: hairline borders, generous padding,
442
+ no hard chrome — the content is the page, the frame is barely there. */
443
  .canvas-page-with-sidebar .widget {
444
  background: var(--canvas-surface);
445
+ border: 1px solid transparent;
446
+ border-radius: 6px;
447
  display: flex;
448
  flex-direction: column;
449
  min-height: 200px;
450
  position: relative;
451
  overflow: hidden;
452
+ transition: background .15s, border-color .15s, box-shadow .15s;
453
+ }
454
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .widget {
455
+ background: #FFFFFF;
456
+ border-color: rgba(15, 15, 15, 0.06);
457
  }
458
  .canvas-page-with-sidebar .widget.size-S { grid-column: span 2; }
459
  .canvas-page-with-sidebar .widget.size-M { grid-column: span 3; }
460
  .canvas-page-with-sidebar .widget.size-L { grid-column: span 6; }
461
  .canvas-page-with-sidebar .widget.dragging { opacity: 0.4; }
462
+ .canvas-page-with-sidebar .widget.drag-over {
463
+ box-shadow: 0 0 0 2px var(--canvas-accent), 0 4px 14px var(--canvas-accent-glow);
464
+ }
465
+ .canvas-page-with-sidebar .widget:hover {
466
+ background: var(--canvas-surface-2);
467
+ }
468
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .widget:hover {
469
+ background: rgba(0, 0, 0, 0.015);
470
+ border-color: rgba(15, 15, 15, 0.08);
 
 
471
  }
472
+ .canvas-page-with-sidebar .widget.critic { border-color: rgba(167, 139, 250, 0.18); }
473
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .widget.critic { border-color: rgba(139, 92, 246, 0.18); }
474
+ .canvas-page-with-sidebar .widget.critic:hover { border-color: var(--canvas-critic); }
475
 
476
  /* Preserve 3-S / 2-M / 1-L per row at every desktop width.
477
  On phones (≤768px) just stack everything full-width for readability. */
 
482
  .canvas-page-with-sidebar .widget.size-L { grid-column: 1 / -1; }
483
  }
484
 
485
+ /* Notion-style header: drag grip is hover-only, title is plain text. */
486
  .canvas-page-with-sidebar .widget-head {
487
  display: flex;
488
  align-items: center;
489
  gap: 8px;
490
+ padding: 14px 16px 8px;
491
  cursor: grab;
492
  }
493
  .canvas-page-with-sidebar .widget-head:active { cursor: grabbing; }
 
495
  color: var(--canvas-text-4);
496
  display: grid;
497
  place-items: center;
498
+ width: 14px;
499
+ opacity: 0;
500
+ transition: opacity .15s;
501
  }
502
+ .canvas-page-with-sidebar .widget:hover .widget-head .drag-grip { opacity: 0.6; }
503
  .canvas-page-with-sidebar .widget-icon {
504
+ width: 18px;
505
+ height: 18px;
506
  display: grid;
507
  place-items: center;
508
+ color: var(--canvas-text-3);
509
  }
510
  .canvas-page-with-sidebar .widget.critic .widget-icon { color: var(--canvas-critic); }
511
+ .canvas-page-with-sidebar .widget-title {
512
+ font-weight: 600;
513
+ font-size: 13.5px;
514
+ flex: 1;
515
+ letter-spacing: -0.005em;
516
+ color: var(--canvas-text);
517
+ }
518
  .canvas-page-with-sidebar .widget-tag,
519
  .canvas-modal-backdrop .widget-tag {
520
  font-size: 9.5px;
 
548
  .canvas-page-with-sidebar .size-pill:hover { background: var(--canvas-surface-3); color: var(--canvas-text); }
549
 
550
  .canvas-page-with-sidebar .widget-body {
551
+ padding: 4px 16px 18px;
552
  flex: 1;
553
  display: flex;
554
  flex-direction: column;
555
+ gap: 12px;
556
  min-height: 0;
557
  }
558
 
559
+ /* Preset picker (shown above the empty workspace) */
560
+ .canvas-page-with-sidebar .canvas-presets {
561
+ margin-bottom: 18px;
562
+ }
563
+ .canvas-page-with-sidebar .canvas-presets-head {
564
+ margin-bottom: 10px;
565
+ }
566
+ .canvas-page-with-sidebar .canvas-presets-title {
567
+ font-size: 16px;
568
+ font-weight: 600;
569
+ color: var(--canvas-text);
570
+ }
571
+ .canvas-page-with-sidebar .canvas-presets-sub {
572
+ font-size: 12.5px;
573
+ color: var(--canvas-text-3);
574
+ margin-top: 2px;
575
+ }
576
+ .canvas-page-with-sidebar .canvas-presets-grid {
577
+ display: grid;
578
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
579
+ gap: 10px;
580
+ }
581
+ .canvas-page-with-sidebar .canvas-preset-card {
582
+ background: transparent;
583
+ border: 1px solid rgba(15, 15, 15, 0.10);
584
+ border-radius: 8px;
585
+ padding: 16px;
586
+ display: flex;
587
+ gap: 12px;
588
+ text-align: left;
589
+ cursor: pointer;
590
+ transition: background .15s, border-color .15s;
591
+ font-family: inherit;
592
+ color: var(--canvas-text);
593
+ }
594
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-preset-card {
595
+ border-color: rgba(15, 15, 15, 0.12);
596
+ }
597
+ .canvas-page-with-sidebar .canvas-preset-card:hover {
598
+ background: var(--canvas-surface-2);
599
+ border-color: rgba(15, 15, 15, 0.15);
600
+ }
601
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-preset-card:hover {
602
+ background: rgba(0, 0, 0, 0.025);
603
+ }
604
+ .canvas-page-with-sidebar .canvas-preset-icon {
605
+ width: 32px;
606
+ height: 32px;
607
+ border-radius: 6px;
608
+ background: var(--canvas-surface-2);
609
+ color: var(--canvas-text-2);
610
+ display: grid;
611
+ place-items: center;
612
+ flex-shrink: 0;
613
+ font-size: 16px;
614
+ }
615
+ .canvas-page-with-sidebar .canvas-preset-content { flex: 1; min-width: 0; }
616
+ .canvas-page-with-sidebar .canvas-preset-name { font-size: 13.5px; font-weight: 600; color: var(--canvas-text); }
617
+ .canvas-page-with-sidebar .canvas-preset-desc { font-size: 11.5px; color: var(--canvas-text-3); margin-top: 3px; line-height: 1.45; }
618
+ .canvas-page-with-sidebar .canvas-preset-meta {
619
+ font-family: var(--canvas-mono);
620
+ font-size: 10px;
621
+ color: var(--canvas-text-4);
622
+ margin-top: 6px;
623
+ text-transform: uppercase;
624
+ letter-spacing: 0.06em;
625
+ }
626
+
627
  .canvas-page-with-sidebar .empty-cell {
628
  border: 1.5px dashed var(--canvas-border-2);
629
  border-radius: var(--canvas-r-md);
 
1267
  .canvas-page-with-sidebar .row-actions .icon-btn svg { width: 12px; height: 12px; }
1268
  .canvas-page-with-sidebar .tag-pill { display: inline-block; font-family: var(--canvas-mono); font-size: 9.5px; padding: 1px 6px; border-radius: 3px; background: var(--canvas-accent-glow); color: var(--canvas-accent); text-transform: uppercase; letter-spacing: 0.06em; }
1269
 
1270
+ /* ----- Deliverables view ----- */
1271
+
1272
+ /* Paper / document mode — three-column layout that collapses on narrow screens */
1273
+ .canvas-page-with-sidebar .deliverable-grid {
1274
+ display: grid;
1275
+ grid-template-columns: 180px minmax(0, 1fr) 240px;
1276
+ gap: 16px;
1277
+ align-items: start;
1278
+ }
1279
+ .canvas-page-with-sidebar .deliverable-nav {
1280
+ display: flex;
1281
+ flex-direction: column;
1282
+ gap: 4px;
1283
+ position: sticky;
1284
+ top: 0;
1285
+ }
1286
+
1287
+ /* Slides mode — thumbs on left, big canvas in middle, panel on right */
1288
+ .canvas-page-with-sidebar .deliverable-slides-grid {
1289
+ display: grid;
1290
+ grid-template-columns: 160px minmax(0, 1fr) 240px;
1291
+ gap: 16px;
1292
+ align-items: start;
1293
+ }
1294
+ @media (max-width: 1280px) {
1295
+ .canvas-page-with-sidebar .deliverable-slides-grid { grid-template-columns: 140px minmax(0, 1fr); }
1296
+ .canvas-page-with-sidebar .deliverable-slides-grid > .deliverable-insertables { display: none; }
1297
+ }
1298
+ @media (max-width: 768px) {
1299
+ .canvas-page-with-sidebar .deliverable-slides-grid { grid-template-columns: 1fr; }
1300
+ .canvas-page-with-sidebar .slide-thumbs { display: none; }
1301
+ }
1302
+
1303
+ /* Slide thumbnails column */
1304
+ .canvas-page-with-sidebar .slide-thumbs {
1305
+ display: flex;
1306
+ flex-direction: column;
1307
+ gap: 8px;
1308
+ position: sticky;
1309
+ top: 0;
1310
+ max-height: calc(100vh - 100px);
1311
+ overflow-y: auto;
1312
+ padding-right: 4px;
1313
+ }
1314
+ .canvas-page-with-sidebar .slide-thumb {
1315
+ display: flex;
1316
+ align-items: stretch;
1317
+ gap: 6px;
1318
+ padding: 0;
1319
+ background: transparent;
1320
+ border: none;
1321
+ cursor: pointer;
1322
+ font-family: inherit;
1323
+ text-align: left;
1324
+ }
1325
+ .canvas-page-with-sidebar .slide-thumb-num {
1326
+ width: 18px;
1327
+ font-family: var(--canvas-mono);
1328
+ font-size: 10px;
1329
+ color: var(--canvas-text-3);
1330
+ padding-top: 4px;
1331
+ flex-shrink: 0;
1332
+ text-align: right;
1333
+ }
1334
+ .canvas-page-with-sidebar .slide-thumb-canvas {
1335
+ flex: 1;
1336
+ background: #fff;
1337
+ border: 1px solid var(--canvas-border-2);
1338
+ border-radius: 4px;
1339
+ aspect-ratio: 16 / 9;
1340
+ padding: 5px 6px;
1341
+ overflow: hidden;
1342
+ color: #111;
1343
+ display: flex;
1344
+ flex-direction: column;
1345
+ gap: 3px;
1346
+ transition: all .15s;
1347
+ }
1348
+ .canvas-page-with-sidebar .slide-thumb:hover .slide-thumb-canvas {
1349
+ border-color: var(--canvas-accent);
1350
+ transform: translateY(-1px);
1351
+ }
1352
+ .canvas-page-with-sidebar .slide-thumb.active .slide-thumb-canvas {
1353
+ border: 2px solid var(--canvas-accent);
1354
+ box-shadow: 0 0 0 2px var(--canvas-accent-glow);
1355
+ padding: 4px 5px;
1356
+ }
1357
+ .canvas-page-with-sidebar .slide-thumb.active .slide-thumb-num { color: var(--canvas-accent); font-weight: 700; }
1358
+ .canvas-page-with-sidebar .slide-thumb-title {
1359
+ font-size: 8px;
1360
+ font-weight: 700;
1361
+ color: #1a1a1a;
1362
+ letter-spacing: -0.01em;
1363
+ line-height: 1.1;
1364
+ overflow: hidden;
1365
+ text-overflow: ellipsis;
1366
+ white-space: nowrap;
1367
+ }
1368
+ .canvas-page-with-sidebar .slide-thumb-body {
1369
+ font-size: 6.5px;
1370
+ color: #555;
1371
+ line-height: 1.25;
1372
+ overflow: hidden;
1373
+ flex: 1;
1374
+ }
1375
+
1376
+ /* Big slide canvas */
1377
+ .canvas-page-with-sidebar .slide-canvas-wrap {
1378
+ background: var(--canvas-surface-2);
1379
+ border: 1px solid var(--canvas-border);
1380
+ border-radius: 10px;
1381
+ padding: 18px;
1382
+ display: flex;
1383
+ align-items: center;
1384
+ justify-content: center;
1385
+ }
1386
+ .canvas-page-with-sidebar .slide-canvas {
1387
+ position: relative;
1388
+ width: 100%;
1389
+ max-width: 720px;
1390
+ aspect-ratio: 16 / 9;
1391
+ background: #fff;
1392
+ border-radius: 6px;
1393
+ box-shadow: 0 12px 32px rgba(0,0,0,0.18), 0 4px 10px rgba(0,0,0,0.10);
1394
+ padding: 36px 44px;
1395
+ display: flex;
1396
+ flex-direction: column;
1397
+ gap: 16px;
1398
+ color: #111;
1399
+ overflow: hidden;
1400
+ }
1401
+ .canvas-page-with-sidebar .slide-canvas::before {
1402
+ content: '';
1403
+ position: absolute;
1404
+ top: 0; left: 0; right: 0;
1405
+ height: 4px;
1406
+ background: linear-gradient(90deg, var(--canvas-accent), var(--canvas-critic));
1407
+ }
1408
+ .canvas-page-with-sidebar .slide-canvas-title {
1409
+ font-size: 26px;
1410
+ font-weight: 700;
1411
+ letter-spacing: -0.02em;
1412
+ color: #0f172a;
1413
+ line-height: 1.15;
1414
+ }
1415
+ .canvas-page-with-sidebar .slide-canvas-body {
1416
+ flex: 1;
1417
+ font-size: 15px;
1418
+ color: #1f2937;
1419
+ line-height: 1.55;
1420
+ overflow: hidden;
1421
+ }
1422
+ .canvas-page-with-sidebar .slide-canvas-body ul {
1423
+ list-style: none;
1424
+ padding: 0;
1425
+ margin: 0;
1426
+ display: flex;
1427
+ flex-direction: column;
1428
+ gap: 8px;
1429
+ }
1430
+ .canvas-page-with-sidebar .slide-canvas-body ul li {
1431
+ position: relative;
1432
+ padding-left: 22px;
1433
+ }
1434
+ .canvas-page-with-sidebar .slide-canvas-body ul li::before {
1435
+ content: '';
1436
+ position: absolute;
1437
+ left: 0;
1438
+ top: 9px;
1439
+ width: 7px;
1440
+ height: 7px;
1441
+ border-radius: 50%;
1442
+ background: var(--canvas-accent);
1443
+ }
1444
+ .canvas-page-with-sidebar .slide-paragraph {
1445
+ font-size: 17px;
1446
+ line-height: 1.6;
1447
+ }
1448
+ .canvas-page-with-sidebar .slide-placeholder {
1449
+ color: #9ca3af;
1450
+ font-style: italic;
1451
+ font-size: 14px;
1452
+ }
1453
+ .canvas-page-with-sidebar .slide-canvas-footer {
1454
+ position: absolute;
1455
+ bottom: 14px;
1456
+ right: 18px;
1457
+ font-family: var(--canvas-mono);
1458
+ font-size: 10px;
1459
+ color: #9ca3af;
1460
+ }
1461
+
1462
+ /* Section-check pill (used in both modes) */
1463
+ .canvas-page-with-sidebar .check-pill {
1464
+ display: inline-flex;
1465
+ align-items: center;
1466
+ gap: 6px;
1467
+ font-size: 11px;
1468
+ padding: 3px 9px;
1469
+ border-radius: 999px;
1470
+ background: var(--canvas-surface-2);
1471
+ color: var(--canvas-text-3);
1472
+ font-family: var(--canvas-sans);
1473
+ }
1474
+ .canvas-page-with-sidebar .check-pill[data-passed="true"] {
1475
+ background: rgba(16, 185, 129, 0.15);
1476
+ color: #10B981;
1477
+ }
1478
+
1479
+ /* Document-page preview (Google Docs feel) */
1480
+ .canvas-page-with-sidebar .doc-page {
1481
+ background: #ffffff;
1482
+ color: #1a1a1a;
1483
+ padding: 56px 72px;
1484
+ border-radius: 4px;
1485
+ box-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
1486
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
1487
+ max-width: 800px;
1488
+ margin: 0 auto;
1489
+ width: 100%;
1490
+ }
1491
+ .canvas-page-with-sidebar .doc-title {
1492
+ font-size: 28px;
1493
+ font-weight: 700;
1494
+ margin: 0 0 24px;
1495
+ letter-spacing: -0.02em;
1496
+ color: #0f172a;
1497
+ }
1498
+ .canvas-page-with-sidebar .doc-h2 {
1499
+ font-size: 18px;
1500
+ font-weight: 700;
1501
+ margin: 24px 0 8px;
1502
+ color: #0f172a;
1503
+ }
1504
+ .canvas-page-with-sidebar .doc-body { font-size: 14px; line-height: 1.7; color: #1f2937; }
1505
+ .canvas-page-with-sidebar .doc-body p { margin: 0 0 12px; }
1506
+ .canvas-page-with-sidebar .doc-body strong { color: #0f172a; }
1507
+ .canvas-page-with-sidebar .doc-section { margin-bottom: 8px; }
1508
+
1509
+ /* Paper-page preview (Overleaf feel — serif, two-column body) */
1510
+ .canvas-page-with-sidebar .paper-page {
1511
+ background: #fdfbf7;
1512
+ color: #1a1a1a;
1513
+ padding: 64px 72px;
1514
+ border-radius: 4px;
1515
+ box-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
1516
+ font-family: 'Georgia', 'Times New Roman', serif;
1517
+ max-width: 820px;
1518
+ margin: 0 auto;
1519
+ width: 100%;
1520
+ }
1521
+ .canvas-page-with-sidebar .paper-page-head {
1522
+ text-align: center;
1523
+ border-bottom: 1px solid #d1d5db;
1524
+ padding-bottom: 16px;
1525
+ margin-bottom: 22px;
1526
+ }
1527
+ .canvas-page-with-sidebar .paper-title {
1528
+ font-size: 22px;
1529
+ font-weight: 700;
1530
+ letter-spacing: -0.01em;
1531
+ color: #0f172a;
1532
+ }
1533
+ .canvas-page-with-sidebar .paper-byline {
1534
+ font-size: 12px;
1535
+ color: #6b7280;
1536
+ font-style: italic;
1537
+ margin-top: 6px;
1538
+ }
1539
+ .canvas-page-with-sidebar .paper-h2 {
1540
+ font-size: 14px;
1541
+ font-weight: 700;
1542
+ text-transform: uppercase;
1543
+ letter-spacing: 0.08em;
1544
+ color: #0f172a;
1545
+ margin: 22px 0 8px;
1546
+ }
1547
+ .canvas-page-with-sidebar .paper-body { font-size: 13px; line-height: 1.65; color: #1f2937; text-align: justify; }
1548
+ .canvas-page-with-sidebar .paper-body p { margin: 0 0 10px; text-indent: 1.5em; }
1549
+ .canvas-page-with-sidebar .paper-body p:first-child { text-indent: 0; }
1550
+ .canvas-page-with-sidebar .paper-empty { color: #9ca3af; font-style: italic; font-size: 12px; }
1551
+ .canvas-page-with-sidebar .paper-section { margin-bottom: 4px; }
1552
+
1553
+ /* ----- Notion-style single-surface deliverable editor ----- */
1554
+ .canvas-page-with-sidebar .notion-deliverable-grid {
1555
+ display: grid;
1556
+ grid-template-columns: 200px minmax(0, 1fr) 240px;
1557
+ gap: 28px;
1558
+ align-items: start;
1559
+ }
1560
+ @media (max-width: 1280px) {
1561
+ .canvas-page-with-sidebar .notion-deliverable-grid { grid-template-columns: 200px minmax(0, 1fr); }
1562
+ .canvas-page-with-sidebar .notion-deliverable-grid > .deliverable-insertables { display: none; }
1563
+ }
1564
+ @media (max-width: 768px) {
1565
+ .canvas-page-with-sidebar .notion-deliverable-grid { grid-template-columns: 1fr; }
1566
+ .canvas-page-with-sidebar .notion-toc { display: none; }
1567
+ }
1568
+
1569
+ /* Subtle TOC */
1570
+ .canvas-page-with-sidebar .notion-toc {
1571
+ display: flex;
1572
+ flex-direction: column;
1573
+ gap: 1px;
1574
+ position: sticky;
1575
+ top: 0;
1576
+ padding: 4px 0;
1577
+ }
1578
+ .canvas-page-with-sidebar .notion-toc-label {
1579
+ font-size: 11px;
1580
+ color: var(--canvas-text-4);
1581
+ font-weight: 500;
1582
+ padding: 4px 6px 8px;
1583
+ letter-spacing: 0;
1584
+ text-transform: none;
1585
+ }
1586
+ .canvas-page-with-sidebar .notion-toc-link {
1587
+ display: flex;
1588
+ align-items: center;
1589
+ gap: 8px;
1590
+ background: transparent;
1591
+ border: none;
1592
+ padding: 5px 8px;
1593
+ border-radius: 4px;
1594
+ cursor: pointer;
1595
+ font-family: inherit;
1596
+ font-size: 13px;
1597
+ color: var(--canvas-text-2);
1598
+ text-align: left;
1599
+ transition: background .12s, color .12s;
1600
+ }
1601
+ .canvas-page-with-sidebar .notion-toc-link:hover {
1602
+ background: rgba(255, 255, 255, 0.04);
1603
+ color: var(--canvas-text);
1604
+ }
1605
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .notion-toc-link:hover {
1606
+ background: rgba(0, 0, 0, 0.04);
1607
+ }
1608
+ .canvas-page-with-sidebar .notion-toc-link.active {
1609
+ background: rgba(255, 255, 255, 0.06);
1610
+ color: var(--canvas-text);
1611
+ font-weight: 500;
1612
+ }
1613
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .notion-toc-link.active {
1614
+ background: rgba(0, 0, 0, 0.05);
1615
+ }
1616
+ .canvas-page-with-sidebar .notion-toc-link-text {
1617
+ flex: 1;
1618
+ overflow: hidden;
1619
+ text-overflow: ellipsis;
1620
+ white-space: nowrap;
1621
+ }
1622
+ .canvas-page-with-sidebar .notion-toc-link-count {
1623
+ font-family: var(--canvas-mono);
1624
+ font-size: 10px;
1625
+ color: var(--canvas-text-4);
1626
+ }
1627
+
1628
+ /* The page itself */
1629
+ .canvas-page-with-sidebar .notion-page-wrap {
1630
+ display: flex;
1631
+ justify-content: center;
1632
+ }
1633
+ .canvas-page-with-sidebar .notion-page {
1634
+ background: var(--canvas-surface);
1635
+ width: 100%;
1636
+ max-width: 760px;
1637
+ padding: 60px 80px;
1638
+ border-radius: 4px;
1639
+ color: var(--canvas-text);
1640
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
1641
+ }
1642
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .notion-page {
1643
+ background: #ffffff;
1644
+ border: 1px solid rgba(15, 15, 15, 0.06);
1645
+ box-shadow: 0 1px 3px rgba(0,0,0,0.04);
1646
+ }
1647
+ .canvas-page-with-sidebar .notion-page.serif {
1648
+ font-family: 'Georgia', 'Times New Roman', serif;
1649
+ }
1650
+ .canvas-page-with-sidebar .notion-page-wrap.paper .notion-page {
1651
+ background: #fdfbf7;
1652
+ }
1653
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .notion-page-wrap.paper .notion-page {
1654
+ background: #fdfbf7;
1655
+ }
1656
+ .canvas-page-with-sidebar .notion-page-title {
1657
+ font-size: 40px;
1658
+ font-weight: 700;
1659
+ margin: 0 0 6px;
1660
+ letter-spacing: -0.025em;
1661
+ line-height: 1.15;
1662
+ color: var(--canvas-text);
1663
+ }
1664
+ .canvas-page-with-sidebar .notion-page-meta {
1665
+ font-size: 12.5px;
1666
+ color: var(--canvas-text-3);
1667
+ margin: 0 0 36px;
1668
+ }
1669
+
1670
+ .canvas-page-with-sidebar .notion-block {
1671
+ margin: 24px 0;
1672
+ position: relative;
1673
+ }
1674
+ .canvas-page-with-sidebar .notion-h2 {
1675
+ font-size: 24px;
1676
+ font-weight: 700;
1677
+ letter-spacing: -0.015em;
1678
+ margin: 0 0 8px;
1679
+ color: var(--canvas-text);
1680
+ }
1681
+ .canvas-page-with-sidebar .notion-h2.serif {
1682
+ font-size: 16px;
1683
+ text-transform: uppercase;
1684
+ letter-spacing: 0.06em;
1685
+ font-weight: 700;
1686
+ }
1687
+ .canvas-page-with-sidebar .notion-hint {
1688
+ font-size: 13.5px;
1689
+ color: var(--canvas-text-4);
1690
+ font-style: italic;
1691
+ margin: 4px 0 6px;
1692
+ }
1693
+
1694
+ /* The textarea, styled as Notion body text */
1695
+ .canvas-page-with-sidebar .notion-text {
1696
+ display: block;
1697
+ width: 100%;
1698
+ background: transparent;
1699
+ border: none;
1700
+ outline: none;
1701
+ resize: none;
1702
+ padding: 4px 0;
1703
+ margin: 0;
1704
+ font-family: inherit;
1705
+ font-size: 16px;
1706
+ line-height: 1.6;
1707
+ color: var(--canvas-text);
1708
+ overflow: hidden;
1709
+ min-height: 28px;
1710
+ }
1711
+ .canvas-page-with-sidebar .notion-text.serif {
1712
+ font-family: 'Georgia', 'Times New Roman', serif;
1713
+ font-size: 14.5px;
1714
+ line-height: 1.7;
1715
+ }
1716
+ .canvas-page-with-sidebar .notion-text::placeholder {
1717
+ color: var(--canvas-text-4);
1718
+ }
1719
+ .canvas-page-with-sidebar .notion-text:focus {
1720
+ background: rgba(0, 0, 0, 0.02);
1721
+ border-radius: 3px;
1722
+ padding: 4px 6px;
1723
+ margin: 0 -6px;
1724
+ }
1725
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .notion-text:focus {
1726
+ background: rgba(0, 0, 0, 0.025);
1727
+ }
1728
+
1729
+ /* Inline meta (check pills + word count) sits below the block */
1730
+ .canvas-page-with-sidebar .notion-block-meta {
1731
+ margin-top: 10px;
1732
+ display: flex;
1733
+ flex-wrap: wrap;
1734
+ gap: 6px;
1735
+ }
1736
+
1737
+ /* Notion-style callout box for AI suggestions */
1738
+ .canvas-page-with-sidebar .notion-callout {
1739
+ margin-top: 12px;
1740
+ background: rgba(99, 102, 241, 0.06);
1741
+ border-radius: 4px;
1742
+ padding: 14px 16px;
1743
+ display: flex;
1744
+ gap: 10px;
1745
+ font-size: 13.5px;
1746
+ line-height: 1.55;
1747
+ color: var(--canvas-text);
1748
+ }
1749
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .notion-callout {
1750
+ background: rgba(99, 102, 241, 0.07);
1751
+ }
1752
+ .canvas-page-with-sidebar .notion-callout > svg {
1753
+ color: var(--canvas-accent);
1754
+ flex-shrink: 0;
1755
+ margin-top: 2px;
1756
+ }
1757
+ .canvas-page-with-sidebar .notion-callout-label {
1758
+ font-size: 11px;
1759
+ text-transform: uppercase;
1760
+ letter-spacing: 0.06em;
1761
+ color: var(--canvas-accent);
1762
+ font-weight: 600;
1763
+ margin-bottom: 2px;
1764
+ }
1765
+
1766
+ /* Export dropdown — uses native <details> for zero-state-management */
1767
+ .canvas-page-with-sidebar .canvas-export-menu summary {
1768
+ list-style: none;
1769
+ }
1770
+ .canvas-page-with-sidebar .canvas-export-menu summary::-webkit-details-marker {
1771
+ display: none;
1772
+ }
1773
+ .canvas-page-with-sidebar .canvas-export-menu[open] summary {
1774
+ filter: brightness(1.05);
1775
+ }
1776
+ .canvas-page-with-sidebar .canvas-export-menu-list {
1777
+ position: absolute;
1778
+ top: calc(100% + 4px);
1779
+ right: 0;
1780
+ background: var(--canvas-surface);
1781
+ border: 1px solid var(--canvas-border-2);
1782
+ border-radius: 6px;
1783
+ box-shadow: var(--canvas-shadow-lg);
1784
+ padding: 4px;
1785
+ min-width: 180px;
1786
+ display: flex;
1787
+ flex-direction: column;
1788
+ z-index: 30;
1789
+ }
1790
+ .canvas-page-with-sidebar .canvas-export-menu-list button {
1791
+ text-align: left;
1792
+ background: transparent;
1793
+ border: none;
1794
+ padding: 7px 10px;
1795
+ border-radius: 4px;
1796
+ font-size: 13px;
1797
+ cursor: pointer;
1798
+ color: var(--canvas-text);
1799
+ }
1800
+ .canvas-page-with-sidebar .canvas-export-menu-list button:hover {
1801
+ background: rgba(255, 255, 255, 0.05);
1802
+ }
1803
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-export-menu-list button:hover {
1804
+ background: rgba(0, 0, 0, 0.05);
1805
+ }
1806
+
1807
+ /* Old paper/doc preview styles can stay below for the slides bottom-info case */
1808
+
1809
+ /* Responsive breakpoints for paper/document mode */
1810
+ @media (max-width: 1280px) {
1811
+ .canvas-page-with-sidebar .deliverable-grid { grid-template-columns: 160px minmax(0, 1fr); }
1812
+ .canvas-page-with-sidebar .deliverable-grid > .deliverable-insertables { display: none; }
1813
+ }
1814
+ @media (max-width: 768px) {
1815
+ .canvas-page-with-sidebar .deliverable-grid { grid-template-columns: 1fr; }
1816
+ .canvas-page-with-sidebar .doc-page,
1817
+ .canvas-page-with-sidebar .paper-page { padding: 32px 24px; }
1818
+ }
1819
+
1820
+ /* Insertable row in the right rail of Deliverables */
1821
+ .canvas-page-with-sidebar .canvas-insert-row {
1822
+ display: flex;
1823
+ align-items: center;
1824
+ gap: 8px;
1825
+ padding: 6px 8px;
1826
+ background: transparent;
1827
+ border: none;
1828
+ border-radius: 4px;
1829
+ text-align: left;
1830
+ cursor: pointer;
1831
+ font-family: inherit;
1832
+ color: var(--canvas-text);
1833
+ transition: background .12s;
1834
+ }
1835
+ .canvas-page-with-sidebar .canvas-insert-row:hover {
1836
+ background: rgba(255, 255, 255, 0.05);
1837
+ }
1838
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-insert-row:hover {
1839
+ background: rgba(0, 0, 0, 0.04);
1840
+ }
1841
+
1842
+ /* Cross-widget drop overlay — shows over the target widget body during drag */
1843
+ .canvas-page-with-sidebar .canvas-drop-overlay {
1844
+ position: absolute;
1845
+ inset: -4px;
1846
+ background: var(--canvas-accent-glow);
1847
+ border: 2px dashed var(--canvas-accent);
1848
+ border-radius: 9px;
1849
+ display: flex;
1850
+ align-items: center;
1851
+ justify-content: center;
1852
+ gap: 8px;
1853
+ font-size: 13px;
1854
+ font-weight: 600;
1855
+ color: var(--canvas-accent);
1856
+ z-index: 5;
1857
+ pointer-events: none;
1858
+ }
1859
+ .canvas-page-with-sidebar .canvas-drop-active { outline: none; }
1860
+
1861
  /* Compact markdown rendering inside note rows */
1862
  .canvas-page-with-sidebar .canvas-md p { margin: 0 0 4px; }
1863
  .canvas-page-with-sidebar .canvas-md p:last-child { margin-bottom: 0; }