diff --git a/phd-advisor-frontend/package-lock.json b/phd-advisor-frontend/package-lock.json
index 94168385..d6d6f317 100644
--- a/phd-advisor-frontend/package-lock.json
+++ b/phd-advisor-frontend/package-lock.json
@@ -8,16 +8,22 @@
"name": "phd-advisor-frontend",
"version": "0.1.0",
"dependencies": {
+ "@codemirror/legacy-modes": "^6.5.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
+ "@uiw/react-codemirror": "^4.25.9",
+ "katex": "^0.16.45",
+ "latex.js": "^0.12.6",
"lucide-react": "^0.544.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-scripts": "5.0.1",
+ "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"web-vitals": "^2.1.4"
}
},
@@ -2068,6 +2074,108 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"license": "MIT"
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
+ "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
+ "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.6.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
+ "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/legacy-modes": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
+ "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
+ "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.42.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.7.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
+ "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.6.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
+ "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.42.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz",
+ "integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.6.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"node_modules/@csstools/normalize.css": {
"version": "12.1.1",
"resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz",
@@ -2966,6 +3074,36 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
+ "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
+ "license": "MIT"
+ },
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3032,6 +3170,12 @@
"node": ">= 8"
}
},
+ "node_modules/@one-ini/wasm": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+ "license": "MIT"
+ },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3438,6 +3582,15 @@
"url": "https://github.com/sponsors/gregberge"
}
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
+ "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -3797,6 +3950,12 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"license": "MIT"
},
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
+ "license": "MIT"
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -3866,16 +4025,6 @@
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"license": "MIT"
},
- "node_modules/@types/react": {
- "version": "19.1.9",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
- "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "csstype": "^3.0.2"
- }
- },
"node_modules/@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -4207,6 +4356,59 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@uiw/codemirror-extensions-basic-setup": {
+ "version": "4.25.9",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz",
+ "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@codemirror/autocomplete": ">=6.0.0",
+ "@codemirror/commands": ">=6.0.0",
+ "@codemirror/language": ">=6.0.0",
+ "@codemirror/lint": ">=6.0.0",
+ "@codemirror/search": ">=6.0.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0"
+ }
+ },
+ "node_modules/@uiw/react-codemirror": {
+ "version": "4.25.9",
+ "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz",
+ "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.6",
+ "@codemirror/commands": "^6.1.0",
+ "@codemirror/state": "^6.1.1",
+ "@codemirror/theme-one-dark": "^6.0.0",
+ "@uiw/codemirror-extensions-basic-setup": "4.25.9",
+ "codemirror": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.11.0",
+ "@codemirror/state": ">=6.0.0",
+ "@codemirror/theme-one-dark": ">=6.0.0",
+ "@codemirror/view": ">=6.0.0",
+ "codemirror": ">=6.0.0",
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -4378,6 +4580,15 @@
"deprecated": "Use your platform's native atob() and btoa() methods instead",
"license": "BSD-3-Clause"
},
+ "node_modules/abbrev": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -5227,6 +5438,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/batch": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz",
@@ -5365,6 +5596,15 @@
"node": ">=8"
}
},
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
"node_modules/browser-process-hrtime": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
@@ -5746,6 +5986,15 @@
"wrap-ansi": "^7.0.0"
}
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -5841,6 +6090,21 @@
"node": ">=4"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
"node_modules/collect-v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -5974,6 +6238,16 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
@@ -6088,6 +6362,12 @@
"node": ">=10"
}
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6486,13 +6766,6 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"license": "MIT"
},
- "node_modules/csstype": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "license": "MIT",
- "peer": true
- },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6773,6 +7046,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -6973,6 +7252,57 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
+ "node_modules/editorconfig": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
+ "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@one-ini/wasm": "0.1.1",
+ "commander": "^10.0.0",
+ "minimatch": "^9.0.1",
+ "semver": "^7.5.3"
+ },
+ "bin": {
+ "editorconfig": "bin/editorconfig"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/editorconfig/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/editorconfig/node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/editorconfig/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8394,6 +8724,23 @@
}
}
},
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -9017,6 +9364,125 @@
"node": ">= 0.4"
}
},
+ "node_modules/hast-util-from-dom": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
+ "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
+ "license": "ISC",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hastscript": "^9.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
+ "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.1.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "parse5": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html-isomorphic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
+ "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-from-dom": "^5.0.0",
+ "hast-util-from-html": "^2.0.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-html/node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
+ "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "devlop": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "property-information": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-location": "^5.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -9044,6 +9510,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -9057,6 +9539,23 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hastscript": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -9341,6 +9840,28 @@
"node": ">=10.17.0"
}
},
+ "node_modules/hyphenation.de": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/hyphenation.de/-/hyphenation.de-0.2.1.tgz",
+ "integrity": "sha512-s6Y4TFA8xWjRLneOPI6HV/+wzfm2c2yurTvFaXlznmsbeI6waZhMpxu94fSXGNGsrPxrzI1zTtYDEWeEeaANnw==",
+ "dependencies": {
+ "hypher": "*"
+ }
+ },
+ "node_modules/hyphenation.en-us": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/hyphenation.en-us/-/hyphenation.en-us-0.2.1.tgz",
+ "integrity": "sha512-ItXYgvIpfN8rfXl/GTBQC7DsSb5PPsKh9gGzViK/iWzCS5mvjDebFJ6xCcIYo8dal+nSp2rUzvTT7BosrKlL8A==",
+ "dependencies": {
+ "hypher": "*"
+ }
+ },
+ "node_modules/hypher": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/hypher/-/hypher-0.2.5.tgz",
+ "integrity": "sha512-kUTpuyzBWWDO2VakmjHC/cxesg4lKQP+Fdc+7lrK4yvjNjkV9vm5UTZMDAwOyyHTOpbkYrAMlNZHG61NnE9vYQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -9392,6 +9913,21 @@
"node": ">= 4"
}
},
+ "node_modules/image-size": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
+ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
+ "license": "MIT",
+ "dependencies": {
+ "queue": "6.0.2"
+ },
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -11134,6 +11670,71 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-beautify": {
+ "version": "1.14.11",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.11.tgz",
+ "integrity": "sha512-rPogWqAfoYh1Ryqqh2agUpVfbxAhbjuN1SmU86dskQUKouRiggUTCO4+2ym9UPXllc2WAp0J+T5qxn7Um3lCdw==",
+ "license": "MIT",
+ "dependencies": {
+ "config-chain": "^1.1.13",
+ "editorconfig": "^1.0.3",
+ "glob": "^10.3.3",
+ "nopt": "^7.2.0"
+ },
+ "bin": {
+ "css-beautify": "js/bin/css-beautify.js",
+ "html-beautify": "js/bin/html-beautify.js",
+ "js-beautify": "js/bin/js-beautify.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-beautify/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/js-beautify/node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/js-beautify/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -11312,6 +11913,22 @@
"node": ">=4.0"
}
},
+ "node_modules/katex": {
+ "version": "0.16.45",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
+ "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11366,6 +11983,27 @@
"node": ">=0.10"
}
},
+ "node_modules/latex.js": {
+ "version": "0.12.6",
+ "resolved": "https://registry.npmjs.org/latex.js/-/latex.js-0.12.6.tgz",
+ "integrity": "sha512-spMTeSq9cP4vidMQuPgoYGKmsQTMElZhDtNF3NyLIRc03XkuuPvMA0tzb0ShS/otaIJrB7DIHDk0GL3knCisEw==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "8.x",
+ "fs-extra": "10.x",
+ "hyphenation.de": "*",
+ "hyphenation.en-us": "*",
+ "js-beautify": "1.14.x",
+ "stdin": "*",
+ "svgdom": "^0.1.8"
+ },
+ "bin": {
+ "latex.js": "bin/latex.js"
+ },
+ "engines": {
+ "node": ">= 14.0"
+ }
+ },
"node_modules/launch-editor": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz",
@@ -11756,6 +12394,25 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-math": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
+ "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.1.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -12135,6 +12792,25 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/micromark-extension-math": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
+ "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/katex": "^0.16.0",
+ "devlop": "^1.0.0",
+ "katex": "^0.16.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -12746,6 +13422,21 @@
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"license": "MIT"
},
+ "node_modules/nopt": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^2.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -13108,6 +13799,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
"node_modules/param-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
@@ -14810,6 +15507,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "license": "ISC"
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -14885,6 +15588,15 @@
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
+ "node_modules/queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "~2.0.3"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -15451,6 +16163,25 @@
"node": ">=6"
}
},
+ "node_modules/rehype-katex": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
+ "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/katex": "^0.16.0",
+ "hast-util-from-html-isomorphic": "^2.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "katex": "^0.16.0",
+ "unist-util-visit-parents": "^6.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/relateurl": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@@ -15478,6 +16209,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-math": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
+ "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-math": "^3.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -15679,6 +16426,12 @@
"node": ">=10"
}
},
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
+ "license": "MIT"
+ },
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
@@ -16620,6 +17373,11 @@
"node": ">= 0.8"
}
},
+ "node_modules/stdin": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/stdin/-/stdin-0.0.1.tgz",
+ "integrity": "sha512-2bacd1TXzqOEsqRa+eEWkRdOSznwptrs4gqFcpMq5tOtmJUGPZd10W5Lam6wQ4YQ/+qjQt4e9u35yXCF6mrlfQ=="
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -16929,6 +17687,12 @@
"webpack": "^5.0.0"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
"node_modules/style-to-js": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz",
@@ -17081,6 +17845,30 @@
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"license": "MIT"
},
+ "node_modules/svgdom": {
+ "version": "0.1.23",
+ "resolved": "https://registry.npmjs.org/svgdom/-/svgdom-0.1.23.tgz",
+ "integrity": "sha512-zTT8cz8rf07OCvRhTipJv+bt/MgL5Zm2ZF3IttK9zr/MKVDTrgo+usbDQMOK1PGzqDT7GA1SshnYlXvtafK7Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "fontkit": "^2.0.4",
+ "image-size": "^1.2.1",
+ "sax": "^1.4.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Fuzzyma"
+ }
+ },
+ "node_modules/svgdom/node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/svgo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz",
@@ -17420,6 +18208,12 @@
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"license": "MIT"
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -17704,20 +18498,6 @@
"is-typedarray": "^1.0.0"
}
},
- "node_modules/typescript": {
- "version": "4.9.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
- "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
- "license": "Apache-2.0",
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=4.2.0"
- }
- },
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -17779,6 +18559,16 @@
"node": ">=4"
}
},
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/unicode-property-aliases-ecmascript": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
@@ -17788,6 +18578,16 @@
"node": ">=4"
}
},
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -17831,6 +18631,20 @@
"node": ">=8"
}
},
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -17857,6 +18671,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/unist-util-remove-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
+ "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -18070,6 +18898,20 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/vfile-location": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
+ "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -18094,6 +18936,12 @@
"browser-process-hrtime": "^1.0.0"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/w3c-xmlserializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz",
@@ -18137,6 +18985,16 @@
"minimalistic-assert": "^1.0.0"
}
},
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+ "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/web-vitals": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
diff --git a/phd-advisor-frontend/package.json b/phd-advisor-frontend/package.json
index c66f19b1..a3fc60ca 100644
--- a/phd-advisor-frontend/package.json
+++ b/phd-advisor-frontend/package.json
@@ -3,16 +3,22 @@
"version": "0.1.0",
"private": true,
"dependencies": {
+ "@codemirror/legacy-modes": "^6.5.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
+ "@uiw/react-codemirror": "^4.25.9",
+ "katex": "^0.16.45",
+ "latex.js": "^0.12.6",
"lucide-react": "^0.544.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-scripts": "5.0.1",
+ "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
+ "remark-math": "^6.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {
diff --git a/phd-advisor-frontend/src/App.js b/phd-advisor-frontend/src/App.js
index 9d03459c..897f05e5 100644
--- a/phd-advisor-frontend/src/App.js
+++ b/phd-advisor-frontend/src/App.js
@@ -38,7 +38,10 @@ function App() {
setCurrentView('auth');
};
- const navigateToCanvas = () => {
+ const navigateToCanvas = (canvasView) => {
+ if (['insights', 'workspace', 'deliverables'].includes(canvasView)) {
+ localStorage.setItem('canvas-view-v2', canvasView);
+ }
setCurrentView('canvas');
};
@@ -74,7 +77,9 @@ function App() {
{currentView === 'home' && (
)}
@@ -82,9 +87,10 @@ function App() {
)}
{currentView === 'canvas' && isAuthenticated && (
-
diff --git a/phd-advisor-frontend/src/components/AppHeader.js b/phd-advisor-frontend/src/components/AppHeader.js
new file mode 100644
index 00000000..84f5fd56
--- /dev/null
+++ b/phd-advisor-frontend/src/components/AppHeader.js
@@ -0,0 +1,104 @@
+import React from 'react';
+import { Home, Menu, Users } from 'lucide-react';
+import ThemeToggle from './ThemeToggle';
+import { useAppConfig } from '../contexts/AppConfigContext';
+
+/**
+ * Shared floating header used on every page so the app feels like one surface.
+ *
+ * Props:
+ * currentPage: 'home' | 'chat' | 'canvas'
+ * onNavigateToHome, onNavigateToChat, onNavigateToCanvas: navigation callbacks
+ * (onNavigateToCanvas may receive 'insights' | 'workspace' to deep-link a view)
+ * onMobileMenu?: () => void — when present, shows the mobile menu button
+ * children?: ReactNode — extra controls slotted between the tabs and the theme toggle
+ */
+const AppHeader = ({
+ currentPage = 'home',
+ onNavigateToHome,
+ onNavigateToChat,
+ onNavigateToCanvas,
+ onMobileMenu,
+ children,
+}) => {
+ const { config, resolveIcon } = useAppConfig();
+ const BrandIcon = resolveIcon ? resolveIcon('Users') : Users;
+
+ const goToCanvas = (view) => {
+ if (onNavigateToCanvas) onNavigateToCanvas(view);
+ };
+
+ // Accept either 'canvas' (all canvas tabs highlight equally) or a more specific
+ // 'canvas-
' from CanvasPage so only the active one highlights.
+ const isOnHome = currentPage === 'home';
+ const isOnChat = currentPage === 'chat';
+ const isOnCanvas = currentPage === 'canvas' || currentPage.startsWith('canvas-');
+ const canvasSub = currentPage.startsWith('canvas-') ? currentPage.slice(7) : null;
+ const tabActive = (sub) => isOnCanvas && (canvasSub === null ? false : canvasSub === sub);
+
+ return (
+
+ );
+};
+
+export default AppHeader;
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasCriticWidgets.js b/phd-advisor-frontend/src/components/canvas/CanvasCriticWidgets.js
new file mode 100644
index 00000000..0f947963
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasCriticWidgets.js
@@ -0,0 +1,430 @@
+import React, { useState } from 'react';
+import Icon from './CanvasIcon';
+
+const fireToast = (msg, kind = 'success') =>
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
+
+// Critic widgets are scripted in canvas; the *real* critique should happen in
+// the main chat so message history lives in one place (per Daniel's review).
+// "Open in chat" stashes a draft prompt + persona hint then asks CanvasPage to
+// navigate to chat — the chat page can read `canvas-chat-handoff` from localStorage.
+const handoffToChat = (persona, prompt, context = {}) => {
+ try {
+ localStorage.setItem('canvas-chat-handoff', JSON.stringify({
+ at: Date.now(), persona, prompt, ...context,
+ }));
+ } catch { /* ignore */ }
+ window.dispatchEvent(new CustomEvent('canvas-open-in-chat', { detail: { persona, prompt } }));
+ fireToast(`Opening ${persona} in chat — full history will be there.`);
+};
+
+// ---------- Reviewer 2 widget ----------
+export function Reviewer2Widget({ state, setState, openModal }) {
+ return (
+ <>
+
+ "Paste a draft paragraph, abstract, or section. I'll respond as the most uncharitable but technically competent reviewer your work will ever see."
+
+ {state.lastReview ? (
+
+
Last critique · {state.lastReview.severity}/10 severity
+
Major: {state.lastReview.major}
+
+{state.lastReview.minorCount} minor issues, {state.lastReview.suggestionCount} suggestions
+
+ ) : (
+
+ No drafts reviewed yet.
+
+ )}
+
+
+ openModal('reviewer-2', {
+ initial: state.lastDraft,
+ onComplete: (review) => setState({ ...state, lastDraft: review.draft, lastReview: review }),
+ })}
+ >
+ Get critique
+
+ handoffToChat('Reviewer 2', state.lastDraft
+ ? `Critique this draft as Reviewer 2: "${state.lastDraft}"`
+ : 'Open Reviewer 2 and ready a critique.')}
+ >
+ Open in chat
+
+
+ >
+ );
+}
+
+// ---------- Devil's Advocate widget ----------
+export function DevilsAdvocateWidget({ state, setState, openModal }) {
+ return (
+ <>
+ Your claim
+ "{state.claim}"
+
+ Strongest counters · {state.counters.length}
+
+
+ {state.counters.slice(0, 3).map((c, i) => (
+
+ ))}
+
+
+ openModal('devils-advocate', {
+ claim: state.claim,
+ counters: state.counters,
+ onUpdate: (next) => setState({ ...state, ...next }),
+ })}
+ >
+ Push harder
+
+ handoffToChat("Devil's Advocate",
+ `Take the position of devil's advocate on my claim: "${state.claim || 'my current hypothesis'}". Be ruthless.`)}
+ >
+ Open in chat
+
+
+ >
+ );
+}
+
+// ---------- Scope realism widget ----------
+export function ScopeRealismWidget({ state, openModal }) {
+ return (
+
+
+
+
{state.score.toFixed(1)}
+
+
Feasibility
+
{state.label}
+
+
+
+ target: {state.target}
+
+
+
+ {state.factors.map(f => (
+
+ {f.label}
+
+ {f.val}
+
+ ))}
+
+
+ openModal('scope-realism', { state })}
+ >
+ Read full verdict
+
+ handoffToChat('Scope Realism',
+ `Run a brutal feasibility check on my goal: "${state.target || 'my current research scope'}". Be specific about what's at risk.`)}
+ >
+ Open in chat
+
+
+
+ );
+}
+
+// ===================================================================
+// Critic modals
+// ===================================================================
+
+const REVIEW_TEMPLATES = [
+ {
+ severity: 8,
+ major: 'The hypothesis is presented before its operationalization. You write that L2/3 spiking "encodes" prediction error without specifying what spike-pattern feature you will measure (rate? latency? variance?), what range of values would count as "encoding," or what would falsify the claim.',
+ minor: [
+ 'Sample size of n=4 animals is described as "preliminary" without a power calculation or a stopping rule.',
+ 'GLM with history kernel is presented as the analysis but no mention of how its outputs map to the proposed predictive-coding interpretation.',
+ 'No engagement with adaptation as a confound — the obvious alternative explanation for any oddball-driven decrease in firing.',
+ 'Reference to "predictive coding" is loose. Rao & Ballard, Bastos, and Friston make different commitments. Which one are you testing?',
+ ],
+ suggestions: [
+ 'Add a single sentence specifying the measurable signature you predict, with directionality.',
+ 'Either control for arousal/pupil or acknowledge it as a limit upfront.',
+ 'Add a stopping rule and target effect size before scaling beyond n=4.',
+ ],
+ },
+ {
+ severity: 7,
+ major: 'You claim the GLM analyses are "consistent with" the hypothesis. This phrase is doing too much work. Consistency with PE encoding is also consistency with at least three alternative explanations (adaptation, arousal, attention). Without a positive test that PE encoding predicts but the alternatives do not, "consistent with" is unfalsifiable.',
+ minor: [
+ 'Mouse V1 is justified by convention rather than by what makes it the right model for this question.',
+ 'No statement of what would change your mind.',
+ 'Figure-free abstract for an empirical claim is a red flag for reviewers.',
+ ],
+ suggestions: [
+ 'Replace "consistent with" with a specific signed prediction the data either matches or doesn\'t.',
+ 'List 1-2 results that, if observed, would refute the hypothesis.',
+ ],
+ },
+ {
+ severity: 9,
+ major: 'This reads like an introduction, not an abstract. There is no result. There is no number. The strongest claim is that your "preliminary analyses are consistent" with your hypothesis — which is the lowest possible bar in empirical neuroscience. If the actual finding is interesting, lead with the finding, not with the framing.',
+ minor: [
+ 'Word "preliminary" appears three times in three sentences. Cut two.',
+ '"Oddball stimulus paradigm" is jargon-without-citation; one sentence of definition or one citation, not zero.',
+ 'No mention of layer-specificity, despite L2/3 being the core claim.',
+ ],
+ suggestions: [
+ 'Lead sentence: "We find that " — even if the effect is small, name it.',
+ 'Cut "we hypothesize that" entirely. Hypotheses go in the intro of the paper, not the abstract.',
+ ],
+ },
+];
+
+export function ReviewerModal({ data, onClose }) {
+ const [draft, setDraft] = useState(data.initial || '');
+ const [running, setRunning] = useState(false);
+ const [review, setReview] = useState(null);
+
+ const run = () => {
+ if (!draft.trim()) return;
+ setRunning(true);
+ setReview(null);
+ setTimeout(() => {
+ const idx = (draft.length + draft.split(' ').length) % REVIEW_TEMPLATES.length;
+ const r = REVIEW_TEMPLATES[idx];
+ setReview(r);
+ setRunning(false);
+ }, 900);
+ };
+
+ const accept = () => {
+ if (!review) return;
+ data.onComplete({
+ draft,
+ severity: review.severity,
+ major: review.major,
+ minorCount: review.minor.length,
+ suggestionCount: review.suggestions.length,
+ });
+ fireToast('Critique saved · severity ' + review.severity + '/10', 'critic');
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
Reviewer 2 Simulator
+
Paste a draft. Get the harshest competent peer review you'll ever read — before a real reviewer does.
+
+
+
+
+
+
+ {!review && !running && (
+
+ What this is: a simulated peer review tuned to push back hard. It will name unstated operationalizations, rival explanations, and weak phrasings. It will not flatter you. That's the point.
+
+ )}
+
+ {running && (
+
+
+
parsing claims · finding rival hypotheses · drafting…
+
+ )}
+
+ {review && (
+
+
+
{review.severity}/10
+
severity
+
+
{review.minor.length} minor · {review.suggestions.length} suggestions
+
+
+ Major issue
+ {review.major}
+
+
+
Minor issues
+
+ {review.minor.map((m, i) => {m} )}
+
+
+
+
Suggestions
+
+ {review.suggestions.map((s, i) => {s} )}
+
+
+
+ )}
+
+
+ Close
+ {review ? (
+ <>
+ { setReview(null); }}>New critique
+ Save to widget
+ >
+ ) : (
+
+ {running ? 'Reviewing…' : 'Critique my draft'}
+
+ )}
+
+
+ );
+}
+
+const HARDER_COUNTERS = [
+ { lbl: 'Reverse causation', text: 'You assume PE drives spike changes. The opposite mapping — that some intrinsic cortical state drives both the spike pattern and the perceived "surprise" — is observationally indistinguishable in your design.' },
+ { lbl: 'Definition shift', text: 'You will be tempted, when results don\'t fit, to redefine "prediction error" until they do. Pre-register your operationalization or you cannot honestly claim to have tested PC.' },
+ { lbl: 'Wrong layer', text: 'Most predictive-coding accounts place PE signaling in L4 or L5b, not L2/3. Your prior for finding PE in L2/3 should be lower than you\'re writing.' },
+ { lbl: 'Mouse vs. theory', text: 'Predictive coding theories were built on primate visual hierarchies with rich top-down attention. Mouse V1 lacks several of the assumed circuits. You may be testing the theory on a substrate it doesn\'t apply to.' },
+];
+
+export function DevilsModal({ data, onClose }) {
+ const [counters, setCounters] = useState(data.counters);
+ const [pushing, setPushing] = useState(false);
+
+ const pushHarder = () => {
+ setPushing(true);
+ setTimeout(() => {
+ const next = HARDER_COUNTERS.find(c => !counters.find(x => x.lbl === c.lbl));
+ if (next) {
+ const nc = [...counters, next];
+ setCounters(nc);
+ data.onUpdate({ counters: nc });
+ fireToast('Stronger counter added: "' + next.lbl + '"', 'critic');
+ } else {
+ fireToast('No more counters — your hypothesis is more robust than I thought.');
+ }
+ setPushing(false);
+ }, 800);
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
Devil's Advocate
+
The strongest counter-arguments to your hypothesis, ranked by how much they should worry you.
+
+
+
+
+
+
Your claim
+
"{data.claim}"
+
+
+ {counters.map((c, i) => (
+
+ ))}
+
+
+
+
Close
+
+ {pushing ? <>
Thinking…> : <>Push harder>}
+
+
+
+ );
+}
+
+export function ScopeModal({ data, onClose }) {
+ const s = data.state;
+ return (
+ e.stopPropagation()}>
+
+
+
+
Scope Realism Check
+
A brutal feasibility verdict on "{s.target}" given your current pace.
+
+
+
+
+
+
{s.score.toFixed(1)}
+
+
Verdict
+
{s.label}
+
5.0 = neither feasible nor infeasible · <3 = unrealistic · >7 = comfortable
+
+
+
+
+
Factor breakdown
+ {s.factors.map(f => (
+
+ {f.label}
+
+ {f.val}
+
+ ))}
+
+
+
+ The actual problem
+ {s.notes}
+
+
+
+
Recommended actions
+
+ Commit to a PC formulation by May 31. Theory clarity is your bottleneck, not data.
+ Build a writing buffer. Your 9-day streak is great; 90 days is the minimum to absorb the inevitable lab/family/health setbacks.
+ Cut a chapter. A 5-chapter dissertation that ships beats a 6-chapter one that doesn't.
+ Calibrate against your cohort: median time-to-defense in your program is 5.8 years. You are projecting 5.2.
+
+
+
+
+ Close
+ Re-run with new estimates
+
+
+ );
+}
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js b/phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js
new file mode 100644
index 00000000..ddac446a
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js
@@ -0,0 +1,1328 @@
+// 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: 'Research Paper',
+ desc: 'Abstract → Introduction → Methods → Results → Discussion → References',
+ 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: 'Thesis Chapter',
+ desc: 'Standard chapter scaffolding for a dissertation.',
+ 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: () => '' },
+ { 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
+
+ Your one-stop deliverable center. 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 (
+
switchProject(p.id)}>
+
+
+
{p.name}
+
{t.name} · {wc}/{target} words
+
opened {new Date(p.createdAt).toLocaleDateString()}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* 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 => (
+
createProject(t.id)}>
+
+
+
{t.name}
+
{t.desc}
+
{t.sections.length} sections · {t.mode}
+
+
+ ))}
+
+
+
+ {/* 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.)
+
+
+ Upload project brief
+
+
+ >
+ );
+ }
+
+ // ============================================================================
+ // 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 (
+
switchProject(p.id)}
+ title={`${t?.name || ''} · ${wordCount(Object.values(p.sections || {}).join(' '))} words`}
+ >
+
+ {p.name}
+
+ );
+ })}
+
+ New
+
+ {TEMPLATES.map(t => (
+ createProject(t.id)}>
+
+ {t.name}
+
+
+ ))}
+
+
+
+ );
+
+ const Header = (
+
+
+
+ All drafts
+
+
+ renameProject(e.target.value)}
+ />
+
+
+
+ {template.name} · {totalWords} / {totalTarget} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
+
+
+
+
setHistoryOpen(o => !o)} title="Version history">
+ History · {project.versions.length}
+
+
+ {generatingAi ? <>
> : }
+ AI check
+
+
+ Export
+
+ exportAs('markdown')}>Markdown (.md)
+ exportAs('latex')}>LaTeX (.tex)
+ exportAs('html')}>HTML (.html)
+ window.print()}>Print / Save as PDF
+
+
+
+
+
+
+
+ );
+
+ const HistoryPanel = historyOpen && (
+
+
+ Version history · {project.versions.length}
+ setHistoryOpen(false)}>
+
+ {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 (
+
restoreVersion(v)}>
+ {sec?.name || v.sectionId}
+ {new Date(v.at).toLocaleString()}
+
+
+ );
+ })
+ )}
+
+ );
+
+ 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) => (
+ insertIntoActive(it.snippet)} className="canvas-insert-row">
+ {it.kind}
+ {it.label}
+
+
+ ))}
+
+ );
+
+ // ---------- 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 (
+
setActiveSectionId(s.id)}
+ title={s.name}>
+ {i + 1}
+
+
{s.name}
+
+ {tLines.slice(0, 3).map((l, j) =>
• {l.slice(0, 30)}
)}
+
+
+
+ );
+ })}
+
+
+
+
+
{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
+ setEditorMode('rich')} title="Switch to Notion-style rich editor">
+ LaTeX
+
+ setEditorMode('rich')}>Rich
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {ProjectTabs}
+ {Header}
+ {HistoryPanel}
+ {paperLike && (
+
+ Editor
+ setEditorMode('latex')}>LaTeX
+ setEditorMode('rich')}>Rich
+
+ )}
+
+
+
On this page
+ {template.sections.map(s => {
+ const wc = wordCount(sections[s.id]);
+ return (
+
{
+ setActiveSectionId(s.id);
+ const el = document.getElementById(`notion-section-${s.id}`);
+ if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
+ }}>
+ {s.name}
+ {wc > 0 && {wc} }
+
+ );
+ })}
+
+
+
+
+
{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 => (
+ applyToolbar(t)}>
+ {t.id === 'bold' && B }
+ {t.id === 'italic' && I }
+ {t.id === 'code' && {'<>'} }
+ {t.id === 'h2' && H2 }
+ {t.id === 'h3' && H3 }
+ {t.id === 'list' && '•'}
+ {t.id === 'numbered' && 1. }
+ {t.id === 'quote' && '"'}
+ {t.id === 'link' && }
+ {t.id === 'cite' && '@'}
+ {t.id === 'math' && x }
+ {t.id === 'math-block' && ∑ }
+
+ ))}
+
+ )}
+
+ {editing ? (
+
+
+ {slash && slash.choices.length > 0 && (
+
+
Insert block
+ {slash.choices.map((c, i) => (
+
setSlash(s => ({ ...s, idx: i }))}
+ onClick={() => insertSlash(c)}
+ className={i === slash.idx ? 'active' : ''}>
+
+ {c.label}
+ {c.kind}
+
+ ))}
+
+ )}
+
+ ) : (
+
setEditing(true)}
+ onFocus={() => setEditing(true)}
+ tabIndex={0}
+ >
+ {value ? (
+ {value}
+ ) : (
+ {hint || placeholder || 'Click to edit'}
+ )}
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// SlashTextarea — auto-grows; `/` opens a block-insert popover.
+// ============================================================================
+function SlashTextarea({ value, onChange, placeholder, serif, notion, rows = 1 }) {
+ const ref = useRef(null);
+ const [slash, setSlash] = useState(null); // { start, query, choices, idx }
+
+ useEffect(() => {
+ if (!ref.current || !notion) return;
+ ref.current.style.height = 'auto';
+ ref.current.style.height = ref.current.scrollHeight + 'px';
+ }, [value, notion]);
+
+ 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 insertCmd = (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 (ref.current) {
+ const pos = before.length + inserted.length;
+ ref.current.setSelectionRange(pos, pos);
+ ref.current.focus();
+ }
+ }, 0);
+ };
+
+ const onKeyDown = (e) => {
+ if (!slash || slash.choices.length === 0) return;
+ if (e.key === 'ArrowDown') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.min(s.choices.length - 1, s.idx + 1) })); }
+ if (e.key === 'ArrowUp') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.max(0, s.idx - 1) })); }
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertCmd(slash.choices[slash.idx]); }
+ if (e.key === 'Escape') setSlash(null);
+ };
+
+ return (
+
+
+ {slash && slash.choices.length > 0 && (
+
+
Insert block
+ {slash.choices.map((c, i) => (
+
setSlash(s => ({ ...s, idx: i }))}
+ onClick={() => insertCmd(c)}
+ className={i === slash.idx ? 'active' : ''}>
+
+ {c.label}
+ {c.kind}
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Poster panel — single-section block in the poster layout
+// ============================================================================
+function PosterPanel({ section, sections, updateSection }) {
+ if (!section) return null;
+ const text = sections[section.id] || '';
+ return (
+
+
{section.name}
+
updateSection(section.id, v)}
+ placeholder={section.hint}
+ hint={section.hint}
+ />
+
+ );
+}
+
+// ============================================================================
+// arXiv search — public ATOM API, CORS-enabled.
+// ============================================================================
+function ArxivSearch({ onPick }) {
+ const [q, setQ] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [results, setResults] = useState([]);
+
+ const search = async () => {
+ if (!q.trim()) return;
+ setBusy(true);
+ try {
+ const url = `https://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(q)}&max_results=5`;
+ const res = await fetch(url);
+ const xml = await res.text();
+ const doc = new DOMParser().parseFromString(xml, 'text/xml');
+ const entries = Array.from(doc.getElementsByTagName('entry')).map(e => {
+ const id = e.getElementsByTagName('id')[0]?.textContent?.split('/').pop() || '';
+ const title = e.getElementsByTagName('title')[0]?.textContent?.trim() || '';
+ const authors = Array.from(e.getElementsByTagName('author')).map(a => a.getElementsByTagName('name')[0]?.textContent?.trim()).filter(Boolean);
+ const year = (e.getElementsByTagName('published')[0]?.textContent || '').slice(0, 4);
+ return { id, title, authors, year };
+ });
+ setResults(entries);
+ } catch (e) {
+ fireToast('arXiv search failed', 'danger');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+ Search arXiv
+
+
+
setQ(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') search(); }}
+ style={{ fontSize: 12, padding: '5px 8px' }}/>
+
+ {busy ?
: }
+
+
+ {results.length > 0 && (
+
+ {results.map(r => {
+ const a = r.authors[0] ? r.authors[0].split(' ').pop() : 'Unknown';
+ const snippet = ` (${a}, ${r.year}; arXiv:${r.id})`;
+ return (
+ onPick(snippet)}>
+ arXiv
+ {r.title}
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+export default DeliverablesView;
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasIcon.js b/phd-advisor-frontend/src/components/canvas/CanvasIcon.js
new file mode 100644
index 00000000..933b71f9
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasIcon.js
@@ -0,0 +1,90 @@
+import React from 'react';
+
+const ICONS = {
+ sparkles: <> >,
+ layout: <> >,
+ insights: <> >,
+ search: <> >,
+ bell: <> >,
+ settings: <> >,
+ plus: ,
+ x: ,
+ check: ,
+ refresh: <> >,
+ trash: <> >,
+ grip: <> >,
+ more: <> >,
+ pin: <> >,
+ link: <> >,
+ message: ,
+ task: <> >,
+ book: <> >,
+ kanban: <> >,
+ timer: <> >,
+ pencil: <> >,
+ calendar: <> >,
+ wallet: <> >,
+ flag: <> >,
+ gavel: <> >,
+ scale: <> >,
+ brain: <> >,
+ alert: <> >,
+ notes: <> >,
+ list: <> >,
+ zap: ,
+ flame: <> >,
+ heart: ,
+ graph: <> >,
+ award: <> >,
+ network: <> >,
+ database: <> >,
+ flask: <> >,
+ shield: ,
+ music: <> >,
+ bullseye: <> >,
+ arrow: <> >,
+ copy: <> >,
+ download: <> >,
+ play: ,
+ pause: <> >,
+ reset: <> >,
+ star: ,
+ shuffle: <> >,
+ expand: <> >,
+ resize: <> >,
+ smile: <> >,
+ send: ,
+ chevron: ,
+ user: <> >,
+ cite: <> >,
+ pinned: <> >,
+ microscope: <> >,
+ sun: <> >,
+ moon: ,
+ back: <> >,
+};
+
+const Icon = ({ name, size = 16, className = '', style }) => {
+ const paths = ICONS[name];
+ if (!paths) return null;
+ return (
+
+ {paths}
+
+ );
+};
+
+export default Icon;
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasLatexEditor.js b/phd-advisor-frontend/src/components/canvas/CanvasLatexEditor.js
new file mode 100644
index 00000000..9785b5df
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasLatexEditor.js
@@ -0,0 +1,225 @@
+// Real LaTeX editor for paper-mode Documents.
+// Left: CodeMirror 6 editor with LaTeX (stex) syntax highlighting.
+// Right: live LaTeX.js HTML preview (handles \section, \textbf, \emph, lists,
+// citations, math via KaTeX — most thesis-shaped documents work).
+// Plus a "Compile PDF" action that POSTs to LaTeX-Online (latex.ytotech.com)
+// for real pdflatex output in an iframe.
+import React, { useState, useEffect, useRef, useMemo } from 'react';
+import CodeMirror from '@uiw/react-codemirror';
+import { StreamLanguage } from '@codemirror/language';
+import { stex } from '@codemirror/legacy-modes/mode/stex';
+import Icon from './CanvasIcon';
+
+const fireToast = (msg, kind = 'success') =>
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
+
+// Wrap a section body as a full standalone .tex document so both LaTeX.js (HTML)
+// and LaTeX-Online (PDF) can render it. The user's section content is the body.
+const wrapLatexDoc = (body, title = 'Document') => `\\documentclass[11pt]{article}
+\\usepackage[utf8]{inputenc}
+\\usepackage{amsmath,amssymb}
+\\usepackage{graphicx}
+\\usepackage{hyperref}
+\\title{${title}}
+\\begin{document}
+${body}
+\\end{document}`;
+
+// Render LaTeX source to HTML using latex.js, returned as a sandboxed iframe
+// (latex.js renders to a shadow DOM-style document via createGenerator()).
+function LatexPreview({ source, title }) {
+ const containerRef = useRef(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ let cancelled = false;
+ setError(null);
+ (async () => {
+ try {
+ // Dynamic import — latex.js is heavy, we only pay the cost when used.
+ const latexJs = await import('latex.js');
+ if (cancelled) return;
+ const { HtmlGenerator, parse } = latexJs;
+ const generator = new HtmlGenerator({ hyphenate: false });
+ const doc = parse(wrapLatexDoc(source || '', title), { generator });
+ if (cancelled || !containerRef.current) return;
+ containerRef.current.innerHTML = '';
+ const fragment = doc.htmlDocument().body;
+ containerRef.current.appendChild(fragment);
+ } catch (e) {
+ if (!cancelled) setError(e.message || String(e));
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [source, title]);
+
+ return (
+
+ {error && (
+
+
+
+
LaTeX parse error
+
{error}
+
+
+ )}
+
+
+ );
+}
+
+// PDF compile via LaTeX-Online API. POST the wrapped doc, get a PDF blob back.
+function PdfPanel({ source, title, onClose }) {
+ const [busy, setBusy] = useState(true);
+ const [pdfUrl, setPdfUrl] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ let blobUrl = null;
+ (async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ // YtoTech LaTeX-Online accepts JSON with the .tex source.
+ // CORS-enabled. Returns PDF binary on success.
+ const res = await fetch('https://latex.ytotech.com/builds/sync', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ compiler: 'pdflatex',
+ resources: [{ main: true, content: wrapLatexDoc(source, title) }],
+ }),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+ const blob = await res.blob();
+ if (cancelled) return;
+ blobUrl = URL.createObjectURL(blob);
+ setPdfUrl(blobUrl);
+ } catch (e) {
+ if (!cancelled) setError(e.message || String(e));
+ } finally {
+ if (!cancelled) setBusy(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
+ };
+ }, [source, title]);
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}>
+
e.stopPropagation()}>
+
+
+
+
PDF preview · pdflatex
+
+ Compiled via{' '}
+
+ latex-on-http
+
+ {' '}— a free open-source service. CORS-enabled.
+
+
+ {pdfUrl && (
+
+ Save PDF
+
+ )}
+
+
+
+ {busy && (
+
+ )}
+ {error && (
+
+
Compile failed
+ {error}
+
+ The free latex-online service can be flaky and doesn't support every package. The HTML preview to the left should still work for most thesis-shaped documents.
+
+
+ )}
+ {pdfUrl && !busy && !error && (
+
+ )}
+
+
+
+ );
+}
+
+// ============================================================================
+// LatexEditor — split-pane editor + preview, with PDF compile button
+// ============================================================================
+export default function LatexEditor({ value, onChange, title, theme = 'dark' }) {
+ const [mode, setMode] = useState('split'); // 'split' | 'source' | 'preview'
+ const [pdfOpen, setPdfOpen] = useState(false);
+
+ const extensions = useMemo(() => [
+ StreamLanguage.define(stex),
+ ], []);
+
+ const cmTheme = theme === 'light' ? 'light' : 'dark';
+
+ return (
+
+
+
+ setMode('split')} title="Editor + preview side by side">
+ Split
+
+ setMode('source')} title="Just the LaTeX source">
+ Source
+
+ setMode('preview')} title="Just the rendered HTML">
+ Preview
+
+
+
+
setPdfOpen(true)} title="Compile to PDF with real pdflatex">
+ Compile PDF
+
+
+
+ {(mode === 'split' || mode === 'source') && (
+
+
+
+ )}
+ {(mode === 'split' || mode === 'preview') && (
+
+ )}
+
+ {pdfOpen &&
setPdfOpen(false)}/>}
+
+ );
+}
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasModals.js b/phd-advisor-frontend/src/components/canvas/CanvasModals.js
new file mode 100644
index 00000000..d6404dd0
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasModals.js
@@ -0,0 +1,1096 @@
+import React, { useState, useMemo, useRef } from 'react';
+import Icon from './CanvasIcon';
+import { WIDGET_CATALOG, CATEGORIES } from './canvasData';
+
+const fireToast = (msg, kind = 'success') =>
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
+
+// ---------- Add citation (with DOI lookup via CrossRef) ----------
+export function AddCitationModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [authors, setA] = useState(init.authors || '');
+ const [title, setT] = useState(init.title || '');
+ const [journal, setJ] = useState(init.journal || '');
+ const [year, setY] = useState(init.year || new Date().getFullYear());
+ const [doi, setDoi] = useState(init.doi || '');
+ const [lookingUp, setLookingUp] = useState(false);
+ const [bibtexInput, setBibtexInput] = useState('');
+ const [showBibtex, setShowBibtex] = useState(false);
+ const valid = authors && title && journal;
+ const editing = !!init.key;
+
+ const lookupDoi = async () => {
+ const cleaned = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
+ if (!cleaned) return;
+ setLookingUp(true);
+ try {
+ const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
+ if (!res.ok) throw new Error('Not found');
+ const json = await res.json();
+ const w = json.message;
+ const authorList = (w.author || []).map(a => `${a.family || ''}, ${(a.given || '').charAt(0)}.`).join('; ');
+ setA(authorList || 'Unknown');
+ setT((w.title && w.title[0]) || 'Untitled');
+ setJ((w['container-title'] && w['container-title'][0]) || '');
+ setY((w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || new Date().getFullYear());
+ fireToast('DOI resolved · fields filled');
+ } catch (e) {
+ fireToast(`DOI lookup failed: ${e.message}`, 'danger');
+ } finally {
+ setLookingUp(false);
+ }
+ };
+
+ const importBibtex = () => {
+ // Minimal BibTeX parser: pull author/title/journal/year out of the first @entry block.
+ const get = (field) => {
+ const m = bibtexInput.match(new RegExp(`${field}\\s*=\\s*[{"]([^}"]+)`, 'i'));
+ return m ? m[1].trim() : '';
+ };
+ const a = get('author');
+ const t = get('title');
+ const j = get('journal') || get('booktitle');
+ const y = get('year');
+ if (!t) { fireToast('Could not parse BibTeX', 'danger'); return; }
+ setA(a); setT(t); setJ(j); setY(y || new Date().getFullYear());
+ setShowBibtex(false);
+ fireToast('BibTeX imported');
+ };
+
+ const submit = () => {
+ if (!valid) return;
+ const key = init.key || (authors.split(',')[0] || 'cite').toLowerCase().replace(/[^a-z]/g, '') + year;
+ data.onAdd({ key, authors, title, journal, year: +year, cited: init.cited || 0, doi: doi || init.doi });
+ fireToast(`${editing ? 'Updated' : 'Added'} @${key}`);
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit citation' : 'Add citation'}
+
{editing ? `@${init.key}` : 'Paste a DOI or BibTeX, or fill in manually.'}
+
+
+
+
+
+
+
DOI
+
+
setDoi(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') lookupDoi(); }}/>
+
+ {lookingUp ? <>
Looking up> : <>Resolve>}
+
+
+
+
+ setShowBibtex(s => !s)} style={{ padding: '4px 8px', fontSize: 11 }}>
+ {showBibtex ? 'Hide BibTeX' : 'Import BibTeX'}
+
+
+ {showBibtex && (
+
+ Paste a BibTeX entry
+
+ )}
+
+ Authors
+ setA(e.target.value)} placeholder="Smith, J., & Doe, A."/>
+
+
+ Title
+ setT(e.target.value)} placeholder="A study of …"/>
+
+
+
+
+
+ Cancel
+
+ {editing ? 'Save changes' : 'Add citation'}
+
+
+
+ );
+}
+
+// ---------- Add task ----------
+export function AddTaskModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [title, setT] = useState(init.title || '');
+ const [priority, setP] = useState(init.priority || 'med');
+ const [meta, setM] = useState(init.meta || '');
+ const editing = !!init.id;
+
+ const submit = () => {
+ if (!title) return;
+ data.onAdd({ title, priority, meta: meta || '—' });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit task' : 'Add task'}
+
{editing ? '' : 'It\'ll be added to the column.'}
+
+
+
+
+
+
+ Task
+ setT(e.target.value)} placeholder="What needs doing?"/>
+
+
+
+
Priority
+
+ {[['high', 'High'], ['med', 'Med'], ['low', 'Low']].map(([v, l]) => (
+ setP(v)}>{l}
+ ))}
+
+
+
+ Note / due
+ setM(e.target.value)} placeholder="May 22 · 2h"/>
+
+
+
+
+
+ Cancel
+
+ {editing ? 'Save changes' : 'Add task'}
+
+
+
+ );
+}
+
+// ---------- Add deadline ----------
+export function AddDeadlineModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [title, setT] = useState(init.title || '');
+ const [date, setD] = useState(init.date || '');
+ const [tag, setG] = useState(init.tag || 'writing');
+ const editing = !!init.id;
+
+ const submit = () => {
+ if (!title || !date) return;
+ data.onAdd({ title, date, tag });
+ fireToast(`${editing ? 'Updated' : 'Added'} · ${title}`);
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit deadline' : 'Add deadline'}
+
Will appear sorted by urgency.
+
+
+
+
+
+
+ What's due
+ setT(e.target.value)} placeholder="NSF report, COSYNE abstract…"/>
+
+
+
+ Date
+ setD(e.target.value)}/>
+
+
+ Category
+ setG(e.target.value)}>
+ Writing
+ Lab
+ Conference
+ Funding
+ Milestone
+
+
+
+
+
+
+ Cancel
+
+ {editing ? 'Save' : 'Add deadline'}
+
+
+
+ );
+}
+
+// ---------- Log words ----------
+export function LogWordsModal({ data, onClose }) {
+ const [n, setN] = useState(data.today);
+ const submit = () => {
+ data.onLog(+n || 0);
+ fireToast(`Logged ${n} words today.`);
+ onClose();
+ };
+ return (
+ e.stopPropagation()}>
+
+
+
+
Log today's words
+
Total written today, including edits to existing chapters.
+
+
+
+
+
+ Words written
+ setN(e.target.value)} style={{ fontFamily: 'var(--canvas-mono)', fontSize: 18, padding: '12px 14px' }}/>
+
+
+ {[0, 100, 250, 500, 750, 1000].map(v => (
+ setN(v)}>{v}
+ ))}
+
+
+
+ Cancel
+ Log words
+
+
+ );
+}
+
+// ---------- Confirm remove ----------
+export function ConfirmRemoveModal({ data, onClose }) {
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+
Remove "{data.label}"?
+
Widget state stays in your project — you can add it back from the palette.
+
+
+
+ Cancel
+ { data.onConfirm(); onClose(); }}>
+ Remove
+
+
+
+ );
+}
+
+// ---------- Reading paper (with CrossRef search/DOI lookup) ----------
+export function ReadingPaperModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [title, setT] = useState(init.title || '');
+ const [priority, setP] = useState(init.priority || 'med');
+ const [time, setTime] = useState(init.time || '1h');
+ const [doi, setDoi] = useState(init.doi || '');
+ const [searchQ, setSearchQ] = useState('');
+ const [searching, setSearching] = useState(false);
+ const [results, setResults] = useState([]);
+ const editing = !!init.title && data.initial;
+
+ const lookupByDoi = async () => {
+ const cleaned = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
+ if (!cleaned) return;
+ setSearching(true);
+ try {
+ const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
+ if (!res.ok) throw new Error('not found');
+ const json = await res.json();
+ const w = json.message;
+ const author = (w.author && w.author[0]) ? `${w.author[0].family}` : 'Unknown';
+ const yr = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || '';
+ setT(`${author} ${yr} — ${(w.title && w.title[0]) || 'Untitled'}`);
+ fireToast('DOI resolved');
+ } catch (e) {
+ fireToast(`Lookup failed: ${e.message}`, 'danger');
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ const searchCrossref = async () => {
+ if (!searchQ.trim()) return;
+ setSearching(true);
+ try {
+ const res = await fetch(`https://api.crossref.org/works?query=${encodeURIComponent(searchQ)}&rows=5&select=DOI,title,author,issued`);
+ const json = await res.json();
+ setResults(json.message.items || []);
+ } catch (e) {
+ fireToast(`Search failed: ${e.message}`, 'danger');
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ const pickResult = (w) => {
+ const author = (w.author && w.author[0]) ? w.author[0].family : 'Unknown';
+ const yr = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || '';
+ setT(`${author} ${yr} — ${(w.title && w.title[0]) || 'Untitled'}`);
+ setDoi(w.DOI || '');
+ setResults([]);
+ };
+
+ const submit = () => {
+ if (!title) return;
+ data.onAdd({ title, priority, time, doi });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit paper' : 'Queue a paper'}
+
Search by title, paste a DOI, or type freely.
+
+
+
+
+
+
+
Search CrossRef
+
+
setSearchQ(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') searchCrossref(); }}/>
+
+ {searching ?
: }
+ Search
+
+
+
+ {results.length > 0 && (
+
+ {results.map((w, i) => (
+
pickResult(w)}>
+
+
{(w.title && w.title[0]) || 'Untitled'}
+
+ {(w.author && w.author.slice(0, 2).map(a => a.family).join(', ')) || ''}
+ {w.author && w.author.length > 2 ? ' et al.' : ''}
+ {' · '}
+ {(w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || ''}
+
+
+
+ ))}
+
+ )}
+
+
DOI (optional)
+
+ setDoi(e.target.value)} placeholder="10.1038/…"/>
+ Resolve
+
+
+
+ Title
+ setT(e.target.value)} placeholder="Author Year — Short title"/>
+
+
+
+ Priority
+ setP(e.target.value)}>
+ High
+ Med
+ Low
+
+
+
+ Est. read time
+ setTime(e.target.value)} placeholder="2h, 90m, etc."/>
+
+
+
+
+
+ Cancel
+ {editing ? 'Save' : 'Queue'}
+
+
+ );
+}
+
+// ---------- Budget item ----------
+export function BudgetItemModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [label, setL] = useState(init.label || '');
+ const [spent, setS] = useState(init.spent ?? 0);
+ const [color, setC] = useState(init.color || '#3dd9d6');
+ const editing = !!init.label;
+ const colors = ['#3dd9d6', '#e864b8', '#f5b454', '#7ed98a', '#9b8cff', '#f06a6a'];
+
+ const submit = () => {
+ if (!label) return;
+ data.onSave({ label, spent: +spent || 0, color });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit category' : 'Add category'}
+
+
+
+
+
+
+ Category
+ setL(e.target.value)} placeholder="Equipment, travel, etc."/>
+
+
+ Amount spent ($)
+ setS(e.target.value)} style={{ fontFamily: 'var(--canvas-mono)' }}/>
+
+
+
Color
+
+ {colors.map(c => (
+ setC(c)} title={c}
+ style={{ width: 28, height: 28, borderRadius: 6, background: c, border: color === c ? '2px solid var(--canvas-text)' : '2px solid transparent', cursor: 'pointer' }}/>
+ ))}
+
+
+
+
+
+ {editing && data.onDelete && (
+ { data.onDelete(); onClose(); }}>Delete
+ )}
+ Cancel
+ {editing ? 'Save' : 'Add'}
+
+
+ );
+}
+
+// ---------- Note (with @mention autocomplete) ----------
+export function NoteModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [text, setT] = useState(init.text || '');
+ const [tag, setG] = useState(init.tag || '');
+ const [linkTo, setL] = useState(init.linkTo || '');
+ const [mention, setMention] = useState(null); // { start, query, choices }
+ const [mentionIdx, setMentionIdx] = useState(0);
+ const taRef = useRef(null);
+ const editing = !!init.id;
+
+ // Pull mention sources from the persisted canvas state so the modal stays self-contained.
+ const mentionSources = useMemo(() => {
+ try {
+ const all = JSON.parse(localStorage.getItem('canvas-states-v2') || '{}');
+ const out = [];
+ (all.bibliography?.entries || []).forEach(e => out.push({ key: '@' + e.key, label: e.title, kind: 'cite' }));
+ (all.writing?.chapters || []).forEach(c => out.push({ key: '@' + c.name.replace(/\s+/g, '-'), label: c.name, kind: 'chapter' }));
+ (all.kanban?.cards || []).forEach(c => out.push({ key: '@' + c.title.slice(0, 30).replace(/\s+/g, '-'), label: c.title, kind: 'task' }));
+ return out;
+ } catch { return []; }
+ }, []);
+
+ const onTextChange = (e) => {
+ const val = e.target.value;
+ const cursor = e.target.selectionStart;
+ setT(val);
+ // Look back from cursor for an @mention being typed
+ const before = val.slice(0, cursor);
+ const m = before.match(/@(\S*)$/);
+ if (m) {
+ const q = m[1].toLowerCase();
+ const choices = mentionSources
+ .filter(s => s.label.toLowerCase().includes(q) || s.key.toLowerCase().includes('@' + q))
+ .slice(0, 6);
+ setMention({ start: cursor - m[0].length, query: m[1], choices });
+ setMentionIdx(0);
+ } else {
+ setMention(null);
+ }
+ };
+
+ const insertMention = (item) => {
+ if (!mention) return;
+ const before = text.slice(0, mention.start);
+ const after = text.slice(mention.start + 1 + mention.query.length); // +1 for the @
+ const inserted = item.key + ' ';
+ setT(before + inserted + after);
+ setMention(null);
+ setTimeout(() => {
+ if (taRef.current) {
+ const pos = before.length + inserted.length;
+ taRef.current.setSelectionRange(pos, pos);
+ taRef.current.focus();
+ }
+ }, 0);
+ };
+
+ const onKeyDown = (e) => {
+ if (!mention || mention.choices.length === 0) return;
+ if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIdx(i => Math.min(mention.choices.length - 1, i + 1)); }
+ if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIdx(i => Math.max(0, i - 1)); }
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertMention(mention.choices[mentionIdx]); }
+ if (e.key === 'Escape') setMention(null);
+ };
+
+ const submit = () => {
+ if (!text.trim()) return;
+ data.onSave({ text: text.trim(), tag: tag.trim(), linkTo: linkTo.trim() });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit note' : 'New note'}
+
Markdown supported · **bold**, *italic*, `code`, lists, links
+
+
+
+
+
+
+
+ {mention && mention.choices.length > 0 && (
+
+ {mention.choices.map((c, i) => (
+ setMentionIdx(i)} onClick={() => insertMention(c)}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 8,
+ padding: '6px 10px', textAlign: 'left',
+ background: i === mentionIdx ? 'var(--canvas-surface-2)' : 'transparent',
+ color: 'var(--canvas-text)', border: 'none', cursor: 'pointer', fontSize: 12,
+ }}>
+ {c.kind}
+ {c.key}
+ {c.label}
+
+ ))}
+
+ )}
+
+
+
+
+
+ {editing && data.onDelete && (
+ { data.onDelete(); onClose(); }}>Delete
+ )}
+ Cancel
+ {editing ? 'Save' : 'Add note'}
+
+
+ );
+}
+
+// ---------- Habit ----------
+export function HabitModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [label, setL] = useState(init.label || '');
+ const [icon, setI] = useState(init.icon || 'flame');
+ const editing = !!init.id;
+ const iconOpts = ['flame', 'pencil', 'book', 'flask', 'heart', 'graph', 'star'];
+ const submit = () => {
+ if (!label) return;
+ data.onSave({ label, icon });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit habit' : 'New habit'}
+
Tracked daily. Tap squares to mark done.
+
+
+
+
+
+
+ Habit
+ setL(e.target.value)} placeholder="Read 1 paper, write 30 min…"/>
+
+
+
Icon
+
+ {iconOpts.map(n => (
+ setI(n)} title={n}
+ style={{ width: 32, height: 32, borderRadius: 7, background: icon === n ? 'var(--canvas-accent-glow)' : 'var(--canvas-surface-2)', color: icon === n ? 'var(--canvas-accent)' : 'var(--canvas-text-3)', border: `1px solid ${icon === n ? 'var(--canvas-accent)' : 'var(--canvas-border)'}`, display: 'grid', placeItems: 'center', cursor: 'pointer' }}>
+
+
+ ))}
+
+
+
+
+
+ {editing && data.onDelete && (
+ { data.onDelete(); onClose(); }}>Delete
+ )}
+ Cancel
+ {editing ? 'Save' : 'Add habit'}
+
+
+ );
+}
+
+// ---------- Goal ----------
+export function GoalModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [label, setL] = useState(init.label || '');
+ const [progress, setP] = useState(init.progress ?? 0);
+ const [due, setD] = useState(init.due || '');
+ const editing = !!init.id;
+
+ const submit = () => {
+ if (!label) return;
+ data.onSave({ label, progress: +progress, due });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit goal' : 'New goal'}
+
Quarterly OKR or dissertation milestone.
+
+
+
+
+
+
+ Goal
+ setL(e.target.value)} placeholder="Submit Aim 2 to advisor by July"/>
+
+
+
+
+
+ {editing && data.onDelete && (
+ { data.onDelete(); onClose(); }}>Delete
+ )}
+ Cancel
+ {editing ? 'Save' : 'Add goal'}
+
+
+ );
+}
+
+// ---------- Meeting ----------
+export function MeetingModal({ data, onClose }) {
+ const init = data.initial || {};
+ const [who, setW] = useState(init.who || '');
+ const [date, setD] = useState(init.date || new Date().toISOString().slice(0, 10));
+ const [notes, setN] = useState(init.notes || '');
+ const [actions, setA] = useState(init.actions || '');
+ const editing = !!init.id;
+
+ const submit = () => {
+ if (!who) return;
+ data.onSave({ who, date, notes, actions });
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
{editing ? 'Edit meeting' : 'Log meeting'}
+
Advisor / collaborator / sponsor — anyone you want to track.
+
+
+
+
+
+
+
+ Notes
+
+
+ Action items (one per line)
+
+
+
+
+ {editing && data.onDelete && (
+ { data.onDelete(); onClose(); }}>Delete
+ )}
+ Cancel
+ {editing ? 'Save' : 'Log meeting'}
+
+
+ );
+}
+
+// ---------- Widget palette ----------
+export function PaletteModal({ data, onClose }) {
+ const [q, setQ] = useState('');
+ const [cat, setCat] = useState('all');
+ const present = new Set(data.layout.map(w => w.type));
+
+ const filtered = useMemo(() => {
+ let r = WIDGET_CATALOG;
+ if (cat !== 'all') r = r.filter(w => w.cat === cat);
+ if (q) {
+ const ql = q.toLowerCase();
+ r = r.filter(w => w.name.toLowerCase().includes(ql) || w.desc.toLowerCase().includes(ql));
+ }
+ return r;
+ }, [q, cat]);
+
+ const add = (w) => {
+ if (present.has(w.type)) return;
+ data.onAdd(w);
+ fireToast(`${w.name} added to workspace`, w.critic ? 'critic' : 'success');
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
Add widget
+
{WIDGET_CATALOG.length} widgets · {WIDGET_CATALOG.filter(w => w.critic).length} anti-yes-man
+
+
+
+
+
+
+
+ setQ(e.target.value)}/>
+
+
+ {CATEGORIES.map(c => (
+ setCat(c.id)}>
+ {c.label}{c.critic && ★ }
+
+ ))}
+
+
+
+ {filtered.map(w => {
+ const added = present.has(w.type);
+ return (
+
add(w)} disabled={added}>
+
+
+
+ {w.name}
+ {w.critic && WEDGE }
+ {w.critic && BEST IN CHAT }
+ {w.enhanced && !w.stub && !w.critic && ENHANCED }
+ {w.stub && SOON }
+
+
{w.desc}
+
+ {added && added }
+
+ );
+ })}
+ {filtered.length === 0 && (
+
+ No widgets match "{q}".
+
+ )}
+
+
+
+ );
+}
+
+// ---------- Global content search (notes / quotes / citations / kanban / deadlines / outline) ----------
+export function GlobalSearchModal({ data, onClose }) {
+ const [q, setQ] = useState('');
+ const [idx, setIdx] = useState(0);
+ const states = data.states || {};
+
+ const items = useMemo(() => {
+ if (!q.trim()) return [];
+ const ql = q.toLowerCase();
+ const out = [];
+ // Notes
+ (states.notes?.items || []).forEach(n => {
+ if ((n.text || '').toLowerCase().includes(ql) || (n.tag || '').toLowerCase().includes(ql) || (n.linkTo || '').toLowerCase().includes(ql)) {
+ out.push({ kind: 'Note', label: (n.text || '').slice(0, 80), sub: n.tag ? `#${n.tag}` : '', icon: 'notes', widgetType: 'notes' });
+ }
+ });
+ // Citations
+ (states.bibliography?.entries || []).forEach(e => {
+ const blob = `${e.title} ${e.authors} ${e.journal} ${e.key}`.toLowerCase();
+ if (blob.includes(ql)) out.push({ kind: 'Citation', label: e.title, sub: `${e.authors} (${e.year})`, icon: 'book', widgetType: 'bibliography' });
+ });
+ // Kanban
+ (states.kanban?.cards || []).forEach(c => {
+ if (`${c.title} ${c.meta || ''}`.toLowerCase().includes(ql)) {
+ out.push({ kind: 'Task', label: c.title, sub: `${c.priority?.toUpperCase()} · ${c.meta || ''}`, icon: 'kanban', widgetType: 'kanban' });
+ }
+ });
+ // Deadlines
+ (states.deadlines || []).forEach(d => {
+ if (`${d.title} ${d.tag}`.toLowerCase().includes(ql)) {
+ out.push({ kind: 'Deadline', label: d.title, sub: `${d.date} · ${d.tag}`, icon: 'calendar', widgetType: 'deadlines' });
+ }
+ });
+ // Highlights
+ (states.highlights?.items || []).forEach(h => {
+ if (`${h.text} ${h.citeKey}`.toLowerCase().includes(ql)) {
+ out.push({ kind: 'Quote', label: `"${h.text}"`.slice(0, 80), sub: h.citeKey ? `@${h.citeKey}` : '', icon: 'cite', widgetType: 'highlights' });
+ }
+ });
+ // Outline
+ (states.outline?.items || []).forEach(o => {
+ if ((o.text || '').toLowerCase().includes(ql)) {
+ out.push({ kind: 'Outline', label: o.text, sub: `depth ${o.depth}`, icon: 'list', widgetType: 'outline' });
+ }
+ });
+ // Documenter
+ (states.documenter?.entries || []).forEach(e => {
+ if ((e.text || '').toLowerCase().includes(ql)) {
+ out.push({ kind: 'Journal', label: e.text.slice(0, 80), sub: e.date, icon: 'pencil', widgetType: 'documenter' });
+ }
+ });
+ return out.slice(0, 30);
+ }, [q, states]);
+
+ const jumpTo = (it) => {
+ const el = document.querySelector(`[data-widget-id^="w-"][data-widget-type="${it.widgetType}"]`)
+ || document.querySelector(`.widget`); // fallback: scroll to first
+ if (el) {
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
+ }
+ onClose();
+ };
+
+ return (
+ e.stopPropagation()}>
+
+
+ { setQ(e.target.value); setIdx(0); }}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }
+ if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
+ if (e.key === 'Enter' && items[idx]) jumpTo(items[idx]);
+ }}
+ placeholder="Search across notes, citations, tasks, quotes, deadlines…"
+ style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: 'var(--canvas-text)', fontSize: 15, padding: '4px 0' }}
+ />
+ esc
+
+
+ {!q.trim() && (
+
+ Type to search across your canvas content.
+
+ )}
+ {q.trim() && items.length === 0 && (
+
+ No matches for "{q}".
+
+ )}
+ {items.map((it, i) => (
+
jumpTo(it)} onMouseEnter={() => setIdx(i)}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 12,
+ padding: '8px 12px', borderRadius: 7, textAlign: 'left',
+ background: idx === i ? 'var(--canvas-surface-2)' : 'transparent',
+ color: 'var(--canvas-text)', border: 'none', cursor: 'pointer',
+ }}>
+
+
+
+
+
{it.label}
+
{it.sub}
+
+ {it.kind}
+
+ ))}
+
+
+ ↑↓ navigate ↵ jump esc close
+
+
+ );
+}
+
+// ---------- Command palette ----------
+export function CommandPaletteModal({ data, onClose }) {
+ const [q, setQ] = useState('');
+ const [idx, setIdx] = useState(0);
+
+ const items = useMemo(() => {
+ const all = [
+ ...data.layout.map(w => {
+ const meta = WIDGET_CATALOG.find(m => m.type === w.type);
+ return { kind: 'widget', label: meta?.name || w.type, icon: meta?.icon || 'layout', sub: 'Open widget', action: () => {
+ const el = document.querySelector(`[data-widget-id="${w.id}"]`);
+ if (el) {
+ el.scrollIntoView({ block: 'center' });
+ el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
+ setTimeout(() => { el.style.boxShadow = ''; }, 1400);
+ }
+ }};
+ }),
+ ...WIDGET_CATALOG.filter(w => !data.layout.find(l => l.type === w.type)).map(w => ({
+ kind: 'add', label: 'Add: ' + w.name, icon: w.icon, sub: w.desc, action: () => data.onAddWidget(w),
+ })),
+ { kind: 'cmd', label: 'Switch to Insights', icon: 'insights', sub: 'View AI summaries', action: () => data.onSetView('insights') },
+ { kind: 'cmd', label: 'Switch to Workspace', icon: 'layout', sub: 'View dashboard', action: () => data.onSetView('workspace') },
+ { kind: 'cmd', label: 'Toggle theme', icon: 'star', sub: 'Dark / light', action: () => data.onToggleTheme() },
+ { kind: 'cmd', label: 'Export workspace JSON', icon: 'download', sub: 'Download current layout', action: () => data.onExport() },
+ ];
+ if (!q) return all.slice(0, 8);
+ const ql = q.toLowerCase();
+ return all.filter(it => it.label.toLowerCase().includes(ql) || it.sub.toLowerCase().includes(ql)).slice(0, 12);
+ }, [q, data]);
+
+ const run = (i) => { items[i]?.action(); onClose(); };
+
+ return (
+ e.stopPropagation()}>
+
+
+ { setQ(e.target.value); setIdx(0); }}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }
+ if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
+ if (e.key === 'Enter') run(idx);
+ }}
+ placeholder="Search widgets, switch view, run a command…"
+ style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: 'var(--canvas-text)', fontSize: 15, padding: '4px 0' }}
+ />
+ esc
+
+
+ {items.map((it, i) => (
+
run(i)}
+ onMouseEnter={() => setIdx(i)}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 12,
+ padding: '8px 12px', borderRadius: 7, textAlign: 'left',
+ background: idx === i ? 'var(--canvas-surface-2)' : 'transparent',
+ color: 'var(--canvas-text)', border: 'none', cursor: 'pointer',
+ }}>
+
+
+
+
+
{it.label}
+
{it.sub}
+
+ {it.kind}
+
+ ))}
+ {items.length === 0 && (
+
No matches.
+ )}
+
+
+ ↑↓ navigate ↵ run esc close
+
+
+ );
+}
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasWelcomeTour.js b/phd-advisor-frontend/src/components/canvas/CanvasWelcomeTour.js
new file mode 100644
index 00000000..cc344292
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasWelcomeTour.js
@@ -0,0 +1,93 @@
+import React, { useState, useEffect } from 'react';
+import Icon from './CanvasIcon';
+import { MOD } from './platform';
+
+const TOUR_KEY = 'canvas-tour-seen-v1';
+
+const STEPS = [
+ {
+ title: 'Welcome to your Canvas',
+ icon: 'sparkles',
+ body: 'This is your research workspace. Two views — Insights (AI-summarized highlights from your chats) and Workspace (a customizable dashboard of widgets). It starts empty so you can build it the way you want.',
+ },
+ {
+ title: 'Add widgets from the palette',
+ icon: 'plus',
+ body: `Click "Add widget" on the Workspace view, or hit ${MOD}+K and search. There are 30+ widgets — bibliography, kanban, pomodoro, writing tracker, plus three "anti-yes-man" widgets that push back on your thinking.`,
+ },
+ {
+ title: 'Make it yours',
+ icon: 'layout',
+ body: 'Drag widget headers to reorder. Click the size pill (S/M/L) to resize. Hover and click trash to remove. Layout and content auto-save to your browser.',
+ },
+ {
+ title: 'Try the anti-yes-man widgets',
+ icon: 'gavel',
+ body: 'Reviewer 2, Devil\'s Advocate, and Scope Realism are tuned to push back, not validate. They\'re where the real work gets sharpened. Add them last — when you\'re ready for honest feedback.',
+ },
+];
+
+const CanvasWelcomeTour = ({ forceShow = false, onClose }) => {
+ const [step, setStep] = useState(0);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const seen = localStorage.getItem(TOUR_KEY);
+ if (forceShow || !seen) setVisible(true);
+ }, [forceShow]);
+
+ const dismiss = () => {
+ localStorage.setItem(TOUR_KEY, '1');
+ setVisible(false);
+ if (onClose) onClose();
+ };
+
+ if (!visible) return null;
+
+ const s = STEPS[step];
+ const isLast = step === STEPS.length - 1;
+
+ return (
+ { if (e.target === e.currentTarget) dismiss(); }}>
+
e.stopPropagation()}>
+
+
+
+
{s.title}
+
Step {step + 1} of {STEPS.length}
+
+
+
+
+
+
+ {STEPS.map((_, i) => (
+ setStep(i)}
+ style={{
+ width: 8, height: 8, borderRadius: '50%', cursor: 'pointer',
+ background: i === step ? 'var(--canvas-accent)' : 'var(--canvas-surface-3)',
+ transition: 'background .15s',
+ }}/>
+ ))}
+
+
+ {step > 0 && (
+ setStep(s => s - 1)}>Back
+ )}
+ {!isLast && (
+ Skip
+ )}
+ isLast ? dismiss() : setStep(s => s + 1)}>
+ {isLast ? <>Get started> : <>Next>}
+
+
+
+
+
+ );
+};
+
+export default CanvasWelcomeTour;
diff --git a/phd-advisor-frontend/src/components/canvas/CanvasWidgets.js b/phd-advisor-frontend/src/components/canvas/CanvasWidgets.js
new file mode 100644
index 00000000..fb06600f
--- /dev/null
+++ b/phd-advisor-frontend/src/components/canvas/CanvasWidgets.js
@@ -0,0 +1,1663 @@
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import Icon from './CanvasIcon';
+import { MOD } from './platform';
+
+const fireToast = (msg, kind = 'success') =>
+ window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
+const fireActivity = (source, msg) =>
+ window.dispatchEvent(new CustomEvent('canvas-activity', { detail: { source, msg } }));
+
+// Shared empty-state block — every widget uses this so the look stays consistent.
+function EmptyState({ icon = 'sparkles', title, hint, action }) {
+ return (
+
+
+
{title}
+ {hint &&
{hint}
}
+ {action &&
{action}
}
+
+ );
+}
+
+// Cross-widget drag-drop helpers — one mime type, JSON payload tagged by `kind`.
+const X_MIME = 'application/x-canvas-item';
+const setDragPayload = (e, kind, payload) => {
+ e.dataTransfer.setData(X_MIME, JSON.stringify({ kind, payload }));
+ e.dataTransfer.effectAllowed = 'copy';
+};
+const readDragPayload = (e) => {
+ try { return JSON.parse(e.dataTransfer.getData(X_MIME)); } catch { return null; }
+};
+
+// ===== Bibliography =====
+export function BibliographyWidget({ state, setState, openModal }) {
+ const formats = ['APA', 'MLA', 'Chicago', 'BibTeX'];
+ const fmt = state.format || 'APA';
+ const [sortBy, setSortBy] = useState('year');
+ const [dropOver, setDropOver] = useState(false);
+
+ const onDrop = async (e) => {
+ e.preventDefault();
+ setDropOver(false);
+ const data = readDragPayload(e);
+ if (!data || data.kind !== 'paper') return;
+ const p = data.payload;
+ // If we have a DOI, hit CrossRef and build a real citation; else stub from title.
+ const titleStr = p.title || 'Untitled';
+ let entry = {
+ key: 'cite' + Date.now(),
+ authors: 'Unknown',
+ title: titleStr.replace(/^.*?— /, ''),
+ journal: '',
+ year: new Date().getFullYear(),
+ cited: 0,
+ doi: p.doi || '',
+ };
+ if (p.doi) {
+ try {
+ const cleaned = p.doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
+ const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
+ if (res.ok) {
+ const json = await res.json();
+ const w = json.message;
+ entry.authors = (w.author || []).map(a => `${a.family || ''}, ${(a.given || '').charAt(0)}.`).join('; ') || entry.authors;
+ entry.title = (w.title && w.title[0]) || entry.title;
+ entry.journal = (w['container-title'] && w['container-title'][0]) || '';
+ entry.year = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || entry.year;
+ const firstAuthor = (entry.authors.split(',')[0] || 'cite').toLowerCase().replace(/[^a-z]/g, '');
+ entry.key = firstAuthor + entry.year;
+ }
+ } catch { /* fall through with stub */ }
+ }
+ setState({ ...state, entries: [...state.entries, entry] });
+ fireToast(`@${entry.key} added from Reading Queue`);
+ fireActivity('Bibliography', `Added @${entry.key} (from Reading Queue)`);
+ };
+
+ const setFmt = (f) => setState({ ...state, format: f });
+
+ const formatEntry = (e) => {
+ if (fmt === 'APA') return `${e.authors} (${e.year}). ${e.title}. ${e.journal} .`;
+ if (fmt === 'MLA') return `${e.authors.split(',')[0]}, et al. "${e.title}." ${e.journal} , ${e.year}.`;
+ if (fmt === 'Chicago') return `${e.authors}. "${e.title}." ${e.journal} (${e.year}).`;
+ return `@article{${e.key},\n author = {${e.authors}},\n title = {${e.title}},\n journal = {${e.journal}},\n year = {${e.year}}\n}`;
+ };
+
+ const sorted = useMemo(() => {
+ const arr = [...state.entries];
+ if (sortBy === 'year') arr.sort((a, b) => b.year - a.year);
+ if (sortBy === 'author') arr.sort((a, b) => a.authors.localeCompare(b.authors));
+ if (sortBy === 'cited') arr.sort((a, b) => b.cited - a.cited);
+ return arr;
+ }, [state.entries, sortBy]);
+
+ const copy = () => {
+ const out = sorted.map(formatEntry).map(s => s.replace(/<[^>]+>/g, '')).join('\n\n');
+ navigator.clipboard?.writeText(out);
+ fireToast(`${sorted.length} citations copied as ${fmt}`);
+ };
+
+ const remove = (key) => {
+ const e = state.entries.find(x => x.key === key);
+ setState({ ...state, entries: state.entries.filter(x => x.key !== key) });
+ fireToast(`Removed @${e.key}`);
+ };
+
+ const edit = (entry) => openModal('add-citation', {
+ initial: entry,
+ onAdd: (next) => setState({ ...state, entries: state.entries.map(e => e.key === entry.key ? next : e) }),
+ });
+
+ return (
+ { if (e.dataTransfer.types.includes(X_MIME)) { e.preventDefault(); setDropOver(true); } }}
+ onDragLeave={() => setDropOver(false)}
+ onDrop={onDrop}
+ style={{ display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', flex: 1 }}
+ className={dropOver ? 'canvas-drop-active' : ''}
+ >
+ {dropOver && (
+
+
+ Drop to add citation
+
+ )}
+
+
+ {formats.map(f => (
+ setFmt(f)}>{f}
+ ))}
+
+
+ setSortBy(e.target.value)}>
+ ↓ year
+ A–Z
+ ↓ cited
+
+
+ openModal('add-citation', { onAdd: (entry) => setState({ ...state, entries: [...state.entries, entry] }) })} title="Add">
+
+
+
+ {sorted.map(e => (
+
+
+
+ @{e.key}
+ cited {e.cited.toLocaleString()}x
+
+
+ edit(e)} title="Edit">
+ remove(e.key)} title="Delete">
+
+
+ ))}
+ {sorted.length === 0 && (
+
+ )}
+
+
+ );
+}
+
+// ===== Kanban (with priority filter chips + due-date sort) =====
+const PRI_RANK = { high: 0, med: 1, low: 2 };
+export function KanbanWidget({ state, setState, openModal }) {
+ const [dragId, setDragId] = useState(null);
+ const [dragCol, setDragCol] = useState(null);
+ const [editId, setEditId] = useState(null);
+ const [priFilter, setPriFilter] = useState('all');
+ const [sortBy, setSortBy] = useState('manual');
+
+ const move = (id, toCol) => setState({ ...state, cards: state.cards.map(c => c.id === id ? { ...c, col: toCol } : c) });
+ const remove = (id) => setState({ ...state, cards: state.cards.filter(c => c.id !== id) });
+ const updateTitle = (id, title) => setState({ ...state, cards: state.cards.map(c => c.id === id ? { ...c, title } : c) });
+
+ const addCard = (col) => openModal('add-task', {
+ onAdd: (card) => setState({ ...state, cards: [...state.cards, { ...card, id: 'k' + Date.now(), col }] }),
+ });
+
+ const editCard = (card) => openModal('add-task', {
+ initial: card,
+ onAdd: (next) => setState({ ...state, cards: state.cards.map(c => c.id === card.id ? { ...c, ...next } : c) }),
+ });
+
+ const visibleCards = (state.cards || []).filter(c => priFilter === 'all' || c.priority === priFilter);
+ const sortCards = (cards) => {
+ if (sortBy === 'priority') return [...cards].sort((a, b) => (PRI_RANK[a.priority] ?? 9) - (PRI_RANK[b.priority] ?? 9));
+ if (sortBy === 'due') {
+ const parseDue = (m) => {
+ if (!m) return Infinity;
+ const d = new Date(m);
+ return isNaN(d) ? Infinity : d.getTime();
+ };
+ return [...cards].sort((a, b) => parseDue(a.meta) - parseDue(b.meta));
+ }
+ return cards;
+ };
+
+ return (
+ <>
+
+ Filter
+ {[['all', 'All'], ['high', 'High'], ['med', 'Med'], ['low', 'Low']].map(([v, l]) => (
+ setPriFilter(v)} style={{ padding: '3px 9px', fontSize: 11 }}>{l}
+ ))}
+ Sort
+ setSortBy(e.target.value)}>
+ Manual
+ Priority
+ Due date
+
+
+
+ {state.cols.map(col => {
+ const cards = sortCards(visibleCards.filter(c => c.col === col.id));
+ return (
+
{ e.preventDefault(); setDragCol(col.id); }}
+ onDragLeave={() => setDragCol(null)}
+ onDrop={(e) => { e.preventDefault(); if (dragId) move(dragId, col.id); setDragCol(null); setDragId(null); }}>
+
+ {col.label}
+ {cards.length}
+
+ {cards.map(card => (
+
{ setDragId(card.id); e.dataTransfer.effectAllowed = 'move'; }}
+ onDragEnd={() => { setDragId(null); setDragCol(null); }}
+ onDoubleClick={() => editCard(card)}>
+
+ {editId === card.id ? (
+
{ updateTitle(card.id, e.target.value); setEditId(null); }}
+ onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') setEditId(null); }}
+ />
+ ) : (
+
setEditId(card.id)}>{card.title}
+ )}
+
+ {card.priority.toUpperCase()}
+ ·
+ {card.meta}
+
+ { e.stopPropagation(); remove(card.id); }} title="Delete">
+
+
+ ))}
+
addCard(col.id)}>+ Add
+
+ );
+ })}
+
+ >
+ );
+}
+
+// ===== Pomodoro =====
+export function PomodoroWidget({ state, setState }) {
+ const [running, setRunning] = useState(false);
+ const [mode, setMode] = useState('focus');
+ const [secs, setSecs] = useState(state.focus * 60);
+ const [editing, setEditing] = useState(false);
+ const total = (mode === 'focus' ? state.focus : state.brk) * 60;
+
+ useEffect(() => {
+ if (!running) return;
+ const t = setInterval(() => {
+ setSecs(s => {
+ if (s <= 1) {
+ const next = mode === 'focus' ? 'break' : 'focus';
+ setMode(next);
+ if (mode === 'focus') {
+ setState({ ...state, sessionsToday: state.sessionsToday + 1 });
+ fireToast('Focus session complete — 5 min break');
+ }
+ return (next === 'focus' ? state.focus : state.brk) * 60;
+ }
+ return s - 1;
+ });
+ }, 1000);
+ return () => clearInterval(t);
+ }, [running, mode, state, setState]);
+
+ const mm = String(Math.floor(secs / 60)).padStart(2, '0');
+ const ss = String(secs % 60).padStart(2, '0');
+ const r = 56, c = 2 * Math.PI * r;
+ const dash = c * (1 - secs / total);
+ const reset = () => { setRunning(false); setMode('focus'); setSecs(state.focus * 60); };
+
+ return (
+
+
+
+
+
+
+
+
{mm}:{ss}
+
{mode === 'focus' ? 'focus' : 'break'}
+
+
+
+ setRunning(r => !r)}>
+ {running ? 'Pause' : 'Start'}
+
+
+ setEditing(e => !e)} title="Edit durations">
+
+ {editing ? (
+
+ focus
+ { const v = +e.target.value || 25; setState({ ...state, focus: v }); if (!running) setSecs(v*60); }}/>
+ break
+ setState({ ...state, brk: +e.target.value || 5 })}/>
+ min
+
+ ) : (
+
+ today {state.sessionsToday} ·
+ {state.focus}/{state.brk} min
+
+ )}
+
+ );
+}
+
+// ===== Writing tracker =====
+// Inline writing pad with multi-chapter targets, live word count, and 28-day heatmap.
+const todayKey = () => new Date().toISOString().slice(0, 10);
+const countWords = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length;
+
+export function WritingWidget({ state, setState }) {
+ const chapters = state.chapters || [];
+ const activeId = state.activeChapterId || chapters[0]?.id;
+ const active = chapters.find(c => c.id === activeId) || chapters[0];
+ const dailyTotals = state.dailyTotals || {};
+ const today = todayKey();
+ const todayWords = dailyTotals[today] || 0;
+ const target = active?.target ?? state.target ?? 500;
+ const pct = target > 0 ? Math.min(100, Math.round((todayWords / target) * 100)) : 0;
+
+ // Streak: count consecutive days back from today with words > 0
+ const streak = (() => {
+ let s = 0;
+ const d = new Date();
+ while (true) {
+ const k = d.toISOString().slice(0, 10);
+ if ((dailyTotals[k] || 0) > 0) { s++; d.setDate(d.getDate() - 1); } else break;
+ if (s > 365) break;
+ }
+ return s;
+ })();
+
+ const updateChapter = (id, patch) => setState({
+ ...state,
+ chapters: chapters.map(c => c.id === id ? { ...c, ...patch } : c),
+ });
+
+ const onDraftChange = (text) => {
+ const words = countWords(text);
+ updateChapter(active.id, { draft: text });
+ // Track per-day session word count keyed off the active chapter's last-saved baseline
+ const sessionBaseline = active.savedAt === today ? (active.savedWords || 0) : 0;
+ const delta = Math.max(0, words - sessionBaseline);
+ setState({
+ ...state,
+ chapters: chapters.map(c => c.id === active.id ? { ...c, draft: text } : c),
+ dailyTotals: { ...dailyTotals, [today]: (dailyTotals[today] || 0) - (state._sessionDelta || 0) + delta },
+ _sessionDelta: delta,
+ });
+ };
+
+ const saveSession = () => {
+ const words = countWords(active.draft || '');
+ setState({
+ ...state,
+ chapters: chapters.map(c => c.id === active.id ? { ...c, savedAt: today, savedWords: words } : c),
+ _sessionDelta: 0,
+ });
+ fireToast(`Saved · ${words} words in ${active.name}`);
+ fireActivity('Writing', `Saved ${words} words to "${active.name}"`);
+ };
+
+ const addChapter = () => {
+ const id = 'c-' + Date.now();
+ setState({
+ ...state,
+ chapters: [...chapters, { id, name: 'New chapter', target: 500, draft: '' }],
+ activeChapterId: id,
+ });
+ };
+
+ const deleteChapter = (id) => {
+ if (chapters.length === 1) return fireToast('Need at least one chapter', 'danger');
+ const next = chapters.filter(c => c.id !== id);
+ setState({ ...state, chapters: next, activeChapterId: next[0].id });
+ };
+
+ // 28-day heatmap
+ const days = Array.from({ length: 28 }, (_, i) => {
+ const d = new Date();
+ d.setDate(d.getDate() - (27 - i));
+ return d.toISOString().slice(0, 10);
+ });
+ const allCounts = Object.values(dailyTotals);
+ const maxDay = Math.max(target, ...allCounts, 1);
+ const intensity = (n) => {
+ if (!n) return 0;
+ const ratio = n / maxDay;
+ if (ratio < 0.25) return 1;
+ if (ratio < 0.5) return 2;
+ if (ratio < 0.75) return 3;
+ return 4;
+ };
+
+ const draftWords = countWords(active?.draft || '');
+ const totalWritten = chapters.reduce((sum, c) => sum + countWords(c.draft || ''), 0);
+ const totalTarget = chapters.reduce((sum, c) => sum + (c.target || 0), 0);
+
+ return (
+ <>
+ {/* Chapter switcher */}
+
+ {chapters.map(c => (
+ setState({ ...state, activeChapterId: c.id })}
+ className={`format-tab ${c.id === active?.id ? 'active' : ''}`}
+ style={{ padding: '4px 9px', fontSize: 11, fontFamily: 'var(--canvas-sans)', textTransform: 'none', letterSpacing: 0 }}>
+ {c.name}
+
+ ))}
+ + Chapter
+
+
+ {/* Active chapter editing */}
+ {active && (
+ <>
+
+ updateChapter(active.id, { name: e.target.value })}
+ />
+ target
+ updateChapter(active.id, { target: +e.target.value || 0 })}
+ />
+ {chapters.length > 1 && (
+ deleteChapter(active.id)} title="Delete chapter">
+
+
+ )}
+
+
+