// Deliverables view — multi-project PhD deliverable center. // Each "project" is a draft of a template (paper, slides, poster, CV, etc.). // You can keep many projects in flight, switch between them, version-rollback, // drag in citations from your Bibliography or arXiv, embed images, render math, // and export to Markdown / LaTeX / HTML / Print. // AI passes are stubbed (need LLM endpoint) but every static signal works today. import React, { useState, useMemo, useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; import Icon from './CanvasIcon'; import { MOD } from './platform'; import LatexEditor from './CanvasLatexEditor'; // Markdown plugins shared across all rendered blocks. remark-math + rehype-katex // give us real LaTeX math (`$...$` inline, `$$...$$` block) inside any preview. const REMARK_PLUGINS = [remarkGfm, remarkMath]; const REHYPE_PLUGINS = [rehypeKatex]; const fireToast = (msg, kind = 'success') => window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } })); const STORE_KEY = 'canvas-deliverables-v2'; const MAX_VERSIONS = 10; const newId = (p) => p + Math.random().toString(36).slice(2, 8); // ============================================================================ // Templates // ============================================================================ export const TEMPLATES = [ { id: 'research-paper', name: 'Security Assessment Report', desc: 'Executive summary → Scope → Findings → Risk rating → Remediation → Appendix', icon: 'book', mode: 'paper', sections: [ { id: 'abstract', name: 'Abstract', target: 250, hint: 'One paragraph: question, method, finding, implication.', checks: ['hasNumber', 'hasFinding'] }, { id: 'intro', name: 'Introduction', target: 1000, hint: 'Frame the problem, state the gap, name your contribution.', checks: ['hasGap', 'hasCitation'] }, { id: 'methods', name: 'Methods', target: 800, hint: 'Reproducibility-first: subjects, materials, procedure, analysis.', checks: ['hasCitation', 'hasNumber'] }, { id: 'results', name: 'Results', target: 800, hint: 'Lead with the effect. Numbers + figure refs. No interpretation here.', checks: ['hasNumber', 'hasFigure'] }, { id: 'discussion', name: 'Discussion', target: 1000, hint: 'What it means, what it doesn\'t, limits, future work.', checks: ['hasLimit', 'hasCitation'] }, { id: 'refs', name: 'References', target: 0, hint: 'Bibliography list. Drop @keys here from the Bibliography widget.', checks: [] }, ], }, { id: 'thesis-chapter', name: 'Incident Report', desc: 'Timeline → Impact → Root cause → Containment → Lessons learned', icon: 'book', mode: 'paper', sections: [ { id: 'overview', name: 'Overview', target: 200, hint: 'What this chapter does and why it\'s here.', checks: [] }, { id: 'background', name: 'Background', target: 1500, hint: 'Lit review focused on this chapter\'s question.', checks: ['hasCitation'] }, { id: 'methods', name: 'Methods', target: 1500, hint: 'Reproducibility-first.', checks: ['hasCitation', 'hasNumber'] }, { id: 'results', name: 'Results', target: 2000, hint: 'Findings + figures.', checks: ['hasFigure', 'hasNumber'] }, { id: 'discussion', name: 'Discussion', target: 1500, hint: 'How it fits the larger thesis.', checks: ['hasLimit'] }, ], }, { id: 'nsf-grfp', name: 'NSF GRFP', desc: 'Personal Statement (3 pages) + Research Plan (2 pages).', icon: 'award', mode: 'document', sections: [ { id: 'personal', name: 'Personal Statement', target: 1500, hint: 'Background, experiences, broader impacts. Write as a story.', checks: ['hasBroaderImpacts'] }, { id: 'research', name: 'Research Plan', target: 1000, hint: 'Question, hypothesis, approach, intellectual merit.', checks: ['hasHypothesis', 'hasMerit'] }, ], }, { id: 'conference-abstract', name: 'Conference Abstract', desc: 'Single section, 250 words. Lead with the result.', icon: 'send', mode: 'document', sections: [ { id: 'abs', name: 'Abstract', target: 250, hint: 'One paragraph. Lead with finding, end with implication.', checks: ['hasFinding', 'hasNumber'] }, ], }, { id: 'defense-slides', name: 'Defense Slides', desc: 'Title → Outline → Background → Question → Methods → Results → Discussion → Q&A', icon: 'kanban', mode: 'slides', sections: [ { id: 'title', name: 'Title slide', target: 30, hint: 'Title, your name, advisor, date.', checks: [] }, { id: 'outline', name: 'Outline', target: 60, hint: '5–7 bullet points covering the talk arc.', checks: [] }, { id: 'background', name: 'Background', target: 200, hint: 'Just enough context to follow the question.', checks: ['hasCitation'] }, { id: 'question', name: 'Question', target: 80, hint: 'Single sentence, falsifiable.', checks: [] }, { id: 'methods', name: 'Methods', target: 200, hint: 'High-level. Save details for backup slides.', checks: ['hasNumber'] }, { id: 'results', name: 'Results', target: 300, hint: 'One slide per finding. Lead with the headline.', checks: ['hasFigure', 'hasNumber'] }, { id: 'discussion', name: 'Discussion', target: 200, hint: 'Implications + limits + next steps.', checks: ['hasLimit'] }, { id: 'qa', name: 'Anticipated Q&A', target: 300, hint: 'Hardest 5 questions and your answers.', checks: [] }, ], }, { id: 'poster', name: 'Conference Poster', desc: '4-quadrant scientific poster: Intro · Methods · Results · Discussion.', icon: 'layout', mode: 'poster', sections: [ { id: 'title', name: 'Title & Authors', target: 30, hint: 'Title, your name, advisor, affiliation.', checks: [] }, { id: 'intro', name: 'Introduction', target: 200, hint: 'Question, gap, why-care.', checks: ['hasGap'] }, { id: 'methods', name: 'Methods', target: 200, hint: 'High-level: subjects, design, analysis.', checks: ['hasNumber'] }, { id: 'results', name: 'Results', target: 250, hint: 'Headline finding + 1–2 figures.', checks: ['hasFigure', 'hasNumber'] }, { id: 'discussion', name: 'Discussion', target: 200, hint: 'What it means + next steps.', checks: ['hasLimit'] }, { id: 'refs', name: 'References / Acks', target: 80, hint: '5–10 citations + funding + contact.', checks: ['hasCitation'] }, ], }, { id: 'cv', name: 'Academic CV', desc: 'Standard sections: Education · Pubs · Talks · Awards · Service · Skills.', icon: 'user', mode: 'document', sections: [ { id: 'header', name: 'Header', target: 40, hint: 'Name, position, affiliation, contact.', checks: [] }, { id: 'education', name: 'Education', target: 100, hint: 'Most recent first. Degree · Year · Institution.', checks: [] }, { id: 'publications', name: 'Publications', target: 300, hint: 'Drop @keys from Bibliography. Group by type if needed.', checks: ['hasCitation'] }, { id: 'talks', name: 'Invited talks & posters', target: 150, hint: 'Title · Venue · Year.', checks: [] }, { id: 'awards', name: 'Awards & funding', target: 100, hint: 'Most recent first. Amount + year if applicable.', checks: [] }, { id: 'service', name: 'Service & teaching', target: 100, hint: 'Reviewing, mentorship, TA roles.', checks: [] }, { id: 'skills', name: 'Skills', target: 60, hint: 'Methods, languages, software.', checks: [] }, ], }, { id: 'cover-letter', name: 'Cover Letter', desc: 'For job applications, journal submissions, or postdoc inquiries.', icon: 'send', mode: 'document', sections: [ { id: 'header', name: 'Header', target: 40, hint: 'Date, recipient, salutation.', checks: [] }, { id: 'opener', name: 'Opening paragraph', target: 100, hint: 'Why you\'re writing + the position/journal.', checks: [] }, { id: 'body', name: 'Why me', target: 250, hint: 'Specific achievements that match the call. Numbers > adjectives.', checks: ['hasNumber'] }, { id: 'fit', name: 'Why this place', target: 150, hint: 'What about this group / journal makes it the right fit.', checks: [] }, { id: 'close', name: 'Close', target: 60, hint: 'Thanks + next step + signature.', checks: [] }, ], }, { id: 'irb-protocol', name: 'IRB Protocol', desc: 'Standard sections for human-subjects research approval.', icon: 'shield', mode: 'document', sections: [ { id: 'overview', name: 'Study overview', target: 200, hint: 'One-paragraph summary of purpose and procedures.', checks: [] }, { id: 'background', name: 'Background & significance', target: 400, hint: 'Why this study? What gap does it close?', checks: ['hasGap', 'hasCitation'] }, { id: 'aims', name: 'Specific aims & hypotheses', target: 250, hint: '2–3 aims. Each falsifiable.', checks: ['hasHypothesis'] }, { id: 'participants', name: 'Participants & recruitment', target: 300, hint: 'Inclusion/exclusion criteria, sample size, recruitment plan.', checks: ['hasNumber'] }, { id: 'procedures', name: 'Procedures', target: 500, hint: 'Step-by-step what subjects experience. Time burden in minutes.', checks: ['hasNumber'] }, { id: 'risks', name: 'Risks & mitigation', target: 200, hint: 'Anticipated risks (physical, psychological, privacy) + mitigations.', checks: ['hasLimit'] }, { id: 'benefits', name: 'Benefits', target: 100, hint: 'Direct + societal benefits. Be honest about minimal direct benefits.', checks: [] }, { id: 'consent', name: 'Consent process', target: 200, hint: 'Who consents, when, written or verbal, capacity considerations.', checks: [] }, { id: 'data', name: 'Data handling & confidentiality', target: 200, hint: 'Storage, access, identifiers, retention period.', checks: [] }, ], }, { id: 'meeting-prep', name: 'Advisor Meeting Prep', desc: 'Bring this to your 1:1 — agenda, updates, decisions needed, follow-ups.', icon: 'message', mode: 'document', sections: [ { id: 'agenda', name: 'Agenda', target: 80, hint: '3–5 bullets ranked by priority.', checks: [] }, { id: 'progress', name: 'Progress since last meeting', target: 200, hint: 'What you actually did, with numbers when possible.', checks: ['hasNumber'] }, { id: 'blockers', name: 'Blockers', target: 150, hint: 'What you need from them to move forward.', checks: ['hasLimit'] }, { id: 'decisions', name: 'Decisions needed', target: 200, hint: 'Frame as A/B options with your recommendation.', checks: [] }, { id: 'questions', name: 'Questions', target: 150, hint: 'Open questions you genuinely want their take on.', checks: [] }, { id: 'followup', name: 'Action items (post-meeting)', target: 100, hint: 'Fill in during/after. Owner + due date for each.', checks: [] }, ], }, { id: 'dissertation-formatting', name: 'Dissertation Formatting Checklist', desc: 'Catch-everything pass before ProQuest submission.', icon: 'shield', mode: 'document', sections: [ { id: 'frontmatter', name: 'Front matter', target: 0, hint: 'Title page, copyright, abstract, dedication, acknowledgements, ToC, list of figures/tables.', checks: [] }, { id: 'margins', name: 'Margins & spacing', target: 0, hint: 'Verify school requirements. Most: 1" margins, double-spaced body, single-spaced quotes/captions.', checks: ['hasNumber'] }, { id: 'fonts', name: 'Fonts & typography', target: 0, hint: 'One body font (Times/Garamond/Cambria) at 12pt. Captions 10–11pt. Headings consistent.', checks: ['hasNumber'] }, { id: 'pagenumbers', name: 'Page numbering', target: 0, hint: 'Roman for front matter, Arabic from Intro onward. Check section breaks.', checks: [] }, { id: 'figures', name: 'Figures & tables', target: 0, hint: 'Captions below figures, above tables. Numbered. Cited in text before they appear.', checks: ['hasFigure'] }, { id: 'citations', name: 'Citations & references', target: 0, hint: 'Consistent style throughout. Every cite has a reference; every reference is cited.', checks: ['hasCitation'] }, { id: 'appendices', name: 'Appendices', target: 0, hint: 'Lettered (A, B, C). Each cited in the body at least once.', checks: [] }, { id: 'proquest', name: 'ProQuest submission', target: 0, hint: 'PDF/A format, embedded fonts, no broken links, abstract under word limit.', checks: [] }, ], }, { id: 'faculty-hunt', name: 'Faculty / Advisor Hunt', desc: 'For prospective PhDs or finding committee members — research the people.', icon: 'user', mode: 'document', sections: [ { id: 'criteria', name: 'What you\'re looking for', target: 150, hint: 'Research area, methodology, working style, mentorship reputation.', checks: [] }, { id: 'shortlist', name: 'Shortlist (5–10 names)', target: 400, hint: 'For each: name, institution, 2–3 representative papers, why they fit.', checks: ['hasCitation'] }, { id: 'pubs', name: 'Recent publications', target: 300, hint: 'What have they published in the last 2 years? Drop @keys from Bibliography.', checks: ['hasCitation'] }, { id: 'students', name: 'Current/recent students', target: 200, hint: 'Lab size, time-to-defense, where students go after.', checks: ['hasNumber'] }, { id: 'reachout', name: 'Outreach plan', target: 200, hint: 'When to email, what to send, who to mention.', checks: [] }, { id: 'notes', name: 'Conversation notes', target: 0, hint: 'After meetings/emails — vibes, fit signals, red flags.', checks: [] }, ], }, { id: 'research-statement', name: 'Research Statement', desc: 'For faculty applications: past work, current direction, future arc.', icon: 'sparkles', mode: 'document', sections: [ { id: 'overview', name: 'Overview', target: 200, hint: 'One paragraph: your research identity in 2–3 sentences.', checks: [] }, { id: 'past', name: 'Past work', target: 600, hint: 'What you\'ve done. Lead with results, cite your own papers.', checks: ['hasCitation', 'hasFinding'] }, { id: 'current', name: 'Current direction', target: 400, hint: 'What you\'re working on now and why it matters.', checks: ['hasGap'] }, { id: 'future', name: 'Future research', target: 600, hint: '3–5 year arc. 1–2 funding-ready specific aims.', checks: ['hasHypothesis'] }, { id: 'broader', name: 'Broader impacts', target: 200, hint: 'Outreach, mentorship, what your group will look like.', checks: ['hasBroaderImpacts'] }, ], }, ]; // ============================================================================ // Slash command catalog — typed at the start of a line in any section editor. // ============================================================================ const SLASH_COMMANDS = [ { id: 'h2', label: 'Heading', kind: 'block', icon: 'list', insert: () => '## Heading\n' }, { id: 'h3', label: 'Subheading', kind: 'block', icon: 'list', insert: () => '### Subheading\n' }, { id: 'list', label: 'Bullet list', kind: 'block', icon: 'list', insert: () => '- ' }, { id: 'todo', label: 'To-do', kind: 'block', icon: 'task', insert: () => '- [ ] ' }, { id: 'numbered', label: 'Numbered list', kind: 'block', icon: 'list', insert: () => '1. ' }, { id: 'quote', label: 'Quote', kind: 'block', icon: 'cite', insert: () => '> ' }, { id: 'callout', label: 'Callout', kind: 'block', icon: 'sparkles', insert: () => '> [!note]\n> ' }, { id: 'divider', label: 'Divider', kind: 'block', icon: 'list', insert: () => '\n---\n\n' }, { id: 'code', label: 'Code block', kind: 'block', icon: 'flask', insert: () => '```\n\n```\n' }, { id: 'math', label: 'Equation', kind: 'block', icon: 'flask', insert: () => '$$\nE = mc^2\n$$\n' }, { id: 'inline-math', label: 'Inline equation', kind: 'inline', icon: 'flask', insert: () => '$x^2$' }, { id: 'image', label: 'Image (paste URL)', kind: 'block', icon: 'download', insert: () => '![caption](https://)' }, { id: 'cite', label: 'Citation @key', kind: 'inline', icon: 'book', insert: () => '(@key)' }, { id: 'bold', label: 'Bold', kind: 'inline', icon: 'pencil', insert: () => '**bold**' }, { id: 'italic', label: 'Italic', kind: 'inline', icon: 'pencil', insert: () => '*italic*' }, ]; // ============================================================================ // Static "missing" checks // ============================================================================ const CHECKS = { hasNumber: { test: (s) => /\d/.test(s), label: 'Mentions at least one number' }, hasCitation: { test: (s) => /@\w+/.test(s), label: 'Cites at least one source (@key)' }, hasFinding: { test: (s) => /\b(we (find|show|report|demonstrate)|finding|result)/i.test(s), label: 'States a finding' }, hasGap: { test: (s) => /\b(gap|lack|missing|unknown|unclear|despite)/i.test(s), label: 'Names a gap' }, hasFigure: { test: (s) => /\b(fig(ure)?|table)\.?\s*\d/i.test(s), label: 'References a figure or table' }, hasLimit: { test: (s) => /\b(limit|caveat|however|future work|did not|cannot)/i.test(s), label: 'Acknowledges a limit' }, hasBroaderImpacts: { test: (s) => /\b(broader impact|outreach|community|underrepresented|access|teaching)/i.test(s), label: 'Addresses broader impacts' }, hasHypothesis: { test: (s) => /\b(hypothes|predict|aim ?\d)/i.test(s), label: 'States a hypothesis or aim' }, hasMerit: { test: (s) => /\b(intellectual merit|novel|advances|contribut)/i.test(s), label: 'Frames intellectual merit' }, }; const wordCount = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length; const readingMinutes = (n) => Math.max(1, Math.round(n / 220)); // ============================================================================ // Exporters // ============================================================================ const exportMarkdown = (template, sections) => [ `# ${template.name}\n`, ...template.sections.map(s => `## ${s.name}\n\n${sections[s.id] || ''}\n`), ].join('\n'); const exportLatex = (template, sections) => [ '\\documentclass{article}', `\\title{${template.name}}`, '\\begin{document}', '\\maketitle', ...template.sections.map(s => `\n\\section{${s.name}}\n${sections[s.id] || ''}\n`), '\\end{document}', ].join('\n'); const exportHtml = (template, sections) => [ '', `${template.name}`, `

${template.name}

`, ...template.sections.map(s => `

${s.name}

${(sections[s.id] || '').replace(/\n/g, '
')}

`), '', ].join('\n'); const downloadFile = (filename, mime, contents) => { const blob = new Blob([contents], { type: mime }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); }; // ============================================================================ // Project store — multi-project (was: single-template). Migrates v1 if found. // ============================================================================ const loadStore = () => { try { const v2 = JSON.parse(localStorage.getItem(STORE_KEY) || 'null'); if (v2) return v2; // Migrate v1: turn each templateId entry into a project. const v1 = JSON.parse(localStorage.getItem('canvas-deliverables-v1') || '{}'); if (v1 && v1.templates) { const projects = {}; let activeId; Object.entries(v1.templates).forEach(([tid, sec]) => { const id = newId('p-'); const { _aiNotes, ...rest } = sec; projects[id] = { id, name: TEMPLATES.find(t => t.id === tid)?.name || tid, templateId: tid, sections: rest, versions: [], aiNotes: _aiNotes || null, createdAt: Date.now(), }; if (tid === v1.activeTemplateId) activeId = id; }); return { activeProjectId: activeId, projects }; } } catch { /* fallthrough */ } return { activeProjectId: null, projects: {} }; }; // ============================================================================ // Main view // ============================================================================ const DeliverablesView = ({ allStates }) => { const [store, setStore] = useState(loadStore); useEffect(() => { localStorage.setItem(STORE_KEY, JSON.stringify(store)); }, [store]); const project = store.projects[store.activeProjectId] || null; const template = project ? TEMPLATES.find(t => t.id === project.templateId) : null; const sections = project?.sections || {}; const [activeSectionId, setActiveSectionId] = useState(template?.sections[0]?.id); const [generatingAi, setGeneratingAi] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); // Normalize active section when project/template changes useEffect(() => { if (!template) return; const valid = template.sections.some(s => s.id === activeSectionId); if (!valid) setActiveSectionId(template.sections[0].id); }, [project?.id, template, activeSectionId]); // ---------- Project ops ---------- const createProject = (templateId, name) => { const id = newId('p-'); const t = TEMPLATES.find(x => x.id === templateId); setStore(s => ({ activeProjectId: id, projects: { ...s.projects, [id]: { id, name: name || `${t.name} draft`, templateId, sections: {}, versions: [], aiNotes: null, createdAt: Date.now() }, }, })); fireToast(`New ${t.name} draft created`); }; const switchProject = (id) => setStore(s => ({ ...s, activeProjectId: id })); const renameProject = (name) => { if (!project) return; setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], name } } })); }; const deleteProject = () => { if (!project) return; if (!window.confirm(`Delete "${project.name}"? This can't be undone.`)) return; setStore(s => { const { [project.id]: _, ...rest } = s.projects; const nextActive = Object.keys(rest)[0] || null; return { activeProjectId: nextActive, projects: rest }; }); }; const closeProject = () => setStore(s => ({ ...s, activeProjectId: null })); // ---------- Section ops + auto-versioning ---------- const updateSection = (id, value) => { setStore(s => { const proj = s.projects[project.id]; const oldText = proj.sections[id] || ''; // Snapshot a version every time a section gains/loses ≥80 chars (rough save cadence) const shouldSnap = Math.abs(value.length - oldText.length) >= 80; const newVersions = shouldSnap ? [{ at: Date.now(), sectionId: id, snapshot: { ...proj.sections } }, ...proj.versions].slice(0, MAX_VERSIONS) : proj.versions; return { ...s, projects: { ...s.projects, [project.id]: { ...proj, sections: { ...proj.sections, [id]: value }, versions: newVersions }, }, }; }); }; const restoreVersion = (v) => { if (!window.confirm('Restore this version? Current text will be replaced.')) return; setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], sections: v.snapshot } }, })); fireToast('Restored'); }; // ---------- AI pass (stub) ---------- // TODO(LLM): POST {project, sections, canvas} → {notes:[{sectionId,msg}]} const runAiPass = () => { setGeneratingAi(true); setTimeout(() => { const notes = template.sections.map(s => { const text = sections[s.id] || ''; const wc = wordCount(text); if (wc === 0) return { sectionId: s.id, msg: `Empty — start with: "${s.hint}"` }; if (wc < s.target * 0.3) return { sectionId: s.id, msg: `Thin (${wc} words). Target ${s.target}.` }; return { sectionId: s.id, msg: `Looks reasonable for length (${wc} words). LLM-pass would suggest specifics here.` }; }); setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], aiNotes: notes } } })); setGeneratingAi(false); fireToast('AI pass complete (stub)'); }, 700); }; // ---------- Insertables: Bibliography + Highlights + Outline + Drafts + arXiv search ---------- const localInsertables = useMemo(() => { const items = []; (allStates?.bibliography?.entries || []).forEach(e => items.push({ kind: 'cite', label: e.title, snippet: ` (${e.authors}, ${e.year}; @${e.key})` })); (allStates?.highlights?.items || []).forEach(h => items.push({ kind: 'quote', label: h.text.slice(0, 60), snippet: `"${h.text}"${h.citeKey ? ` (@${h.citeKey})` : ''}` })); (allStates?.outline?.items || []).forEach(o => items.push({ kind: 'outline', label: o.text || '(empty)', snippet: '\n' + ' '.repeat(o.depth) + '- ' + (o.text || '') })); (allStates?.writing?.chapters || []).forEach(c => items.push({ kind: 'draft', label: c.name, snippet: c.draft || '' })); return items; }, [allStates]); const insertIntoActive = (snippet) => { if (!activeSectionId) return; const cur = sections[activeSectionId] || ''; updateSection(activeSectionId, cur + (cur && !cur.endsWith('\n') ? ' ' : '') + snippet); fireToast('Inserted into ' + template.sections.find(s => s.id === activeSectionId)?.name); }; // ---------- Export ---------- const exportAs = (format) => { const filename = `${project.name.replace(/\s+/g, '_')}.${format === 'latex' ? 'tex' : format === 'markdown' ? 'md' : 'html'}`; const mime = format === 'html' ? 'text/html' : 'text/plain'; const contents = format === 'markdown' ? exportMarkdown(template, sections) : format === 'latex' ? exportLatex(template, sections) : exportHtml(template, sections); downloadFile(filename, mime, contents); fireToast(`Exported ${filename}`); }; // ---------- Aggregates ---------- const totalWords = template ? template.sections.reduce((sum, s) => sum + wordCount(sections[s.id]), 0) : 0; const totalTarget = template ? template.sections.reduce((sum, s) => sum + s.target, 0) : 0; const projectList = Object.values(store.projects).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); // ---------- Empty state: project picker / template picker ---------- if (!project) { return ( <>

Documents

Security deliverables hub — policies, IR reports, audit evidence, and architecture briefs. Drafts auto-save. Versions kept for rollback. {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
{/* Existing projects, if any */} {projectList.length > 0 && (
Continue working
Drafts you've started.
{projectList.map(p => { const t = TEMPLATES.find(t => t.id === p.templateId); if (!t) return null; const wc = t.sections.reduce((sum, s) => sum + wordCount(p.sections[s.id]), 0); const target = t.sections.reduce((sum, s) => sum + s.target, 0); return ( ); })}
)} {/* Templates */}
{projectList.length > 0 ? 'Or start a new draft' : 'Pick a template'}
9 templates · paper, slides, poster, CV, cover letter, and more.
{TEMPLATES.map(t => ( ))}
{/* TODO(LLM): "Upload project brief → AI generates a custom outline" */}
From a project brief
Upload your brief and the AI will draft a custom outline. (Needs LLM endpoint — coming soon.)
); } // ============================================================================ // Editor: project tabs + header + per-mode editor // ============================================================================ const aiNotes = project.aiNotes; const ProjectTabs = (
{projectList.map(p => { const t = TEMPLATES.find(x => x.id === p.templateId); return ( ); })}
New
{TEMPLATES.map(t => ( ))}
); const Header = (
renameProject(e.target.value)} />
{template.name} · {totalWords} / {totalTarget} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
Export
); const HistoryPanel = historyOpen && (
Version history · {project.versions.length}
{project.versions.length === 0 ? (
Snapshots auto-save every ~80 characters of edits.
) : ( project.versions.map((v, i) => { const sec = template.sections.find(s => s.id === v.sectionId); return ( ); }) )}
); const InsertPanel = (
From canvas · {localInsertables.length}
{localInsertables.length === 0 && (
Add a Bibliography, Highlights, Outline, or Writing widget to your canvas to surface its content here.
)} {localInsertables.map((it, i) => ( ))}
); // ---------- POSTER MODE — 4-quadrant + 2 banner sections ---------- if (template.mode === 'poster') { const layout = template.sections; return ( <> {ProjectTabs} {Header} {HistoryPanel}
{InsertPanel}
); } // ---------- SLIDES MODE — Google Slides feel ---------- if (template.mode === 'slides') { const activeIdx = template.sections.findIndex(s => s.id === activeSectionId); const active = template.sections[activeIdx] || template.sections[0]; const text = sections[active.id] || ''; const aiForSlide = aiNotes && aiNotes.find(n => n.sectionId === active.id); const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean); return ( <> {ProjectTabs} {Header} {HistoryPanel}
{template.sections.length} slides
{template.sections.map((s, i) => { const t = sections[s.id] || ''; const tLines = t.split(/\n+/).map(l => l.trim()).filter(Boolean); return ( ); })}
{active.name}
{lines.length === 0 ? (
{active.hint}
) : lines.length === 1 ? (
{lines[0]}
) : (
    {lines.map((l, j) =>
  • {l}
  • )}
)}
{activeIdx + 1} / {template.sections.length}
Slide content · One bullet per line {wordCount(text)}{active.target ? `/${active.target}` : ''} words
updateSection(active.id, v)} placeholder={active.hint} rows={6} /> {(active.checks || []).length > 0 && (
{active.checks.map(c => { const check = CHECKS[c]; if (!check) return null; const passed = check.test(text); return ( {passed ? : } {check.label} ); })}
)} {aiForSlide && (
AI suggestion · stub
{aiForSlide.msg}
)}
{InsertPanel}
); } // ---------- PAPER / DOCUMENT MODE — Notion single-surface page ---------- const paperLike = template.mode === 'paper'; // For paper-mode projects: optional LaTeX editor (CodeMirror + LaTeX.js preview). // Source stored as a single string at project.latexSource. const editorMode = (paperLike && project.editorMode) || 'rich'; const setEditorMode = (next) => { setStore(s => { const proj = s.projects[project.id]; // First time switching to LaTeX, seed the source from current sections. let nextLatex = proj.latexSource; if (next === 'latex' && (!nextLatex || nextLatex.trim() === '')) { nextLatex = template.sections.map(sec => `\\section{${sec.name}}\n${proj.sections[sec.id] || ''}\n` ).join('\n'); } return { ...s, projects: { ...s.projects, [project.id]: { ...proj, editorMode: next, latexSource: nextLatex }, }, }; }); }; const updateLatexSource = (src) => { setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], latexSource: src } }, })); }; if (paperLike && editorMode === 'latex') { return ( <> {ProjectTabs} {Header} {HistoryPanel}
Editor
); } return ( <> {ProjectTabs} {Header} {HistoryPanel} {paperLike && (
Editor
)}
On this page
{template.sections.map(s => { const wc = wordCount(sections[s.id]); return ( ); })}

{project.name}

{totalWords} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} sections{paperLike ? ' · academic paper' : ''}
{template.sections.map(s => { const text = sections[s.id] || ''; const aiForSection = aiNotes ? aiNotes.find(n => n.sectionId === s.id) : null; return (

{s.name}

updateSection(s.id, v)} placeholder={`Start writing ${s.name.toLowerCase()}…`} hint={s.hint} serif={paperLike} /> {(s.checks || []).length > 0 && (
{s.checks.map(c => { const check = CHECKS[c]; if (!check) return null; const passed = check.test(text); return ( {passed ? : } {check.label} ); })} {s.target > 0 && ( = s.target * 0.7}> {wordCount(text)} / {s.target} words )}
)} {aiForSection && (
AI suggestion · stub
{aiForSection.msg}
)}
); })}
{InsertPanel}
); }; // ============================================================================ // Auto-save indicator. Drafts persist to localStorage on every keystroke, // so we show a transient "Saving…" pill when the project changes, settling // to "Saved · Xs ago" while idle. // ============================================================================ function SaveIndicator({ project }) { const [savedAt, setSavedAt] = useState(Date.now()); const [pulse, setPulse] = useState(false); const sectionsKey = JSON.stringify(project?.sections || {}); useEffect(() => { setPulse(true); const t1 = setTimeout(() => setPulse(false), 300); const t2 = setTimeout(() => setSavedAt(Date.now()), 350); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [sectionsKey]); // Tick the relative-time string const [, force] = useState(0); useEffect(() => { const i = setInterval(() => force(n => n + 1), 5000); return () => clearInterval(i); }, []); const ago = (() => { const d = Math.floor((Date.now() - savedAt) / 1000); if (d < 5) return 'just now'; if (d < 60) return `${d}s ago`; if (d < 3600) return `${Math.floor(d / 60)}m ago`; return `${Math.floor(d / 3600)}h ago`; })(); return ( {pulse ? 'Saving…' : `Saved · ${ago}`} ); } // ============================================================================ // RichBlock — Notion-style click-to-edit + Docs-style floating toolbar. // • Idle: shows rendered markdown (with KaTeX math) — looks like a real doc // • Click: switches to a textarea source view // • While editing: floating toolbar above with Bold / Italic / H2 / list / link / cite / math // • Slash commands still work via the underlying textarea // ============================================================================ const wrap = (text, l, r = l) => `${l}${text || 'text'}${r}`; const lineWrap = (text, prefix) => (text ? text.split('\n').map(line => line ? `${prefix}${line}` : line).join('\n') : `${prefix}`); const TOOLBAR = [ { id: 'bold', icon: 'pencil', label: `Bold (${MOD}+B)`, run: (sel) => wrap(sel, '**') }, { id: 'italic', icon: 'pencil', label: `Italic (${MOD}+I)`, run: (sel) => wrap(sel, '*') }, { id: 'code', icon: 'flask', label: 'Inline code', run: (sel) => wrap(sel, '`') }, { id: 'h2', icon: 'list', label: 'Heading', run: (sel) => `## ${sel || 'Heading'}` }, { id: 'h3', icon: 'list', label: 'Subheading', run: (sel) => `### ${sel || 'Subheading'}` }, { id: 'list', icon: 'list', label: 'Bullet list', run: (sel) => lineWrap(sel, '- ') }, { id: 'numbered', icon: 'list', label: 'Numbered list', run: (sel) => lineWrap(sel, '1. ') }, { id: 'quote', icon: 'cite', label: 'Block quote', run: (sel) => lineWrap(sel, '> ') }, { id: 'link', icon: 'link', label: 'Link', run: (sel) => `[${sel || 'link text'}](https://)` }, { id: 'cite', icon: 'book', label: 'Citation @key', run: (sel) => `(@${sel || 'key'})` }, { id: 'math', icon: 'flask', label: 'Inline math (LaTeX)', run: (sel) => `$${sel || 'x^2'}$` }, { id: 'math-block', icon: 'flask', label: 'Math block', run: (sel) => `\n$$\n${sel || 'E = mc^2'}\n$$\n` }, ]; function RichBlock({ value, onChange, placeholder, serif, hint }) { const [editing, setEditing] = useState(false); const [slash, setSlash] = useState(null); const taRef = useRef(null); const containerRef = useRef(null); // Auto-grow when editing useEffect(() => { if (!editing || !taRef.current) return; taRef.current.style.height = 'auto'; taRef.current.style.height = taRef.current.scrollHeight + 'px'; }, [value, editing]); // Click outside to leave edit mode useEffect(() => { if (!editing) return; const onDocClick = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { setEditing(false); setSlash(null); } }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [editing]); const applyToolbar = (cmd) => { const ta = taRef.current; if (!ta) return; const { selectionStart: s, selectionEnd: e } = ta; const before = value.slice(0, s); const sel = value.slice(s, e); const after = value.slice(e); const replacement = cmd.run(sel); const next = before + replacement + after; onChange(next); // Reposition cursor at end of inserted text setTimeout(() => { if (taRef.current) { const pos = before.length + replacement.length; taRef.current.setSelectionRange(pos, pos); taRef.current.focus(); } }, 0); }; const onTextChange = (e) => { const val = e.target.value; const cursor = e.target.selectionStart; onChange(val); const before = val.slice(0, cursor); const m = before.match(/(?:^|\n)(\/[\w-]*)$/); if (m) { const q = m[1].slice(1).toLowerCase(); const choices = SLASH_COMMANDS.filter(c => c.label.toLowerCase().includes(q) || c.id.includes(q)).slice(0, 8); setSlash({ start: cursor - m[1].length, query: q, choices, idx: 0 }); } else { setSlash(null); } }; const insertSlash = (cmd) => { if (!slash) return; const before = value.slice(0, slash.start); const after = value.slice(slash.start + 1 + slash.query.length); const inserted = cmd.insert(); onChange(before + inserted + after); setSlash(null); setTimeout(() => { if (taRef.current) { const pos = before.length + inserted.length; taRef.current.setSelectionRange(pos, pos); taRef.current.focus(); } }, 0); }; const onKeyDown = (e) => { // ⌘B / ⌘I keyboard shortcuts (Word/Docs convention) if ((e.metaKey || e.ctrlKey) && !e.shiftKey) { if (e.key.toLowerCase() === 'b') { e.preventDefault(); applyToolbar(TOOLBAR.find(t => t.id === 'bold')); return; } if (e.key.toLowerCase() === 'i') { e.preventDefault(); applyToolbar(TOOLBAR.find(t => t.id === 'italic')); return; } } if (slash && slash.choices.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.min(s.choices.length - 1, s.idx + 1) })); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.max(0, s.idx - 1) })); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertSlash(slash.choices[slash.idx]); } else if (e.key === 'Escape') setSlash(null); } }; return (
{/* Floating toolbar — only when actively editing */} {editing && (
e.preventDefault() /* keep textarea focused */}> {TOOLBAR.map(t => ( ))}
)} {editing ? (