Neon:ryan commited on
Commit
dd07b04
·
1 Parent(s): 7f1718b

Enhance deliverables functionality and UI

Browse files

- Added support for multiple project drafts in the deliverables view, allowing users to manage and switch between various templates (e.g., thesis chapters, CVs, cover letters).
- Integrated LaTeX rendering capabilities using remark-math and rehype-katex for improved mathematical expression handling.
- Updated CSS styles for deliverable project tabs and rich editor blocks to enhance user experience and visual consistency.

This update significantly improves the usability of the deliverables feature, making it more versatile for users managing multiple projects.

phd-advisor-frontend/package-lock.json CHANGED
@@ -12,12 +12,15 @@
12
  "@testing-library/jest-dom": "^6.6.3",
13
  "@testing-library/react": "^16.3.0",
14
  "@testing-library/user-event": "^13.5.0",
 
15
  "lucide-react": "^0.544.0",
16
  "react": "^19.1.0",
17
  "react-dom": "^19.1.0",
18
  "react-markdown": "^10.1.0",
19
  "react-scripts": "5.0.1",
 
20
  "remark-gfm": "^4.0.1",
 
21
  "web-vitals": "^2.1.4"
22
  }
23
  },
@@ -3797,6 +3800,12 @@
3797
  "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
3798
  "license": "MIT"
3799
  },
 
 
 
 
 
 
3800
  "node_modules/@types/mdast": {
3801
  "version": "4.0.4",
3802
  "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -3866,16 +3875,6 @@
3866
  "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
3867
  "license": "MIT"
3868
  },
3869
- "node_modules/@types/react": {
3870
- "version": "19.1.9",
3871
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
3872
- "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
3873
- "license": "MIT",
3874
- "peer": true,
3875
- "dependencies": {
3876
- "csstype": "^3.0.2"
3877
- }
3878
- },
3879
  "node_modules/@types/resolve": {
3880
  "version": "1.17.1",
3881
  "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
@@ -6486,13 +6485,6 @@
6486
  "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
6487
  "license": "MIT"
6488
  },
6489
- "node_modules/csstype": {
6490
- "version": "3.1.3",
6491
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
6492
- "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
6493
- "license": "MIT",
6494
- "peer": true
6495
- },
6496
  "node_modules/damerau-levenshtein": {
6497
  "version": "1.0.8",
6498
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -9017,6 +9009,125 @@
9017
  "node": ">= 0.4"
9018
  }
9019
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9020
  "node_modules/hast-util-to-jsx-runtime": {
9021
  "version": "2.3.6",
9022
  "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -9044,6 +9155,22 @@
9044
  "url": "https://opencollective.com/unified"
9045
  }
9046
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9047
  "node_modules/hast-util-whitespace": {
9048
  "version": "3.0.0",
9049
  "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -9057,6 +9184,23 @@
9057
  "url": "https://opencollective.com/unified"
9058
  }
9059
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9060
  "node_modules/he": {
9061
  "version": "1.2.0",
9062
  "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
@@ -11312,6 +11456,22 @@
11312
  "node": ">=4.0"
11313
  }
11314
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11315
  "node_modules/keyv": {
11316
  "version": "4.5.4",
11317
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11756,6 +11916,25 @@
11756
  "url": "https://opencollective.com/unified"
11757
  }
11758
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11759
  "node_modules/mdast-util-mdx-expression": {
11760
  "version": "2.0.1",
11761
  "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -12135,6 +12314,25 @@
12135
  "url": "https://opencollective.com/unified"
12136
  }
12137
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12138
  "node_modules/micromark-factory-destination": {
12139
  "version": "2.0.1",
12140
  "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -15451,6 +15649,25 @@
15451
  "node": ">=6"
15452
  }
15453
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15454
  "node_modules/relateurl": {
15455
  "version": "0.2.7",
15456
  "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
@@ -15478,6 +15695,22 @@
15478
  "url": "https://opencollective.com/unified"
15479
  }
15480
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15481
  "node_modules/remark-parse": {
15482
  "version": "11.0.0",
15483
  "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -17704,20 +17937,6 @@
17704
  "is-typedarray": "^1.0.0"
17705
  }
17706
  },
17707
- "node_modules/typescript": {
17708
- "version": "4.9.5",
17709
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
17710
- "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
17711
- "license": "Apache-2.0",
17712
- "peer": true,
17713
- "bin": {
17714
- "tsc": "bin/tsc",
17715
- "tsserver": "bin/tsserver"
17716
- },
17717
- "engines": {
17718
- "node": ">=4.2.0"
17719
- }
17720
- },
17721
  "node_modules/unbox-primitive": {
17722
  "version": "1.1.0",
17723
  "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -17831,6 +18050,20 @@
17831
  "node": ">=8"
17832
  }
17833
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17834
  "node_modules/unist-util-is": {
17835
  "version": "6.0.0",
17836
  "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -17857,6 +18090,20 @@
17857
  "url": "https://opencollective.com/unified"
17858
  }
17859
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17860
  "node_modules/unist-util-stringify-position": {
17861
  "version": "4.0.0",
17862
  "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
@@ -18070,6 +18317,20 @@
18070
  "url": "https://opencollective.com/unified"
18071
  }
18072
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18073
  "node_modules/vfile-message": {
18074
  "version": "4.0.3",
18075
  "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -18137,6 +18398,16 @@
18137
  "minimalistic-assert": "^1.0.0"
18138
  }
18139
  },
 
 
 
 
 
 
 
 
 
 
18140
  "node_modules/web-vitals": {
18141
  "version": "2.1.4",
18142
  "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
 
12
  "@testing-library/jest-dom": "^6.6.3",
13
  "@testing-library/react": "^16.3.0",
14
  "@testing-library/user-event": "^13.5.0",
15
+ "katex": "^0.16.45",
16
  "lucide-react": "^0.544.0",
17
  "react": "^19.1.0",
18
  "react-dom": "^19.1.0",
19
  "react-markdown": "^10.1.0",
20
  "react-scripts": "5.0.1",
21
+ "rehype-katex": "^7.0.1",
22
  "remark-gfm": "^4.0.1",
23
+ "remark-math": "^6.0.0",
24
  "web-vitals": "^2.1.4"
25
  }
26
  },
 
3800
  "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
3801
  "license": "MIT"
3802
  },
3803
+ "node_modules/@types/katex": {
3804
+ "version": "0.16.8",
3805
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
3806
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
3807
+ "license": "MIT"
3808
+ },
3809
  "node_modules/@types/mdast": {
3810
  "version": "4.0.4",
3811
  "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
 
3875
  "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
3876
  "license": "MIT"
3877
  },
 
 
 
 
 
 
 
 
 
 
3878
  "node_modules/@types/resolve": {
3879
  "version": "1.17.1",
3880
  "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
 
6485
  "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
6486
  "license": "MIT"
6487
  },
 
 
 
 
 
 
 
6488
  "node_modules/damerau-levenshtein": {
6489
  "version": "1.0.8",
6490
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
 
9009
  "node": ">= 0.4"
9010
  }
9011
  },
9012
+ "node_modules/hast-util-from-dom": {
9013
+ "version": "5.0.1",
9014
+ "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
9015
+ "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
9016
+ "license": "ISC",
9017
+ "dependencies": {
9018
+ "@types/hast": "^3.0.0",
9019
+ "hastscript": "^9.0.0",
9020
+ "web-namespaces": "^2.0.0"
9021
+ },
9022
+ "funding": {
9023
+ "type": "opencollective",
9024
+ "url": "https://opencollective.com/unified"
9025
+ }
9026
+ },
9027
+ "node_modules/hast-util-from-html": {
9028
+ "version": "2.0.3",
9029
+ "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
9030
+ "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
9031
+ "license": "MIT",
9032
+ "dependencies": {
9033
+ "@types/hast": "^3.0.0",
9034
+ "devlop": "^1.1.0",
9035
+ "hast-util-from-parse5": "^8.0.0",
9036
+ "parse5": "^7.0.0",
9037
+ "vfile": "^6.0.0",
9038
+ "vfile-message": "^4.0.0"
9039
+ },
9040
+ "funding": {
9041
+ "type": "opencollective",
9042
+ "url": "https://opencollective.com/unified"
9043
+ }
9044
+ },
9045
+ "node_modules/hast-util-from-html-isomorphic": {
9046
+ "version": "2.0.0",
9047
+ "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
9048
+ "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
9049
+ "license": "MIT",
9050
+ "dependencies": {
9051
+ "@types/hast": "^3.0.0",
9052
+ "hast-util-from-dom": "^5.0.0",
9053
+ "hast-util-from-html": "^2.0.0",
9054
+ "unist-util-remove-position": "^5.0.0"
9055
+ },
9056
+ "funding": {
9057
+ "type": "opencollective",
9058
+ "url": "https://opencollective.com/unified"
9059
+ }
9060
+ },
9061
+ "node_modules/hast-util-from-html/node_modules/entities": {
9062
+ "version": "6.0.1",
9063
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
9064
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
9065
+ "license": "BSD-2-Clause",
9066
+ "engines": {
9067
+ "node": ">=0.12"
9068
+ },
9069
+ "funding": {
9070
+ "url": "https://github.com/fb55/entities?sponsor=1"
9071
+ }
9072
+ },
9073
+ "node_modules/hast-util-from-html/node_modules/parse5": {
9074
+ "version": "7.3.0",
9075
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
9076
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
9077
+ "license": "MIT",
9078
+ "dependencies": {
9079
+ "entities": "^6.0.0"
9080
+ },
9081
+ "funding": {
9082
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
9083
+ }
9084
+ },
9085
+ "node_modules/hast-util-from-parse5": {
9086
+ "version": "8.0.3",
9087
+ "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
9088
+ "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
9089
+ "license": "MIT",
9090
+ "dependencies": {
9091
+ "@types/hast": "^3.0.0",
9092
+ "@types/unist": "^3.0.0",
9093
+ "devlop": "^1.0.0",
9094
+ "hastscript": "^9.0.0",
9095
+ "property-information": "^7.0.0",
9096
+ "vfile": "^6.0.0",
9097
+ "vfile-location": "^5.0.0",
9098
+ "web-namespaces": "^2.0.0"
9099
+ },
9100
+ "funding": {
9101
+ "type": "opencollective",
9102
+ "url": "https://opencollective.com/unified"
9103
+ }
9104
+ },
9105
+ "node_modules/hast-util-is-element": {
9106
+ "version": "3.0.0",
9107
+ "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
9108
+ "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
9109
+ "license": "MIT",
9110
+ "dependencies": {
9111
+ "@types/hast": "^3.0.0"
9112
+ },
9113
+ "funding": {
9114
+ "type": "opencollective",
9115
+ "url": "https://opencollective.com/unified"
9116
+ }
9117
+ },
9118
+ "node_modules/hast-util-parse-selector": {
9119
+ "version": "4.0.0",
9120
+ "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
9121
+ "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
9122
+ "license": "MIT",
9123
+ "dependencies": {
9124
+ "@types/hast": "^3.0.0"
9125
+ },
9126
+ "funding": {
9127
+ "type": "opencollective",
9128
+ "url": "https://opencollective.com/unified"
9129
+ }
9130
+ },
9131
  "node_modules/hast-util-to-jsx-runtime": {
9132
  "version": "2.3.6",
9133
  "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
 
9155
  "url": "https://opencollective.com/unified"
9156
  }
9157
  },
9158
+ "node_modules/hast-util-to-text": {
9159
+ "version": "4.0.2",
9160
+ "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
9161
+ "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
9162
+ "license": "MIT",
9163
+ "dependencies": {
9164
+ "@types/hast": "^3.0.0",
9165
+ "@types/unist": "^3.0.0",
9166
+ "hast-util-is-element": "^3.0.0",
9167
+ "unist-util-find-after": "^5.0.0"
9168
+ },
9169
+ "funding": {
9170
+ "type": "opencollective",
9171
+ "url": "https://opencollective.com/unified"
9172
+ }
9173
+ },
9174
  "node_modules/hast-util-whitespace": {
9175
  "version": "3.0.0",
9176
  "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
 
9184
  "url": "https://opencollective.com/unified"
9185
  }
9186
  },
9187
+ "node_modules/hastscript": {
9188
+ "version": "9.0.1",
9189
+ "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
9190
+ "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
9191
+ "license": "MIT",
9192
+ "dependencies": {
9193
+ "@types/hast": "^3.0.0",
9194
+ "comma-separated-tokens": "^2.0.0",
9195
+ "hast-util-parse-selector": "^4.0.0",
9196
+ "property-information": "^7.0.0",
9197
+ "space-separated-tokens": "^2.0.0"
9198
+ },
9199
+ "funding": {
9200
+ "type": "opencollective",
9201
+ "url": "https://opencollective.com/unified"
9202
+ }
9203
+ },
9204
  "node_modules/he": {
9205
  "version": "1.2.0",
9206
  "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
 
11456
  "node": ">=4.0"
11457
  }
11458
  },
11459
+ "node_modules/katex": {
11460
+ "version": "0.16.45",
11461
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz",
11462
+ "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==",
11463
+ "funding": [
11464
+ "https://opencollective.com/katex",
11465
+ "https://github.com/sponsors/katex"
11466
+ ],
11467
+ "license": "MIT",
11468
+ "dependencies": {
11469
+ "commander": "^8.3.0"
11470
+ },
11471
+ "bin": {
11472
+ "katex": "cli.js"
11473
+ }
11474
+ },
11475
  "node_modules/keyv": {
11476
  "version": "4.5.4",
11477
  "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
 
11916
  "url": "https://opencollective.com/unified"
11917
  }
11918
  },
11919
+ "node_modules/mdast-util-math": {
11920
+ "version": "3.0.0",
11921
+ "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
11922
+ "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
11923
+ "license": "MIT",
11924
+ "dependencies": {
11925
+ "@types/hast": "^3.0.0",
11926
+ "@types/mdast": "^4.0.0",
11927
+ "devlop": "^1.0.0",
11928
+ "longest-streak": "^3.0.0",
11929
+ "mdast-util-from-markdown": "^2.0.0",
11930
+ "mdast-util-to-markdown": "^2.1.0",
11931
+ "unist-util-remove-position": "^5.0.0"
11932
+ },
11933
+ "funding": {
11934
+ "type": "opencollective",
11935
+ "url": "https://opencollective.com/unified"
11936
+ }
11937
+ },
11938
  "node_modules/mdast-util-mdx-expression": {
11939
  "version": "2.0.1",
11940
  "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
 
12314
  "url": "https://opencollective.com/unified"
12315
  }
12316
  },
12317
+ "node_modules/micromark-extension-math": {
12318
+ "version": "3.1.0",
12319
+ "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
12320
+ "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
12321
+ "license": "MIT",
12322
+ "dependencies": {
12323
+ "@types/katex": "^0.16.0",
12324
+ "devlop": "^1.0.0",
12325
+ "katex": "^0.16.0",
12326
+ "micromark-factory-space": "^2.0.0",
12327
+ "micromark-util-character": "^2.0.0",
12328
+ "micromark-util-symbol": "^2.0.0",
12329
+ "micromark-util-types": "^2.0.0"
12330
+ },
12331
+ "funding": {
12332
+ "type": "opencollective",
12333
+ "url": "https://opencollective.com/unified"
12334
+ }
12335
+ },
12336
  "node_modules/micromark-factory-destination": {
12337
  "version": "2.0.1",
12338
  "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
 
15649
  "node": ">=6"
15650
  }
15651
  },
15652
+ "node_modules/rehype-katex": {
15653
+ "version": "7.0.1",
15654
+ "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
15655
+ "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==",
15656
+ "license": "MIT",
15657
+ "dependencies": {
15658
+ "@types/hast": "^3.0.0",
15659
+ "@types/katex": "^0.16.0",
15660
+ "hast-util-from-html-isomorphic": "^2.0.0",
15661
+ "hast-util-to-text": "^4.0.0",
15662
+ "katex": "^0.16.0",
15663
+ "unist-util-visit-parents": "^6.0.0",
15664
+ "vfile": "^6.0.0"
15665
+ },
15666
+ "funding": {
15667
+ "type": "opencollective",
15668
+ "url": "https://opencollective.com/unified"
15669
+ }
15670
+ },
15671
  "node_modules/relateurl": {
15672
  "version": "0.2.7",
15673
  "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
 
15695
  "url": "https://opencollective.com/unified"
15696
  }
15697
  },
15698
+ "node_modules/remark-math": {
15699
+ "version": "6.0.0",
15700
+ "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
15701
+ "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
15702
+ "license": "MIT",
15703
+ "dependencies": {
15704
+ "@types/mdast": "^4.0.0",
15705
+ "mdast-util-math": "^3.0.0",
15706
+ "micromark-extension-math": "^3.0.0",
15707
+ "unified": "^11.0.0"
15708
+ },
15709
+ "funding": {
15710
+ "type": "opencollective",
15711
+ "url": "https://opencollective.com/unified"
15712
+ }
15713
+ },
15714
  "node_modules/remark-parse": {
15715
  "version": "11.0.0",
15716
  "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
 
17937
  "is-typedarray": "^1.0.0"
17938
  }
17939
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17940
  "node_modules/unbox-primitive": {
17941
  "version": "1.1.0",
17942
  "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
 
18050
  "node": ">=8"
18051
  }
18052
  },
18053
+ "node_modules/unist-util-find-after": {
18054
+ "version": "5.0.0",
18055
+ "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
18056
+ "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
18057
+ "license": "MIT",
18058
+ "dependencies": {
18059
+ "@types/unist": "^3.0.0",
18060
+ "unist-util-is": "^6.0.0"
18061
+ },
18062
+ "funding": {
18063
+ "type": "opencollective",
18064
+ "url": "https://opencollective.com/unified"
18065
+ }
18066
+ },
18067
  "node_modules/unist-util-is": {
18068
  "version": "6.0.0",
18069
  "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz",
 
18090
  "url": "https://opencollective.com/unified"
18091
  }
18092
  },
18093
+ "node_modules/unist-util-remove-position": {
18094
+ "version": "5.0.0",
18095
+ "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
18096
+ "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
18097
+ "license": "MIT",
18098
+ "dependencies": {
18099
+ "@types/unist": "^3.0.0",
18100
+ "unist-util-visit": "^5.0.0"
18101
+ },
18102
+ "funding": {
18103
+ "type": "opencollective",
18104
+ "url": "https://opencollective.com/unified"
18105
+ }
18106
+ },
18107
  "node_modules/unist-util-stringify-position": {
18108
  "version": "4.0.0",
18109
  "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
 
18317
  "url": "https://opencollective.com/unified"
18318
  }
18319
  },
18320
+ "node_modules/vfile-location": {
18321
+ "version": "5.0.3",
18322
+ "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
18323
+ "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
18324
+ "license": "MIT",
18325
+ "dependencies": {
18326
+ "@types/unist": "^3.0.0",
18327
+ "vfile": "^6.0.0"
18328
+ },
18329
+ "funding": {
18330
+ "type": "opencollective",
18331
+ "url": "https://opencollective.com/unified"
18332
+ }
18333
+ },
18334
  "node_modules/vfile-message": {
18335
  "version": "4.0.3",
18336
  "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
 
18398
  "minimalistic-assert": "^1.0.0"
18399
  }
18400
  },
18401
+ "node_modules/web-namespaces": {
18402
+ "version": "2.0.1",
18403
+ "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
18404
+ "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
18405
+ "license": "MIT",
18406
+ "funding": {
18407
+ "type": "github",
18408
+ "url": "https://github.com/sponsors/wooorm"
18409
+ }
18410
+ },
18411
  "node_modules/web-vitals": {
18412
  "version": "2.1.4",
18413
  "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",
phd-advisor-frontend/package.json CHANGED
@@ -7,12 +7,15 @@
7
  "@testing-library/jest-dom": "^6.6.3",
8
  "@testing-library/react": "^16.3.0",
9
  "@testing-library/user-event": "^13.5.0",
 
10
  "lucide-react": "^0.544.0",
11
  "react": "^19.1.0",
12
  "react-dom": "^19.1.0",
13
  "react-markdown": "^10.1.0",
14
  "react-scripts": "5.0.1",
 
15
  "remark-gfm": "^4.0.1",
 
16
  "web-vitals": "^2.1.4"
17
  },
18
  "scripts": {
 
7
  "@testing-library/jest-dom": "^6.6.3",
8
  "@testing-library/react": "^16.3.0",
9
  "@testing-library/user-event": "^13.5.0",
10
+ "katex": "^0.16.45",
11
  "lucide-react": "^0.544.0",
12
  "react": "^19.1.0",
13
  "react-dom": "^19.1.0",
14
  "react-markdown": "^10.1.0",
15
  "react-scripts": "5.0.1",
16
+ "rehype-katex": "^7.0.1",
17
  "remark-gfm": "^4.0.1",
18
+ "remark-math": "^6.0.0",
19
  "web-vitals": "^2.1.4"
20
  },
21
  "scripts": {
phd-advisor-frontend/src/App.js CHANGED
@@ -39,7 +39,7 @@ function App() {
39
  };
40
 
41
  const navigateToCanvas = (canvasView) => {
42
- if (canvasView === 'insights' || canvasView === 'workspace') {
43
  localStorage.setItem('canvas-view-v2', canvasView);
44
  }
45
  setCurrentView('canvas');
 
39
  };
40
 
41
  const navigateToCanvas = (canvasView) => {
42
+ if (['insights', 'workspace', 'deliverables'].includes(canvasView)) {
43
  localStorage.setItem('canvas-view-v2', canvasView);
44
  }
45
  setCurrentView('canvas');
phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js CHANGED
@@ -1,16 +1,32 @@
1
- // Deliverables view — pick a template, fill in structured sections, export.
2
- // Static "missing" checks run locally. AI checks are stubbed (need LLM endpoint).
 
 
 
 
3
  import React, { useState, useMemo, useEffect, useRef } from 'react';
4
  import ReactMarkdown from 'react-markdown';
5
  import remarkGfm from 'remark-gfm';
 
 
 
6
  import Icon from './CanvasIcon';
7
 
 
 
 
 
 
8
  const fireToast = (msg, kind = 'success') =>
9
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
10
 
11
- const STORE_KEY = 'canvas-deliverables-v1';
 
 
12
 
13
- // ---------- Template definitions ----------
 
 
14
  const TEMPLATES = [
15
  {
16
  id: 'research-paper',
@@ -27,10 +43,24 @@ const TEMPLATES = [
27
  { id: 'refs', name: 'References', target: 0, hint: 'Bibliography list. Drop @keys here from the Bibliography widget.', checks: [] },
28
  ],
29
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  {
31
  id: 'nsf-grfp',
32
  name: 'NSF GRFP',
33
- desc: 'Personal Statement (3 pages) + Research Plan (2 pages)',
34
  icon: 'award',
35
  mode: 'document',
36
  sections: [
@@ -66,22 +96,90 @@ const TEMPLATES = [
66
  ],
67
  },
68
  {
69
- id: 'thesis-chapter',
70
- name: 'Thesis Chapter',
71
- desc: 'Standard chapter scaffolding for a dissertation.',
72
- icon: 'book',
73
- mode: 'paper',
74
  sections: [
75
- { id: 'overview', name: 'Overview', target: 200, hint: 'What this chapter does and why it\'s here.', checks: [] },
76
- { id: 'background', name: 'Background', target: 1500, hint: 'Lit review focused on this chapter\'s question.', checks: ['hasCitation'] },
77
- { id: 'methods', name: 'Methods', target: 1500, hint: 'Reproducibility-first.', checks: ['hasCitation', 'hasNumber'] },
78
- { id: 'results', name: 'Results', target: 2000, hint: 'Findings + figures.', checks: ['hasFigure', 'hasNumber'] },
79
- { id: 'discussion', name: 'Discussion', target: 1500, hint: 'How it fits the larger thesis.', checks: ['hasLimit'] },
 
80
  ],
81
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  ];
83
 
84
- // ---------- Static check rules ----------
 
 
85
  const CHECKS = {
86
  hasNumber: { test: (s) => /\d/.test(s), label: 'Mentions at least one number' },
87
  hasCitation: { test: (s) => /@\w+/.test(s), label: 'Cites at least one source (@key)' },
@@ -89,40 +187,36 @@ const CHECKS = {
89
  hasGap: { test: (s) => /\b(gap|lack|missing|unknown|unclear|despite)/i.test(s), label: 'Names a gap' },
90
  hasFigure: { test: (s) => /\b(fig(ure)?|table)\.?\s*\d/i.test(s), label: 'References a figure or table' },
91
  hasLimit: { test: (s) => /\b(limit|caveat|however|future work|did not|cannot)/i.test(s), label: 'Acknowledges a limit' },
92
- hasBroaderImpacts: { test: (s) => /\b(broader impact|outreach|community|underrepresented|access)/i.test(s), label: 'Addresses broader impacts' },
93
- hasHypothesis: { test: (s) => /\b(hypothes|predict)/i.test(s), label: 'States a hypothesis or prediction' },
94
  hasMerit: { test: (s) => /\b(intellectual merit|novel|advances|contribut)/i.test(s), label: 'Frames intellectual merit' },
95
  };
96
 
97
  const wordCount = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length;
 
98
 
99
- // ---------- Exporters ----------
100
- const exportMarkdown = (template, sections) => {
101
- return [
102
- `# ${template.name}\n`,
103
- ...template.sections.map(s => `## ${s.name}\n\n${sections[s.id] || ''}\n`),
104
- ].join('\n');
105
- };
106
- const exportLatex = (template, sections) => {
107
- return [
108
- '\\documentclass{article}',
109
- `\\title{${template.name}}`,
110
- '\\begin{document}',
111
- '\\maketitle',
112
- ...template.sections.map(s => `\n\\section{${s.name}}\n${sections[s.id] || ''}\n`),
113
- '\\end{document}',
114
- ].join('\n');
115
- };
116
- const exportHtml = (template, sections) => {
117
- return [
118
- '<!doctype html>',
119
- `<html><head><title>${template.name}</title></head><body>`,
120
- `<h1>${template.name}</h1>`,
121
- ...template.sections.map(s => `<section><h2>${s.name}</h2><p>${(sections[s.id] || '').replace(/\n/g, '<br>')}</p></section>`),
122
- '</body></html>',
123
- ].join('\n');
124
- };
125
-
126
  const downloadFile = (filename, mime, contents) => {
127
  const blob = new Blob([contents], { type: mime });
128
  const a = document.createElement('a');
@@ -131,36 +225,119 @@ const downloadFile = (filename, mime, contents) => {
131
  a.click();
132
  };
133
 
134
- // ---------- Component ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  const DeliverablesView = ({ allStates }) => {
136
- const [store, setStore] = useState(() => {
137
- try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch { return {}; }
138
- });
139
  useEffect(() => { localStorage.setItem(STORE_KEY, JSON.stringify(store)); }, [store]);
140
 
141
- const activeId = store.activeTemplateId;
142
- const template = TEMPLATES.find(t => t.id === activeId);
143
- const sections = (store.templates && store.templates[activeId]) || {};
144
  const [activeSectionId, setActiveSectionId] = useState(template?.sections[0]?.id);
145
  const [generatingAi, setGeneratingAi] = useState(false);
 
146
 
147
- // Keep activeSectionId valid when template changes (re-mount from localStorage,
148
- // template switch, etc.). Falls back to the first section.
149
  useEffect(() => {
150
  if (!template) return;
151
  const valid = template.sections.some(s => s.id === activeSectionId);
152
  if (!valid) setActiveSectionId(template.sections[0].id);
153
- }, [activeId, template, activeSectionId]);
154
-
155
- // TODO(LLM): wire `runAiPass` to backend that returns per-section "missing" notes.
156
- // const runAiPass = async () => {
157
- // const res = await fetch(`${process.env.REACT_APP_API_URL}/api/canvas/deliverable-check`, {
158
- // method: 'POST',
159
- // body: JSON.stringify({ template: template.id, sections, canvas: allStates }),
160
- // });
161
- // const { notes } = await res.json();
162
- // setStore({ ...store, templates: { ...store.templates, [activeId]: { ...sections, _aiNotes: notes } } });
163
- // };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  const runAiPass = () => {
165
  setGeneratingAi(true);
166
  setTimeout(() => {
@@ -171,32 +348,36 @@ const DeliverablesView = ({ allStates }) => {
171
  if (wc < s.target * 0.3) return { sectionId: s.id, msg: `Thin (${wc} words). Target ${s.target}.` };
172
  return { sectionId: s.id, msg: `Looks reasonable for length (${wc} words). LLM-pass would suggest specifics here.` };
173
  });
174
- setStore(prev => ({
175
- ...prev,
176
- templates: { ...prev.templates, [activeId]: { ...sections, _aiNotes: notes } },
177
- }));
178
  setGeneratingAi(false);
179
  fireToast('AI pass complete (stub)');
180
  }, 700);
181
  };
182
 
183
- const pickTemplate = (id) => {
184
- setStore({ ...store, activeTemplateId: id });
185
- setActiveSectionId(TEMPLATES.find(t => t.id === id).sections[0].id);
186
- };
 
 
 
 
 
 
 
 
 
187
 
188
- const updateSection = (id, value) => {
189
- setStore(prev => ({
190
- ...prev,
191
- templates: {
192
- ...prev.templates,
193
- [activeId]: { ...(prev.templates?.[activeId] || {}), [id]: value },
194
- },
195
- }));
196
  };
197
 
 
198
  const exportAs = (format) => {
199
- const filename = `${template.name.replace(/\s+/g, '_')}.${format === 'latex' ? 'tex' : format === 'markdown' ? 'md' : 'html'}`;
200
  const mime = format === 'html' ? 'text/html' : 'text/plain';
201
  const contents = format === 'markdown' ? exportMarkdown(template, sections)
202
  : format === 'latex' ? exportLatex(template, sections)
@@ -205,58 +386,74 @@ const DeliverablesView = ({ allStates }) => {
205
  fireToast(`Exported ${filename}`);
206
  };
207
 
208
- // Aggregated word count
209
  const totalWords = template ? template.sections.reduce((sum, s) => sum + wordCount(sections[s.id]), 0) : 0;
210
  const totalTarget = template ? template.sections.reduce((sum, s) => sum + s.target, 0) : 0;
 
211
 
212
- // Insertable elements from the canvas (citations, quotes, outline nodes, chapter drafts)
213
- const insertables = useMemo(() => {
214
- const items = [];
215
- (allStates?.bibliography?.entries || []).forEach(e => items.push({ kind: 'cite', label: `${e.title}`, snippet: ` (${e.authors}, ${e.year}; @${e.key})` }));
216
- (allStates?.highlights?.items || []).forEach(h => items.push({ kind: 'quote', label: h.text.slice(0, 60), snippet: `"${h.text}"${h.citeKey ? ` (@${h.citeKey})` : ''}` }));
217
- (allStates?.outline?.items || []).forEach(o => items.push({ kind: 'outline', label: o.text || '(empty)', snippet: '\n' + ' '.repeat(o.depth) + '- ' + (o.text || '') }));
218
- (allStates?.writing?.chapters || []).forEach(c => items.push({ kind: 'draft', label: c.name, snippet: c.draft || '' }));
219
- return items;
220
- }, [allStates]);
221
-
222
- const insertIntoActive = (snippet) => {
223
- if (!activeSectionId) return;
224
- const cur = sections[activeSectionId] || '';
225
- updateSection(activeSectionId, cur + (cur && !cur.endsWith('\n') ? ' ' : '') + snippet);
226
- fireToast('Inserted into ' + template.sections.find(s => s.id === activeSectionId)?.name);
227
- };
228
-
229
- // Empty state — pick a template
230
- if (!template) {
231
  return (
232
  <>
233
  <div className="page-header">
234
  <div>
235
  <h1 className="page-title">Deliverables</h1>
236
- <div className="page-sub">Pick a template; bring elements in from your canvas; export when ready.</div>
 
 
 
237
  </div>
238
  </div>
239
- <div className="canvas-presets-grid">
240
- {TEMPLATES.map(t => (
241
- <button key={t.id} className="canvas-preset-card" onClick={() => pickTemplate(t.id)}>
242
- <div className="canvas-preset-icon"><Icon name={t.icon} size={18}/></div>
243
- <div className="canvas-preset-content">
244
- <div className="canvas-preset-name">{t.name}</div>
245
- <div className="canvas-preset-desc">{t.desc}</div>
246
- <div className="canvas-preset-meta">{t.sections.length} sections</div>
247
- </div>
248
- </button>
249
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  </div>
251
- {/* TODO(LLM): "Upload project brief → AI generates a custom outline" button.
252
- Wire to backend that returns a synthesized template:
253
- const onUpload = async (file) => {
254
- const text = await file.text();
255
- const res = await fetch(`${API}/api/canvas/outline-from-brief`, { method:'POST', body: text });
256
- const { template, sections } = await res.json();
257
- // Inject as a new template into TEMPLATES at runtime, then pickTemplate(template.id);
258
- };
259
- */}
260
  <div className="canvas-presets" style={{ marginTop: 18 }}>
261
  <div className="canvas-presets-head">
262
  <div className="canvas-presets-title">From a project brief</div>
@@ -270,51 +467,118 @@ const DeliverablesView = ({ allStates }) => {
270
  );
271
  }
272
 
273
- const aiNotes = sections._aiNotes;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
- // Shared header + insertables panel — used by all editor modes.
276
  const Header = (
277
  <div className="page-header">
278
  <div>
279
- <button className="btn btn-ghost" style={{ padding: '4px 8px', fontSize: 12, marginBottom: 4, color: 'var(--canvas-text-3)' }} onClick={() => setStore({ ...store, activeTemplateId: undefined })}>
280
- <Icon name="back" size={12}/>Templates
281
  </button>
282
- <h1 className="page-title">{template.name}</h1>
 
 
 
 
283
  <div className="page-sub">
284
- {totalWords} / {totalTarget} words · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
285
  </div>
286
  </div>
287
  <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'flex-start' }}>
 
 
 
288
  <button className="btn btn-ghost" onClick={runAiPass} disabled={generatingAi} title="AI check (stub)">
289
  {generatingAi ? <><div className="spinner"/></> : <Icon name="sparkles" size={13}/>}
290
  AI check
291
  </button>
292
- <div style={{ position: 'relative' }}>
293
- <details className="canvas-export-menu">
294
- <summary className="btn btn-primary"><Icon name="download" size={13}/>Export</summary>
295
- <div className="canvas-export-menu-list">
296
- <button onClick={() => exportAs('markdown')}>Markdown (.md)</button>
297
- <button onClick={() => exportAs('latex')}>LaTeX (.tex)</button>
298
- <button onClick={() => exportAs('html')}>HTML (.html)</button>
299
- <button onClick={() => window.print()}>Print / Save as PDF</button>
300
- </div>
301
- </details>
302
- </div>
 
303
  </div>
304
  </div>
305
  );
306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  const InsertPanel = (
308
  <div className="deliverable-insertables">
309
- <div style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 8 }}>
310
- From canvas · {insertables.length}
 
311
  </div>
312
- {insertables.length === 0 && (
313
  <div style={{ padding: 12, fontSize: 11.5, color: 'var(--canvas-text-3)', background: 'var(--canvas-surface)', border: '1px dashed var(--canvas-border-2)', borderRadius: 7 }}>
314
- Add a Bibliography, Highlights, Outline, or Writing widget to your canvas; their content shows up here for one-click insert.
315
  </div>
316
  )}
317
- {insertables.map((it, i) => (
318
  <button key={i} onClick={() => insertIntoActive(it.snippet)} className="canvas-insert-row">
319
  <span className="tag-pill">{it.kind}</span>
320
  <span style={{ flex: 1, fontSize: 11.5, color: 'var(--canvas-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</span>
@@ -324,20 +588,47 @@ const DeliverablesView = ({ allStates }) => {
324
  </div>
325
  );
326
 
327
- // ---------- SLIDES MODE — PowerPoint / Google Slides feel ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  if (template.mode === 'slides') {
329
  const activeIdx = template.sections.findIndex(s => s.id === activeSectionId);
330
  const active = template.sections[activeIdx] || template.sections[0];
331
  const text = sections[active.id] || '';
332
  const aiForSlide = aiNotes && aiNotes.find(n => n.sectionId === active.id);
333
- // Render body as bullet points if it contains line breaks; otherwise as a paragraph.
334
  const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
335
 
336
  return (
337
  <>
 
338
  {Header}
 
339
  <div className="deliverable-slides-grid">
340
- {/* Slide thumbnails */}
341
  <div className="slide-thumbs">
342
  <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, padding: '0 4px 6px' }}>
343
  {template.sections.length} slides
@@ -361,8 +652,6 @@ const DeliverablesView = ({ allStates }) => {
361
  );
362
  })}
363
  </div>
364
-
365
- {/* Big slide canvas */}
366
  <div>
367
  <div className="slide-canvas-wrap">
368
  <div className="slide-canvas">
@@ -379,8 +668,6 @@ const DeliverablesView = ({ allStates }) => {
379
  <div className="slide-canvas-footer">{activeIdx + 1} / {template.sections.length}</div>
380
  </div>
381
  </div>
382
-
383
- {/* Edit pane below the canvas (so the slide stays the focus) */}
384
  <div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
385
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
386
  <span style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
@@ -392,12 +679,11 @@ const DeliverablesView = ({ allStates }) => {
392
  {wordCount(text)}{active.target ? `/${active.target}` : ''} words
393
  </span>
394
  </div>
395
- <textarea
396
- className="textarea"
397
  value={text}
398
- onChange={e => updateSection(active.id, e.target.value)}
399
  placeholder={active.hint}
400
- style={{ minHeight: 110, fontSize: 13, lineHeight: 1.6 }}
401
  />
402
  {(active.checks || []).length > 0 && (
403
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
@@ -415,27 +701,30 @@ const DeliverablesView = ({ allStates }) => {
415
  </div>
416
  )}
417
  {aiForSlide && (
418
- <div className="review" style={{ borderLeftColor: 'var(--canvas-accent)' }}>
419
- <span className="review-tag" style={{ color: 'var(--canvas-accent)' }}>AI suggestion · stub</span>
420
- {aiForSlide.msg}
 
 
 
421
  </div>
422
  )}
423
  </div>
424
  </div>
425
-
426
  {InsertPanel}
427
  </div>
428
  </>
429
  );
430
  }
431
 
432
- // ---------- PAPER / DOCUMENT MODE — Notion-style single-surface page ----------
433
  const paperLike = template.mode === 'paper';
434
  return (
435
  <>
 
436
  {Header}
 
437
  <div className="notion-deliverable-grid">
438
- {/* TOC sidebar — subtle, no boxes */}
439
  <div className="notion-toc">
440
  <div className="notion-toc-label">On this page</div>
441
  {template.sections.map(s => {
@@ -455,25 +744,23 @@ const DeliverablesView = ({ allStates }) => {
455
  })}
456
  </div>
457
 
458
- {/* The page itself — what you see is what you edit */}
459
  <div className={`notion-page-wrap ${paperLike ? 'paper' : ''}`}>
460
  <div className={`notion-page ${paperLike ? 'serif' : ''}`}>
461
- <h1 className="notion-page-title">{template.name}</h1>
462
  <div className="notion-page-meta">
463
- {totalWords} words · {template.sections.length} sections{paperLike ? ' · academic paper' : ''}
464
  </div>
465
  {template.sections.map(s => {
466
  const text = sections[s.id] || '';
467
  const aiForSection = aiNotes ? aiNotes.find(n => n.sectionId === s.id) : null;
468
- const failed = (s.checks || []).filter(c => CHECKS[c] && !CHECKS[c].test(text));
469
  return (
470
  <div key={s.id} id={`notion-section-${s.id}`} className="notion-block">
471
  <h2 className={`notion-h2 ${paperLike ? 'serif' : ''}`}>{s.name}</h2>
472
- {!text && <div className="notion-hint">{s.hint}</div>}
473
- <NotionTextarea
474
  value={text}
475
  onChange={(v) => updateSection(s.id, v)}
476
  placeholder={`Start writing ${s.name.toLowerCase()}…`}
 
477
  serif={paperLike}
478
  />
479
  {(s.checks || []).length > 0 && (
@@ -517,24 +804,356 @@ const DeliverablesView = ({ allStates }) => {
517
  );
518
  };
519
 
520
- // Auto-growing textarea styled to be invisible on the Notion page —
521
- // looks like body text until the user clicks in.
522
- function NotionTextarea({ value, onChange, placeholder, serif }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  const ref = useRef(null);
 
 
524
  useEffect(() => {
525
- if (!ref.current) return;
526
  ref.current.style.height = 'auto';
527
  ref.current.style.height = ref.current.scrollHeight + 'px';
528
- }, [value]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  return (
530
- <textarea
531
- ref={ref}
532
- className={`notion-text ${serif ? 'serif' : ''}`}
533
- value={value}
534
- onChange={(e) => onChange(e.target.value)}
535
- placeholder={placeholder}
536
- rows={1}
537
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  );
539
  }
540
 
 
1
+ // Deliverables view — multi-project PhD deliverable center.
2
+ // Each "project" is a draft of a template (paper, slides, poster, CV, etc.).
3
+ // You can keep many projects in flight, switch between them, version-rollback,
4
+ // drag in citations from your Bibliography or arXiv, embed images, render math,
5
+ // and export to Markdown / LaTeX / HTML / Print.
6
+ // AI passes are stubbed (need LLM endpoint) but every static signal works today.
7
  import React, { useState, useMemo, useEffect, useRef } from 'react';
8
  import ReactMarkdown from 'react-markdown';
9
  import remarkGfm from 'remark-gfm';
10
+ import remarkMath from 'remark-math';
11
+ import rehypeKatex from 'rehype-katex';
12
+ import 'katex/dist/katex.min.css';
13
  import Icon from './CanvasIcon';
14
 
15
+ // Markdown plugins shared across all rendered blocks. remark-math + rehype-katex
16
+ // give us real LaTeX math (`$...$` inline, `$$...$$` block) inside any preview.
17
+ const REMARK_PLUGINS = [remarkGfm, remarkMath];
18
+ const REHYPE_PLUGINS = [rehypeKatex];
19
+
20
  const fireToast = (msg, kind = 'success') =>
21
  window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
22
 
23
+ const STORE_KEY = 'canvas-deliverables-v2';
24
+ const MAX_VERSIONS = 10;
25
+ const newId = (p) => p + Math.random().toString(36).slice(2, 8);
26
 
27
+ // ============================================================================
28
+ // Templates
29
+ // ============================================================================
30
  const TEMPLATES = [
31
  {
32
  id: 'research-paper',
 
43
  { id: 'refs', name: 'References', target: 0, hint: 'Bibliography list. Drop @keys here from the Bibliography widget.', checks: [] },
44
  ],
45
  },
46
+ {
47
+ id: 'thesis-chapter',
48
+ name: 'Thesis Chapter',
49
+ desc: 'Standard chapter scaffolding for a dissertation.',
50
+ icon: 'book',
51
+ mode: 'paper',
52
+ sections: [
53
+ { id: 'overview', name: 'Overview', target: 200, hint: 'What this chapter does and why it\'s here.', checks: [] },
54
+ { id: 'background', name: 'Background', target: 1500, hint: 'Lit review focused on this chapter\'s question.', checks: ['hasCitation'] },
55
+ { id: 'methods', name: 'Methods', target: 1500, hint: 'Reproducibility-first.', checks: ['hasCitation', 'hasNumber'] },
56
+ { id: 'results', name: 'Results', target: 2000, hint: 'Findings + figures.', checks: ['hasFigure', 'hasNumber'] },
57
+ { id: 'discussion', name: 'Discussion', target: 1500, hint: 'How it fits the larger thesis.', checks: ['hasLimit'] },
58
+ ],
59
+ },
60
  {
61
  id: 'nsf-grfp',
62
  name: 'NSF GRFP',
63
+ desc: 'Personal Statement (3 pages) + Research Plan (2 pages).',
64
  icon: 'award',
65
  mode: 'document',
66
  sections: [
 
96
  ],
97
  },
98
  {
99
+ id: 'poster',
100
+ name: 'Conference Poster',
101
+ desc: '4-quadrant scientific poster: Intro · Methods · Results · Discussion.',
102
+ icon: 'layout',
103
+ mode: 'poster',
104
  sections: [
105
+ { id: 'title', name: 'Title & Authors', target: 30, hint: 'Title, your name, advisor, affiliation.', checks: [] },
106
+ { id: 'intro', name: 'Introduction', target: 200, hint: 'Question, gap, why-care.', checks: ['hasGap'] },
107
+ { id: 'methods', name: 'Methods', target: 200, hint: 'High-level: subjects, design, analysis.', checks: ['hasNumber'] },
108
+ { id: 'results', name: 'Results', target: 250, hint: 'Headline finding + 1–2 figures.', checks: ['hasFigure', 'hasNumber'] },
109
+ { id: 'discussion', name: 'Discussion', target: 200, hint: 'What it means + next steps.', checks: ['hasLimit'] },
110
+ { id: 'refs', name: 'References / Acks', target: 80, hint: '5–10 citations + funding + contact.', checks: ['hasCitation'] },
111
  ],
112
  },
113
+ {
114
+ id: 'cv',
115
+ name: 'Academic CV',
116
+ desc: 'Standard sections: Education · Pubs · Talks · Awards · Service · Skills.',
117
+ icon: 'user',
118
+ mode: 'document',
119
+ sections: [
120
+ { id: 'header', name: 'Header', target: 40, hint: 'Name, position, affiliation, contact.', checks: [] },
121
+ { id: 'education', name: 'Education', target: 100, hint: 'Most recent first. Degree · Year · Institution.', checks: [] },
122
+ { id: 'publications', name: 'Publications', target: 300, hint: 'Drop @keys from Bibliography. Group by type if needed.', checks: ['hasCitation'] },
123
+ { id: 'talks', name: 'Invited talks & posters', target: 150, hint: 'Title · Venue · Year.', checks: [] },
124
+ { id: 'awards', name: 'Awards & funding', target: 100, hint: 'Most recent first. Amount + year if applicable.', checks: [] },
125
+ { id: 'service', name: 'Service & teaching', target: 100, hint: 'Reviewing, mentorship, TA roles.', checks: [] },
126
+ { id: 'skills', name: 'Skills', target: 60, hint: 'Methods, languages, software.', checks: [] },
127
+ ],
128
+ },
129
+ {
130
+ id: 'cover-letter',
131
+ name: 'Cover Letter',
132
+ desc: 'For job applications, journal submissions, or postdoc inquiries.',
133
+ icon: 'send',
134
+ mode: 'document',
135
+ sections: [
136
+ { id: 'header', name: 'Header', target: 40, hint: 'Date, recipient, salutation.', checks: [] },
137
+ { id: 'opener', name: 'Opening paragraph', target: 100, hint: 'Why you\'re writing + the position/journal.', checks: [] },
138
+ { id: 'body', name: 'Why me', target: 250, hint: 'Specific achievements that match the call. Numbers > adjectives.', checks: ['hasNumber'] },
139
+ { id: 'fit', name: 'Why this place', target: 150, hint: 'What about this group / journal makes it the right fit.', checks: [] },
140
+ { id: 'close', name: 'Close', target: 60, hint: 'Thanks + next step + signature.', checks: [] },
141
+ ],
142
+ },
143
+ {
144
+ id: 'research-statement',
145
+ name: 'Research Statement',
146
+ desc: 'For faculty applications: past work, current direction, future arc.',
147
+ icon: 'sparkles',
148
+ mode: 'document',
149
+ sections: [
150
+ { id: 'overview', name: 'Overview', target: 200, hint: 'One paragraph: your research identity in 2–3 sentences.', checks: [] },
151
+ { id: 'past', name: 'Past work', target: 600, hint: 'What you\'ve done. Lead with results, cite your own papers.', checks: ['hasCitation', 'hasFinding'] },
152
+ { id: 'current', name: 'Current direction', target: 400, hint: 'What you\'re working on now and why it matters.', checks: ['hasGap'] },
153
+ { id: 'future', name: 'Future research', target: 600, hint: '3–5 year arc. 1–2 funding-ready specific aims.', checks: ['hasHypothesis'] },
154
+ { id: 'broader', name: 'Broader impacts', target: 200, hint: 'Outreach, mentorship, what your group will look like.', checks: ['hasBroaderImpacts'] },
155
+ ],
156
+ },
157
+ ];
158
+
159
+ // ============================================================================
160
+ // Slash command catalog — typed at the start of a line in any section editor.
161
+ // ============================================================================
162
+ const SLASH_COMMANDS = [
163
+ { id: 'h2', label: 'Heading', kind: 'block', icon: 'list', insert: () => '## Heading\n' },
164
+ { id: 'h3', label: 'Subheading', kind: 'block', icon: 'list', insert: () => '### Subheading\n' },
165
+ { id: 'list', label: 'Bullet list', kind: 'block', icon: 'list', insert: () => '- ' },
166
+ { id: 'todo', label: 'To-do', kind: 'block', icon: 'task', insert: () => '- [ ] ' },
167
+ { id: 'numbered', label: 'Numbered list', kind: 'block', icon: 'list', insert: () => '1. ' },
168
+ { id: 'quote', label: 'Quote', kind: 'block', icon: 'cite', insert: () => '> ' },
169
+ { id: 'callout', label: 'Callout', kind: 'block', icon: 'sparkles', insert: () => '> [!note]\n> ' },
170
+ { id: 'divider', label: 'Divider', kind: 'block', icon: 'list', insert: () => '\n---\n\n' },
171
+ { id: 'code', label: 'Code block', kind: 'block', icon: 'flask', insert: () => '```\n\n```\n' },
172
+ { id: 'math', label: 'Equation', kind: 'block', icon: 'flask', insert: () => '$$\nE = mc^2\n$$\n' },
173
+ { id: 'inline-math', label: 'Inline equation', kind: 'inline', icon: 'flask', insert: () => '$x^2$' },
174
+ { id: 'image', label: 'Image (paste URL)', kind: 'block', icon: 'download', insert: () => '![caption](https://)' },
175
+ { id: 'cite', label: 'Citation @key', kind: 'inline', icon: 'book', insert: () => '(@key)' },
176
+ { id: 'bold', label: 'Bold', kind: 'inline', icon: 'pencil', insert: () => '**bold**' },
177
+ { id: 'italic', label: 'Italic', kind: 'inline', icon: 'pencil', insert: () => '*italic*' },
178
  ];
179
 
180
+ // ============================================================================
181
+ // Static "missing" checks
182
+ // ============================================================================
183
  const CHECKS = {
184
  hasNumber: { test: (s) => /\d/.test(s), label: 'Mentions at least one number' },
185
  hasCitation: { test: (s) => /@\w+/.test(s), label: 'Cites at least one source (@key)' },
 
187
  hasGap: { test: (s) => /\b(gap|lack|missing|unknown|unclear|despite)/i.test(s), label: 'Names a gap' },
188
  hasFigure: { test: (s) => /\b(fig(ure)?|table)\.?\s*\d/i.test(s), label: 'References a figure or table' },
189
  hasLimit: { test: (s) => /\b(limit|caveat|however|future work|did not|cannot)/i.test(s), label: 'Acknowledges a limit' },
190
+ hasBroaderImpacts: { test: (s) => /\b(broader impact|outreach|community|underrepresented|access|teaching)/i.test(s), label: 'Addresses broader impacts' },
191
+ hasHypothesis: { test: (s) => /\b(hypothes|predict|aim ?\d)/i.test(s), label: 'States a hypothesis or aim' },
192
  hasMerit: { test: (s) => /\b(intellectual merit|novel|advances|contribut)/i.test(s), label: 'Frames intellectual merit' },
193
  };
194
 
195
  const wordCount = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length;
196
+ const readingMinutes = (n) => Math.max(1, Math.round(n / 220));
197
 
198
+ // ============================================================================
199
+ // Exporters
200
+ // ============================================================================
201
+ const exportMarkdown = (template, sections) => [
202
+ `# ${template.name}\n`,
203
+ ...template.sections.map(s => `## ${s.name}\n\n${sections[s.id] || ''}\n`),
204
+ ].join('\n');
205
+ const exportLatex = (template, sections) => [
206
+ '\\documentclass{article}',
207
+ `\\title{${template.name}}`,
208
+ '\\begin{document}',
209
+ '\\maketitle',
210
+ ...template.sections.map(s => `\n\\section{${s.name}}\n${sections[s.id] || ''}\n`),
211
+ '\\end{document}',
212
+ ].join('\n');
213
+ const exportHtml = (template, sections) => [
214
+ '<!doctype html>',
215
+ `<html><head><title>${template.name}</title></head><body>`,
216
+ `<h1>${template.name}</h1>`,
217
+ ...template.sections.map(s => `<section><h2>${s.name}</h2><p>${(sections[s.id] || '').replace(/\n/g, '<br>')}</p></section>`),
218
+ '</body></html>',
219
+ ].join('\n');
 
 
 
 
 
220
  const downloadFile = (filename, mime, contents) => {
221
  const blob = new Blob([contents], { type: mime });
222
  const a = document.createElement('a');
 
225
  a.click();
226
  };
227
 
228
+ // ============================================================================
229
+ // Project store — multi-project (was: single-template). Migrates v1 if found.
230
+ // ============================================================================
231
+ const loadStore = () => {
232
+ try {
233
+ const v2 = JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
234
+ if (v2) return v2;
235
+ // Migrate v1: turn each templateId entry into a project.
236
+ const v1 = JSON.parse(localStorage.getItem('canvas-deliverables-v1') || '{}');
237
+ if (v1 && v1.templates) {
238
+ const projects = {};
239
+ let activeId;
240
+ Object.entries(v1.templates).forEach(([tid, sec]) => {
241
+ const id = newId('p-');
242
+ const { _aiNotes, ...rest } = sec;
243
+ projects[id] = {
244
+ id,
245
+ name: TEMPLATES.find(t => t.id === tid)?.name || tid,
246
+ templateId: tid,
247
+ sections: rest,
248
+ versions: [],
249
+ aiNotes: _aiNotes || null,
250
+ createdAt: Date.now(),
251
+ };
252
+ if (tid === v1.activeTemplateId) activeId = id;
253
+ });
254
+ return { activeProjectId: activeId, projects };
255
+ }
256
+ } catch { /* fallthrough */ }
257
+ return { activeProjectId: null, projects: {} };
258
+ };
259
+
260
+ // ============================================================================
261
+ // Main view
262
+ // ============================================================================
263
  const DeliverablesView = ({ allStates }) => {
264
+ const [store, setStore] = useState(loadStore);
 
 
265
  useEffect(() => { localStorage.setItem(STORE_KEY, JSON.stringify(store)); }, [store]);
266
 
267
+ const project = store.projects[store.activeProjectId] || null;
268
+ const template = project ? TEMPLATES.find(t => t.id === project.templateId) : null;
269
+ const sections = project?.sections || {};
270
  const [activeSectionId, setActiveSectionId] = useState(template?.sections[0]?.id);
271
  const [generatingAi, setGeneratingAi] = useState(false);
272
+ const [historyOpen, setHistoryOpen] = useState(false);
273
 
274
+ // Normalize active section when project/template changes
 
275
  useEffect(() => {
276
  if (!template) return;
277
  const valid = template.sections.some(s => s.id === activeSectionId);
278
  if (!valid) setActiveSectionId(template.sections[0].id);
279
+ }, [project?.id, template, activeSectionId]);
280
+
281
+ // ---------- Project ops ----------
282
+ const createProject = (templateId, name) => {
283
+ const id = newId('p-');
284
+ const t = TEMPLATES.find(x => x.id === templateId);
285
+ setStore(s => ({
286
+ activeProjectId: id,
287
+ projects: {
288
+ ...s.projects,
289
+ [id]: { id, name: name || `${t.name} draft`, templateId, sections: {}, versions: [], aiNotes: null, createdAt: Date.now() },
290
+ },
291
+ }));
292
+ fireToast(`New ${t.name} draft created`);
293
+ };
294
+ const switchProject = (id) => setStore(s => ({ ...s, activeProjectId: id }));
295
+ const renameProject = (name) => {
296
+ if (!project) return;
297
+ setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], name } } }));
298
+ };
299
+ const deleteProject = () => {
300
+ if (!project) return;
301
+ if (!window.confirm(`Delete "${project.name}"? This can't be undone.`)) return;
302
+ setStore(s => {
303
+ const { [project.id]: _, ...rest } = s.projects;
304
+ const nextActive = Object.keys(rest)[0] || null;
305
+ return { activeProjectId: nextActive, projects: rest };
306
+ });
307
+ };
308
+ const closeProject = () => setStore(s => ({ ...s, activeProjectId: null }));
309
+
310
+ // ---------- Section ops + auto-versioning ----------
311
+ const updateSection = (id, value) => {
312
+ setStore(s => {
313
+ const proj = s.projects[project.id];
314
+ const oldText = proj.sections[id] || '';
315
+ // Snapshot a version every time a section gains/loses ≥80 chars (rough save cadence)
316
+ const shouldSnap = Math.abs(value.length - oldText.length) >= 80;
317
+ const newVersions = shouldSnap
318
+ ? [{ at: Date.now(), sectionId: id, snapshot: { ...proj.sections } }, ...proj.versions].slice(0, MAX_VERSIONS)
319
+ : proj.versions;
320
+ return {
321
+ ...s,
322
+ projects: {
323
+ ...s.projects,
324
+ [project.id]: { ...proj, sections: { ...proj.sections, [id]: value }, versions: newVersions },
325
+ },
326
+ };
327
+ });
328
+ };
329
+
330
+ const restoreVersion = (v) => {
331
+ if (!window.confirm('Restore this version? Current text will be replaced.')) return;
332
+ setStore(s => ({
333
+ ...s,
334
+ projects: { ...s.projects, [project.id]: { ...s.projects[project.id], sections: v.snapshot } },
335
+ }));
336
+ fireToast('Restored');
337
+ };
338
+
339
+ // ---------- AI pass (stub) ----------
340
+ // TODO(LLM): POST {project, sections, canvas} → {notes:[{sectionId,msg}]}
341
  const runAiPass = () => {
342
  setGeneratingAi(true);
343
  setTimeout(() => {
 
348
  if (wc < s.target * 0.3) return { sectionId: s.id, msg: `Thin (${wc} words). Target ${s.target}.` };
349
  return { sectionId: s.id, msg: `Looks reasonable for length (${wc} words). LLM-pass would suggest specifics here.` };
350
  });
351
+ setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], aiNotes: notes } } }));
 
 
 
352
  setGeneratingAi(false);
353
  fireToast('AI pass complete (stub)');
354
  }, 700);
355
  };
356
 
357
+ // ---------- Insertables: Bibliography + Highlights + Outline + Drafts + arXiv search ----------
358
+ const localInsertables = useMemo(() => {
359
+ const items = [];
360
+ (allStates?.bibliography?.entries || []).forEach(e =>
361
+ items.push({ kind: 'cite', label: e.title, snippet: ` (${e.authors}, ${e.year}; @${e.key})` }));
362
+ (allStates?.highlights?.items || []).forEach(h =>
363
+ items.push({ kind: 'quote', label: h.text.slice(0, 60), snippet: `"${h.text}"${h.citeKey ? ` (@${h.citeKey})` : ''}` }));
364
+ (allStates?.outline?.items || []).forEach(o =>
365
+ items.push({ kind: 'outline', label: o.text || '(empty)', snippet: '\n' + ' '.repeat(o.depth) + '- ' + (o.text || '') }));
366
+ (allStates?.writing?.chapters || []).forEach(c =>
367
+ items.push({ kind: 'draft', label: c.name, snippet: c.draft || '' }));
368
+ return items;
369
+ }, [allStates]);
370
 
371
+ const insertIntoActive = (snippet) => {
372
+ if (!activeSectionId) return;
373
+ const cur = sections[activeSectionId] || '';
374
+ updateSection(activeSectionId, cur + (cur && !cur.endsWith('\n') ? ' ' : '') + snippet);
375
+ fireToast('Inserted into ' + template.sections.find(s => s.id === activeSectionId)?.name);
 
 
 
376
  };
377
 
378
+ // ---------- Export ----------
379
  const exportAs = (format) => {
380
+ const filename = `${project.name.replace(/\s+/g, '_')}.${format === 'latex' ? 'tex' : format === 'markdown' ? 'md' : 'html'}`;
381
  const mime = format === 'html' ? 'text/html' : 'text/plain';
382
  const contents = format === 'markdown' ? exportMarkdown(template, sections)
383
  : format === 'latex' ? exportLatex(template, sections)
 
386
  fireToast(`Exported ${filename}`);
387
  };
388
 
389
+ // ---------- Aggregates ----------
390
  const totalWords = template ? template.sections.reduce((sum, s) => sum + wordCount(sections[s.id]), 0) : 0;
391
  const totalTarget = template ? template.sections.reduce((sum, s) => sum + s.target, 0) : 0;
392
+ const projectList = Object.values(store.projects).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
393
 
394
+ // ---------- Empty state: project picker / template picker ----------
395
+ if (!project) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  return (
397
  <>
398
  <div className="page-header">
399
  <div>
400
  <h1 className="page-title">Deliverables</h1>
401
+ <div className="page-sub">
402
+ Your one-stop deliverable center. Drafts auto-save. Versions kept for rollback.
403
+ {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
404
+ </div>
405
  </div>
406
  </div>
407
+
408
+ {/* Existing projects, if any */}
409
+ {projectList.length > 0 && (
410
+ <div className="canvas-presets" style={{ marginBottom: 24 }}>
411
+ <div className="canvas-presets-head">
412
+ <div className="canvas-presets-title">Continue working</div>
413
+ <div className="canvas-presets-sub">Drafts you've started.</div>
414
+ </div>
415
+ <div className="canvas-presets-grid">
416
+ {projectList.map(p => {
417
+ const t = TEMPLATES.find(t => t.id === p.templateId);
418
+ if (!t) return null;
419
+ const wc = t.sections.reduce((sum, s) => sum + wordCount(p.sections[s.id]), 0);
420
+ const target = t.sections.reduce((sum, s) => sum + s.target, 0);
421
+ return (
422
+ <button key={p.id} className="canvas-preset-card" onClick={() => switchProject(p.id)}>
423
+ <div className="canvas-preset-icon"><Icon name={t.icon} size={18}/></div>
424
+ <div className="canvas-preset-content">
425
+ <div className="canvas-preset-name">{p.name}</div>
426
+ <div className="canvas-preset-desc">{t.name} · {wc}/{target} words</div>
427
+ <div className="canvas-preset-meta">opened {new Date(p.createdAt).toLocaleDateString()}</div>
428
+ </div>
429
+ </button>
430
+ );
431
+ })}
432
+ </div>
433
+ </div>
434
+ )}
435
+
436
+ {/* Templates */}
437
+ <div className="canvas-presets">
438
+ <div className="canvas-presets-head">
439
+ <div className="canvas-presets-title">{projectList.length > 0 ? 'Or start a new draft' : 'Pick a template'}</div>
440
+ <div className="canvas-presets-sub">9 templates · paper, slides, poster, CV, cover letter, and more.</div>
441
+ </div>
442
+ <div className="canvas-presets-grid">
443
+ {TEMPLATES.map(t => (
444
+ <button key={t.id} className="canvas-preset-card" onClick={() => createProject(t.id)}>
445
+ <div className="canvas-preset-icon"><Icon name={t.icon} size={18}/></div>
446
+ <div className="canvas-preset-content">
447
+ <div className="canvas-preset-name">{t.name}</div>
448
+ <div className="canvas-preset-desc">{t.desc}</div>
449
+ <div className="canvas-preset-meta">{t.sections.length} sections · {t.mode}</div>
450
+ </div>
451
+ </button>
452
+ ))}
453
+ </div>
454
  </div>
455
+
456
+ {/* TODO(LLM): "Upload project brief → AI generates a custom outline" */}
 
 
 
 
 
 
 
457
  <div className="canvas-presets" style={{ marginTop: 18 }}>
458
  <div className="canvas-presets-head">
459
  <div className="canvas-presets-title">From a project brief</div>
 
467
  );
468
  }
469
 
470
+ // ============================================================================
471
+ // Editor: project tabs + header + per-mode editor
472
+ // ============================================================================
473
+ const aiNotes = project.aiNotes;
474
+
475
+ const ProjectTabs = (
476
+ <div className="deliverable-projects">
477
+ {projectList.map(p => {
478
+ const t = TEMPLATES.find(x => x.id === p.templateId);
479
+ return (
480
+ <button
481
+ key={p.id}
482
+ className={`deliverable-project-tab ${p.id === project.id ? 'active' : ''}`}
483
+ onClick={() => switchProject(p.id)}
484
+ title={`${t?.name || ''} · ${wordCount(Object.values(p.sections || {}).join(' '))} words`}
485
+ >
486
+ <Icon name={t?.icon || 'book'} size={12}/>
487
+ <span>{p.name}</span>
488
+ </button>
489
+ );
490
+ })}
491
+ <details className="canvas-export-menu deliverable-new-project">
492
+ <summary className="deliverable-project-tab"><Icon name="plus" size={12}/>New</summary>
493
+ <div className="canvas-export-menu-list">
494
+ {TEMPLATES.map(t => (
495
+ <button key={t.id} onClick={() => createProject(t.id)}>
496
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
497
+ <Icon name={t.icon} size={12}/>{t.name}
498
+ </span>
499
+ </button>
500
+ ))}
501
+ </div>
502
+ </details>
503
+ </div>
504
+ );
505
 
 
506
  const Header = (
507
  <div className="page-header">
508
  <div>
509
+ <button className="btn btn-ghost" style={{ padding: '4px 8px', fontSize: 12, marginBottom: 4, color: 'var(--canvas-text-3)' }} onClick={closeProject}>
510
+ <Icon name="back" size={12}/>All drafts
511
  </button>
512
+ <input
513
+ className="page-title page-title-editable"
514
+ value={project.name}
515
+ onChange={(e) => renameProject(e.target.value)}
516
+ />
517
  <div className="page-sub">
518
+ {template.name} · {totalWords} / {totalTarget} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
519
  </div>
520
  </div>
521
  <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'flex-start' }}>
522
+ <button className="btn btn-ghost" onClick={() => setHistoryOpen(o => !o)} title="Version history">
523
+ <Icon name="reset" size={13}/>History · {project.versions.length}
524
+ </button>
525
  <button className="btn btn-ghost" onClick={runAiPass} disabled={generatingAi} title="AI check (stub)">
526
  {generatingAi ? <><div className="spinner"/></> : <Icon name="sparkles" size={13}/>}
527
  AI check
528
  </button>
529
+ <details className="canvas-export-menu">
530
+ <summary className="btn btn-primary"><Icon name="download" size={13}/>Export</summary>
531
+ <div className="canvas-export-menu-list">
532
+ <button onClick={() => exportAs('markdown')}>Markdown (.md)</button>
533
+ <button onClick={() => exportAs('latex')}>LaTeX (.tex)</button>
534
+ <button onClick={() => exportAs('html')}>HTML (.html)</button>
535
+ <button onClick={() => window.print()}>Print / Save as PDF</button>
536
+ </div>
537
+ </details>
538
+ <button className="btn btn-ghost" onClick={deleteProject} title="Delete this draft" style={{ color: 'var(--canvas-danger)' }}>
539
+ <Icon name="trash" size={13}/>
540
+ </button>
541
  </div>
542
  </div>
543
  );
544
 
545
+ const HistoryPanel = historyOpen && (
546
+ <div className="deliverable-history">
547
+ <div className="deliverable-history-head">
548
+ <span>Version history · {project.versions.length}</span>
549
+ <button className="icon-btn" onClick={() => setHistoryOpen(false)}><Icon name="x" size={13}/></button>
550
+ </div>
551
+ {project.versions.length === 0 ? (
552
+ <div style={{ padding: 14, color: 'var(--canvas-text-3)', fontSize: 12 }}>
553
+ Snapshots auto-save every ~80 characters of edits.
554
+ </div>
555
+ ) : (
556
+ project.versions.map((v, i) => {
557
+ const sec = template.sections.find(s => s.id === v.sectionId);
558
+ return (
559
+ <button key={i} className="deliverable-history-row" onClick={() => restoreVersion(v)}>
560
+ <span className="tag-pill">{sec?.name || v.sectionId}</span>
561
+ <span style={{ flex: 1 }}>{new Date(v.at).toLocaleString()}</span>
562
+ <Icon name="reset" size={11} style={{ color: 'var(--canvas-text-3)' }}/>
563
+ </button>
564
+ );
565
+ })
566
+ )}
567
+ </div>
568
+ );
569
+
570
  const InsertPanel = (
571
  <div className="deliverable-insertables">
572
+ <ArxivSearch onPick={insertIntoActive}/>
573
+ <div style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginTop: 14, marginBottom: 6 }}>
574
+ From canvas · {localInsertables.length}
575
  </div>
576
+ {localInsertables.length === 0 && (
577
  <div style={{ padding: 12, fontSize: 11.5, color: 'var(--canvas-text-3)', background: 'var(--canvas-surface)', border: '1px dashed var(--canvas-border-2)', borderRadius: 7 }}>
578
+ Add a Bibliography, Highlights, Outline, or Writing widget to your canvas to surface its content here.
579
  </div>
580
  )}
581
+ {localInsertables.map((it, i) => (
582
  <button key={i} onClick={() => insertIntoActive(it.snippet)} className="canvas-insert-row">
583
  <span className="tag-pill">{it.kind}</span>
584
  <span style={{ flex: 1, fontSize: 11.5, color: 'var(--canvas-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</span>
 
588
  </div>
589
  );
590
 
591
+ // ---------- POSTER MODE — 4-quadrant + 2 banner sections ----------
592
+ if (template.mode === 'poster') {
593
+ const layout = template.sections;
594
+ return (
595
+ <>
596
+ {ProjectTabs}
597
+ {Header}
598
+ {HistoryPanel}
599
+ <div className="poster-grid">
600
+ <div className="poster-banner">
601
+ <PosterPanel section={layout[0]} sections={sections} updateSection={updateSection}/>
602
+ </div>
603
+ <PosterPanel section={layout[1]} sections={sections} updateSection={updateSection}/>
604
+ <PosterPanel section={layout[2]} sections={sections} updateSection={updateSection}/>
605
+ <PosterPanel section={layout[3]} sections={sections} updateSection={updateSection}/>
606
+ <PosterPanel section={layout[4]} sections={sections} updateSection={updateSection}/>
607
+ <div className="poster-banner">
608
+ <PosterPanel section={layout[5]} sections={sections} updateSection={updateSection}/>
609
+ </div>
610
+ </div>
611
+ <div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
612
+ {InsertPanel}
613
+ </div>
614
+ </>
615
+ );
616
+ }
617
+
618
+ // ---------- SLIDES MODE — Google Slides feel ----------
619
  if (template.mode === 'slides') {
620
  const activeIdx = template.sections.findIndex(s => s.id === activeSectionId);
621
  const active = template.sections[activeIdx] || template.sections[0];
622
  const text = sections[active.id] || '';
623
  const aiForSlide = aiNotes && aiNotes.find(n => n.sectionId === active.id);
 
624
  const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
625
 
626
  return (
627
  <>
628
+ {ProjectTabs}
629
  {Header}
630
+ {HistoryPanel}
631
  <div className="deliverable-slides-grid">
 
632
  <div className="slide-thumbs">
633
  <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, padding: '0 4px 6px' }}>
634
  {template.sections.length} slides
 
652
  );
653
  })}
654
  </div>
 
 
655
  <div>
656
  <div className="slide-canvas-wrap">
657
  <div className="slide-canvas">
 
668
  <div className="slide-canvas-footer">{activeIdx + 1} / {template.sections.length}</div>
669
  </div>
670
  </div>
 
 
671
  <div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
672
  <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
673
  <span style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
 
679
  {wordCount(text)}{active.target ? `/${active.target}` : ''} words
680
  </span>
681
  </div>
682
+ <SlashTextarea
 
683
  value={text}
684
+ onChange={(v) => updateSection(active.id, v)}
685
  placeholder={active.hint}
686
+ rows={6}
687
  />
688
  {(active.checks || []).length > 0 && (
689
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
 
701
  </div>
702
  )}
703
  {aiForSlide && (
704
+ <div className="notion-callout">
705
+ <Icon name="sparkles" size={14}/>
706
+ <div>
707
+ <div className="notion-callout-label">AI suggestion · stub</div>
708
+ <div>{aiForSlide.msg}</div>
709
+ </div>
710
  </div>
711
  )}
712
  </div>
713
  </div>
 
714
  {InsertPanel}
715
  </div>
716
  </>
717
  );
718
  }
719
 
720
+ // ---------- PAPER / DOCUMENT MODE — Notion single-surface page ----------
721
  const paperLike = template.mode === 'paper';
722
  return (
723
  <>
724
+ {ProjectTabs}
725
  {Header}
726
+ {HistoryPanel}
727
  <div className="notion-deliverable-grid">
 
728
  <div className="notion-toc">
729
  <div className="notion-toc-label">On this page</div>
730
  {template.sections.map(s => {
 
744
  })}
745
  </div>
746
 
 
747
  <div className={`notion-page-wrap ${paperLike ? 'paper' : ''}`}>
748
  <div className={`notion-page ${paperLike ? 'serif' : ''}`}>
749
+ <h1 className="notion-page-title">{project.name}</h1>
750
  <div className="notion-page-meta">
751
+ {totalWords} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} sections{paperLike ? ' · academic paper' : ''}
752
  </div>
753
  {template.sections.map(s => {
754
  const text = sections[s.id] || '';
755
  const aiForSection = aiNotes ? aiNotes.find(n => n.sectionId === s.id) : null;
 
756
  return (
757
  <div key={s.id} id={`notion-section-${s.id}`} className="notion-block">
758
  <h2 className={`notion-h2 ${paperLike ? 'serif' : ''}`}>{s.name}</h2>
759
+ <RichBlock
 
760
  value={text}
761
  onChange={(v) => updateSection(s.id, v)}
762
  placeholder={`Start writing ${s.name.toLowerCase()}…`}
763
+ hint={s.hint}
764
  serif={paperLike}
765
  />
766
  {(s.checks || []).length > 0 && (
 
804
  );
805
  };
806
 
807
+ // ============================================================================
808
+ // RichBlock Notion-style click-to-edit + Docs-style floating toolbar.
809
+ // • Idle: shows rendered markdown (with KaTeX math) — looks like a real doc
810
+ // • Click: switches to a textarea source view
811
+ // • While editing: floating toolbar above with Bold / Italic / H2 / list / link / cite / math
812
+ // • Slash commands still work via the underlying textarea
813
+ // ============================================================================
814
+ const wrap = (text, l, r = l) => `${l}${text || 'text'}${r}`;
815
+ const lineWrap = (text, prefix) =>
816
+ (text ? text.split('\n').map(line => line ? `${prefix}${line}` : line).join('\n') : `${prefix}`);
817
+
818
+ const TOOLBAR = [
819
+ { id: 'bold', icon: 'pencil', label: 'Bold (⌘B)', run: (sel) => wrap(sel, '**') },
820
+ { id: 'italic', icon: 'pencil', label: 'Italic (⌘I)', run: (sel) => wrap(sel, '*') },
821
+ { id: 'code', icon: 'flask', label: 'Inline code', run: (sel) => wrap(sel, '`') },
822
+ { id: 'h2', icon: 'list', label: 'Heading', run: (sel) => `## ${sel || 'Heading'}` },
823
+ { id: 'h3', icon: 'list', label: 'Subheading', run: (sel) => `### ${sel || 'Subheading'}` },
824
+ { id: 'list', icon: 'list', label: 'Bullet list', run: (sel) => lineWrap(sel, '- ') },
825
+ { id: 'numbered', icon: 'list', label: 'Numbered list', run: (sel) => lineWrap(sel, '1. ') },
826
+ { id: 'quote', icon: 'cite', label: 'Block quote', run: (sel) => lineWrap(sel, '> ') },
827
+ { id: 'link', icon: 'link', label: 'Link', run: (sel) => `[${sel || 'link text'}](https://)` },
828
+ { id: 'cite', icon: 'book', label: 'Citation @key', run: (sel) => `(@${sel || 'key'})` },
829
+ { id: 'math', icon: 'flask', label: 'Inline math (LaTeX)', run: (sel) => `$${sel || 'x^2'}$` },
830
+ { id: 'math-block', icon: 'flask', label: 'Math block', run: (sel) => `\n$$\n${sel || 'E = mc^2'}\n$$\n` },
831
+ ];
832
+
833
+ function RichBlock({ value, onChange, placeholder, serif, hint }) {
834
+ const [editing, setEditing] = useState(false);
835
+ const [slash, setSlash] = useState(null);
836
+ const taRef = useRef(null);
837
+ const containerRef = useRef(null);
838
+
839
+ // Auto-grow when editing
840
+ useEffect(() => {
841
+ if (!editing || !taRef.current) return;
842
+ taRef.current.style.height = 'auto';
843
+ taRef.current.style.height = taRef.current.scrollHeight + 'px';
844
+ }, [value, editing]);
845
+
846
+ // Click outside to leave edit mode
847
+ useEffect(() => {
848
+ if (!editing) return;
849
+ const onDocClick = (e) => {
850
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
851
+ setEditing(false);
852
+ setSlash(null);
853
+ }
854
+ };
855
+ document.addEventListener('mousedown', onDocClick);
856
+ return () => document.removeEventListener('mousedown', onDocClick);
857
+ }, [editing]);
858
+
859
+ const applyToolbar = (cmd) => {
860
+ const ta = taRef.current;
861
+ if (!ta) return;
862
+ const { selectionStart: s, selectionEnd: e } = ta;
863
+ const before = value.slice(0, s);
864
+ const sel = value.slice(s, e);
865
+ const after = value.slice(e);
866
+ const replacement = cmd.run(sel);
867
+ const next = before + replacement + after;
868
+ onChange(next);
869
+ // Reposition cursor at end of inserted text
870
+ setTimeout(() => {
871
+ if (taRef.current) {
872
+ const pos = before.length + replacement.length;
873
+ taRef.current.setSelectionRange(pos, pos);
874
+ taRef.current.focus();
875
+ }
876
+ }, 0);
877
+ };
878
+
879
+ const onTextChange = (e) => {
880
+ const val = e.target.value;
881
+ const cursor = e.target.selectionStart;
882
+ onChange(val);
883
+ const before = val.slice(0, cursor);
884
+ const m = before.match(/(?:^|\n)(\/[\w-]*)$/);
885
+ if (m) {
886
+ const q = m[1].slice(1).toLowerCase();
887
+ const choices = SLASH_COMMANDS.filter(c => c.label.toLowerCase().includes(q) || c.id.includes(q)).slice(0, 8);
888
+ setSlash({ start: cursor - m[1].length, query: q, choices, idx: 0 });
889
+ } else {
890
+ setSlash(null);
891
+ }
892
+ };
893
+
894
+ const insertSlash = (cmd) => {
895
+ if (!slash) return;
896
+ const before = value.slice(0, slash.start);
897
+ const after = value.slice(slash.start + 1 + slash.query.length);
898
+ const inserted = cmd.insert();
899
+ onChange(before + inserted + after);
900
+ setSlash(null);
901
+ setTimeout(() => {
902
+ if (taRef.current) {
903
+ const pos = before.length + inserted.length;
904
+ taRef.current.setSelectionRange(pos, pos);
905
+ taRef.current.focus();
906
+ }
907
+ }, 0);
908
+ };
909
+
910
+ const onKeyDown = (e) => {
911
+ // ⌘B / ⌘I keyboard shortcuts (Word/Docs convention)
912
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey) {
913
+ if (e.key.toLowerCase() === 'b') { e.preventDefault(); applyToolbar(TOOLBAR.find(t => t.id === 'bold')); return; }
914
+ if (e.key.toLowerCase() === 'i') { e.preventDefault(); applyToolbar(TOOLBAR.find(t => t.id === 'italic')); return; }
915
+ }
916
+ if (slash && slash.choices.length > 0) {
917
+ if (e.key === 'ArrowDown') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.min(s.choices.length - 1, s.idx + 1) })); }
918
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.max(0, s.idx - 1) })); }
919
+ else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertSlash(slash.choices[slash.idx]); }
920
+ else if (e.key === 'Escape') setSlash(null);
921
+ }
922
+ };
923
+
924
+ return (
925
+ <div ref={containerRef} className="rich-block">
926
+ {/* Floating toolbar — only when actively editing */}
927
+ {editing && (
928
+ <div className="rich-toolbar" onMouseDown={(e) => e.preventDefault() /* keep textarea focused */}>
929
+ {TOOLBAR.map(t => (
930
+ <button key={t.id} title={t.label} onClick={() => applyToolbar(t)}>
931
+ {t.id === 'bold' && <strong>B</strong>}
932
+ {t.id === 'italic' && <em>I</em>}
933
+ {t.id === 'code' && <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11 }}>{'<>'}</span>}
934
+ {t.id === 'h2' && <span style={{ fontWeight: 700 }}>H2</span>}
935
+ {t.id === 'h3' && <span style={{ fontWeight: 700, fontSize: 11 }}>H3</span>}
936
+ {t.id === 'list' && '•'}
937
+ {t.id === 'numbered' && <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11 }}>1.</span>}
938
+ {t.id === 'quote' && '"'}
939
+ {t.id === 'link' && <Icon name="link" size={12}/>}
940
+ {t.id === 'cite' && '@'}
941
+ {t.id === 'math' && <span style={{ fontFamily: 'serif', fontStyle: 'italic' }}>x</span>}
942
+ {t.id === 'math-block' && <span style={{ fontFamily: 'serif' }}>∑</span>}
943
+ </button>
944
+ ))}
945
+ </div>
946
+ )}
947
+
948
+ {editing ? (
949
+ <div style={{ position: 'relative' }}>
950
+ <textarea
951
+ ref={taRef}
952
+ className={`notion-text ${serif ? 'serif' : ''}`}
953
+ value={value}
954
+ onChange={onTextChange}
955
+ onKeyDown={onKeyDown}
956
+ placeholder={placeholder}
957
+ autoFocus
958
+ rows={1}
959
+ />
960
+ {slash && slash.choices.length > 0 && (
961
+ <div className="slash-menu">
962
+ <div className="slash-menu-head">Insert block</div>
963
+ {slash.choices.map((c, i) => (
964
+ <button key={c.id}
965
+ onMouseEnter={() => setSlash(s => ({ ...s, idx: i }))}
966
+ onClick={() => insertSlash(c)}
967
+ className={i === slash.idx ? 'active' : ''}>
968
+ <Icon name={c.icon} size={13}/>
969
+ <span style={{ flex: 1 }}>{c.label}</span>
970
+ <span className="slash-menu-kind">{c.kind}</span>
971
+ </button>
972
+ ))}
973
+ </div>
974
+ )}
975
+ </div>
976
+ ) : (
977
+ <div
978
+ className={`rich-rendered ${serif ? 'serif' : ''}`}
979
+ onClick={() => setEditing(true)}
980
+ onFocus={() => setEditing(true)}
981
+ tabIndex={0}
982
+ >
983
+ {value ? (
984
+ <ReactMarkdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>{value}</ReactMarkdown>
985
+ ) : (
986
+ <span className="rich-placeholder">{hint || placeholder || 'Click to edit'}</span>
987
+ )}
988
+ </div>
989
+ )}
990
+ </div>
991
+ );
992
+ }
993
+
994
+ // ============================================================================
995
+ // SlashTextarea — auto-grows; `/` opens a block-insert popover.
996
+ // ============================================================================
997
+ function SlashTextarea({ value, onChange, placeholder, serif, notion, rows = 1 }) {
998
  const ref = useRef(null);
999
+ const [slash, setSlash] = useState(null); // { start, query, choices, idx }
1000
+
1001
  useEffect(() => {
1002
+ if (!ref.current || !notion) return;
1003
  ref.current.style.height = 'auto';
1004
  ref.current.style.height = ref.current.scrollHeight + 'px';
1005
+ }, [value, notion]);
1006
+
1007
+ const onTextChange = (e) => {
1008
+ const val = e.target.value;
1009
+ const cursor = e.target.selectionStart;
1010
+ onChange(val);
1011
+ const before = val.slice(0, cursor);
1012
+ const m = before.match(/(?:^|\n)(\/[\w-]*)$/);
1013
+ if (m) {
1014
+ const q = m[1].slice(1).toLowerCase();
1015
+ const choices = SLASH_COMMANDS.filter(c => c.label.toLowerCase().includes(q) || c.id.includes(q)).slice(0, 8);
1016
+ setSlash({ start: cursor - m[1].length, query: q, choices, idx: 0 });
1017
+ } else {
1018
+ setSlash(null);
1019
+ }
1020
+ };
1021
+
1022
+ const insertCmd = (cmd) => {
1023
+ if (!slash) return;
1024
+ const before = value.slice(0, slash.start);
1025
+ const after = value.slice(slash.start + 1 + slash.query.length);
1026
+ const inserted = cmd.insert();
1027
+ onChange(before + inserted + after);
1028
+ setSlash(null);
1029
+ setTimeout(() => {
1030
+ if (ref.current) {
1031
+ const pos = before.length + inserted.length;
1032
+ ref.current.setSelectionRange(pos, pos);
1033
+ ref.current.focus();
1034
+ }
1035
+ }, 0);
1036
+ };
1037
+
1038
+ const onKeyDown = (e) => {
1039
+ if (!slash || slash.choices.length === 0) return;
1040
+ if (e.key === 'ArrowDown') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.min(s.choices.length - 1, s.idx + 1) })); }
1041
+ if (e.key === 'ArrowUp') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.max(0, s.idx - 1) })); }
1042
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertCmd(slash.choices[slash.idx]); }
1043
+ if (e.key === 'Escape') setSlash(null);
1044
+ };
1045
+
1046
+ return (
1047
+ <div style={{ position: 'relative' }}>
1048
+ <textarea
1049
+ ref={ref}
1050
+ className={notion ? `notion-text ${serif ? 'serif' : ''}` : 'textarea'}
1051
+ value={value}
1052
+ onChange={onTextChange}
1053
+ onKeyDown={onKeyDown}
1054
+ placeholder={placeholder}
1055
+ rows={rows}
1056
+ style={notion ? undefined : { minHeight: 110, fontFamily: 'var(--canvas-sans)', fontSize: 13, lineHeight: 1.6 }}
1057
+ />
1058
+ {slash && slash.choices.length > 0 && (
1059
+ <div className="slash-menu">
1060
+ <div className="slash-menu-head">Insert block</div>
1061
+ {slash.choices.map((c, i) => (
1062
+ <button key={c.id}
1063
+ onMouseEnter={() => setSlash(s => ({ ...s, idx: i }))}
1064
+ onClick={() => insertCmd(c)}
1065
+ className={i === slash.idx ? 'active' : ''}>
1066
+ <Icon name={c.icon} size={13}/>
1067
+ <span style={{ flex: 1 }}>{c.label}</span>
1068
+ <span className="slash-menu-kind">{c.kind}</span>
1069
+ </button>
1070
+ ))}
1071
+ </div>
1072
+ )}
1073
+ </div>
1074
+ );
1075
+ }
1076
+
1077
+ // ============================================================================
1078
+ // Poster panel — single-section block in the poster layout
1079
+ // ============================================================================
1080
+ function PosterPanel({ section, sections, updateSection }) {
1081
+ if (!section) return null;
1082
+ const text = sections[section.id] || '';
1083
+ return (
1084
+ <div className="poster-panel">
1085
+ <div className="poster-panel-head">{section.name}</div>
1086
+ <RichBlock
1087
+ value={text}
1088
+ onChange={(v) => updateSection(section.id, v)}
1089
+ placeholder={section.hint}
1090
+ hint={section.hint}
1091
+ />
1092
+ </div>
1093
+ );
1094
+ }
1095
+
1096
+ // ============================================================================
1097
+ // arXiv search — public ATOM API, CORS-enabled.
1098
+ // ============================================================================
1099
+ function ArxivSearch({ onPick }) {
1100
+ const [q, setQ] = useState('');
1101
+ const [busy, setBusy] = useState(false);
1102
+ const [results, setResults] = useState([]);
1103
+
1104
+ const search = async () => {
1105
+ if (!q.trim()) return;
1106
+ setBusy(true);
1107
+ try {
1108
+ const url = `https://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(q)}&max_results=5`;
1109
+ const res = await fetch(url);
1110
+ const xml = await res.text();
1111
+ const doc = new DOMParser().parseFromString(xml, 'text/xml');
1112
+ const entries = Array.from(doc.getElementsByTagName('entry')).map(e => {
1113
+ const id = e.getElementsByTagName('id')[0]?.textContent?.split('/').pop() || '';
1114
+ const title = e.getElementsByTagName('title')[0]?.textContent?.trim() || '';
1115
+ const authors = Array.from(e.getElementsByTagName('author')).map(a => a.getElementsByTagName('name')[0]?.textContent?.trim()).filter(Boolean);
1116
+ const year = (e.getElementsByTagName('published')[0]?.textContent || '').slice(0, 4);
1117
+ return { id, title, authors, year };
1118
+ });
1119
+ setResults(entries);
1120
+ } catch (e) {
1121
+ fireToast('arXiv search failed', 'danger');
1122
+ } finally {
1123
+ setBusy(false);
1124
+ }
1125
+ };
1126
+
1127
  return (
1128
+ <div>
1129
+ <div style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 6 }}>
1130
+ Search arXiv
1131
+ </div>
1132
+ <div style={{ display: 'flex', gap: 4 }}>
1133
+ <input className="input" placeholder="Predictive coding…" value={q}
1134
+ onChange={e => setQ(e.target.value)}
1135
+ onKeyDown={e => { if (e.key === 'Enter') search(); }}
1136
+ style={{ fontSize: 12, padding: '5px 8px' }}/>
1137
+ <button className="icon-btn" onClick={search} disabled={!q.trim() || busy} title="Search arXiv">
1138
+ {busy ? <div className="spinner"/> : <Icon name="search" size={13}/>}
1139
+ </button>
1140
+ </div>
1141
+ {results.length > 0 && (
1142
+ <div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
1143
+ {results.map(r => {
1144
+ const a = r.authors[0] ? r.authors[0].split(' ').pop() : 'Unknown';
1145
+ const snippet = ` (${a}, ${r.year}; arXiv:${r.id})`;
1146
+ return (
1147
+ <button key={r.id} className="canvas-insert-row" onClick={() => onPick(snippet)}>
1148
+ <span className="tag-pill">arXiv</span>
1149
+ <span style={{ flex: 1, fontSize: 11.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={r.title}>{r.title}</span>
1150
+ <Icon name="plus" size={12} style={{ color: 'var(--canvas-text-3)' }}/>
1151
+ </button>
1152
+ );
1153
+ })}
1154
+ </div>
1155
+ )}
1156
+ </div>
1157
  );
1158
  }
1159
 
phd-advisor-frontend/src/styles/CanvasPage.css CHANGED
@@ -1609,6 +1609,374 @@ body[data-canvas-theme="light"] .canvas-modal-backdrop .critic-meter .bar { back
1609
  .canvas-page-with-sidebar .paper-empty { color: #9ca3af; font-style: italic; font-size: 12px; }
1610
  .canvas-page-with-sidebar .paper-section { margin-bottom: 4px; }
1611
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1612
  /* ----- Notion-style single-surface deliverable editor ----- */
1613
  .canvas-page-with-sidebar .notion-deliverable-grid {
1614
  display: grid;
 
1609
  .canvas-page-with-sidebar .paper-empty { color: #9ca3af; font-style: italic; font-size: 12px; }
1610
  .canvas-page-with-sidebar .paper-section { margin-bottom: 4px; }
1611
 
1612
+ /* ----- Deliverable project tabs (Overleaf-style multi-draft) ----- */
1613
+ .canvas-page-with-sidebar .deliverable-projects {
1614
+ display: flex;
1615
+ gap: 4px;
1616
+ flex-wrap: wrap;
1617
+ align-items: center;
1618
+ margin-bottom: 14px;
1619
+ padding-bottom: 12px;
1620
+ border-bottom: 1px solid var(--canvas-border);
1621
+ }
1622
+ .canvas-page-with-sidebar .deliverable-project-tab {
1623
+ display: inline-flex;
1624
+ align-items: center;
1625
+ gap: 6px;
1626
+ padding: 6px 11px;
1627
+ border-radius: 6px;
1628
+ background: transparent;
1629
+ border: 1px solid transparent;
1630
+ color: var(--canvas-text-2);
1631
+ font-family: inherit;
1632
+ font-size: 12.5px;
1633
+ cursor: pointer;
1634
+ transition: background .12s, color .12s, border-color .12s;
1635
+ }
1636
+ .canvas-page-with-sidebar .deliverable-project-tab:hover {
1637
+ background: var(--canvas-surface-2);
1638
+ color: var(--canvas-text);
1639
+ }
1640
+ .canvas-page-with-sidebar .deliverable-project-tab.active {
1641
+ background: var(--canvas-surface-2);
1642
+ border-color: var(--canvas-border);
1643
+ color: var(--canvas-text);
1644
+ font-weight: 600;
1645
+ }
1646
+ .canvas-page-with-sidebar .deliverable-new-project { position: relative; }
1647
+ .canvas-page-with-sidebar .deliverable-new-project summary {
1648
+ list-style: none;
1649
+ cursor: pointer;
1650
+ color: var(--canvas-text-3);
1651
+ }
1652
+ .canvas-page-with-sidebar .deliverable-new-project summary::-webkit-details-marker { display: none; }
1653
+ .canvas-page-with-sidebar .deliverable-new-project[open] summary { background: var(--canvas-surface-2); color: var(--canvas-text); }
1654
+
1655
+ /* Editable project title — looks like the page-title but is an input */
1656
+ .canvas-page-with-sidebar .page-title-editable {
1657
+ background: transparent;
1658
+ border: none;
1659
+ outline: none;
1660
+ width: 100%;
1661
+ font-family: inherit;
1662
+ font-size: 28px;
1663
+ font-weight: 700;
1664
+ letter-spacing: -0.02em;
1665
+ margin: 0;
1666
+ padding: 2px 0;
1667
+ color: var(--canvas-text);
1668
+ }
1669
+ .canvas-page-with-sidebar .page-title-editable:focus {
1670
+ background: rgba(255, 255, 255, 0.03);
1671
+ border-radius: 4px;
1672
+ padding: 2px 6px;
1673
+ margin: 0 -6px;
1674
+ }
1675
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .page-title-editable:focus {
1676
+ background: rgba(0, 0, 0, 0.025);
1677
+ }
1678
+
1679
+ /* Version history dropdown panel */
1680
+ .canvas-page-with-sidebar .deliverable-history {
1681
+ background: var(--canvas-surface);
1682
+ border: 1px solid var(--canvas-border);
1683
+ border-radius: 8px;
1684
+ margin-bottom: 16px;
1685
+ padding: 6px;
1686
+ max-height: 320px;
1687
+ overflow-y: auto;
1688
+ }
1689
+ .canvas-page-with-sidebar .deliverable-history-head {
1690
+ display: flex;
1691
+ align-items: center;
1692
+ justify-content: space-between;
1693
+ padding: 4px 8px 8px;
1694
+ font-size: 11px;
1695
+ font-family: var(--canvas-mono);
1696
+ text-transform: uppercase;
1697
+ letter-spacing: 0.08em;
1698
+ color: var(--canvas-text-3);
1699
+ border-bottom: 1px solid var(--canvas-border);
1700
+ margin-bottom: 4px;
1701
+ }
1702
+ .canvas-page-with-sidebar .deliverable-history-row {
1703
+ display: flex;
1704
+ align-items: center;
1705
+ gap: 8px;
1706
+ width: 100%;
1707
+ padding: 6px 8px;
1708
+ background: transparent;
1709
+ border: none;
1710
+ border-radius: 4px;
1711
+ color: var(--canvas-text-2);
1712
+ font-family: inherit;
1713
+ font-size: 12px;
1714
+ cursor: pointer;
1715
+ }
1716
+ .canvas-page-with-sidebar .deliverable-history-row:hover {
1717
+ background: var(--canvas-surface-2);
1718
+ color: var(--canvas-text);
1719
+ }
1720
+
1721
+ /* Slash menu (block-insert popover) */
1722
+ .canvas-page-with-sidebar .slash-menu {
1723
+ position: absolute;
1724
+ bottom: calc(100% + 4px);
1725
+ left: 0;
1726
+ z-index: 30;
1727
+ background: var(--canvas-surface);
1728
+ border: 1px solid var(--canvas-border-2);
1729
+ border-radius: 8px;
1730
+ box-shadow: var(--canvas-shadow-lg);
1731
+ padding: 4px;
1732
+ min-width: 240px;
1733
+ max-height: 280px;
1734
+ overflow-y: auto;
1735
+ display: flex;
1736
+ flex-direction: column;
1737
+ }
1738
+ .canvas-page-with-sidebar .slash-menu-head {
1739
+ font-size: 10px;
1740
+ font-family: var(--canvas-mono);
1741
+ text-transform: uppercase;
1742
+ letter-spacing: 0.08em;
1743
+ color: var(--canvas-text-4);
1744
+ padding: 6px 8px 4px;
1745
+ }
1746
+ .canvas-page-with-sidebar .slash-menu button {
1747
+ display: flex;
1748
+ align-items: center;
1749
+ gap: 8px;
1750
+ background: transparent;
1751
+ border: none;
1752
+ padding: 6px 8px;
1753
+ border-radius: 4px;
1754
+ font-family: inherit;
1755
+ font-size: 12.5px;
1756
+ color: var(--canvas-text);
1757
+ text-align: left;
1758
+ cursor: pointer;
1759
+ }
1760
+ .canvas-page-with-sidebar .slash-menu button:hover,
1761
+ .canvas-page-with-sidebar .slash-menu button.active {
1762
+ background: var(--canvas-surface-2);
1763
+ }
1764
+ .canvas-page-with-sidebar .slash-menu-kind {
1765
+ font-family: var(--canvas-mono);
1766
+ font-size: 9.5px;
1767
+ color: var(--canvas-text-4);
1768
+ text-transform: uppercase;
1769
+ letter-spacing: 0.06em;
1770
+ }
1771
+
1772
+ /* Poster mode — 4-quadrant grid, banners on top and bottom */
1773
+ .canvas-page-with-sidebar .poster-grid {
1774
+ display: grid;
1775
+ grid-template-columns: 1fr 1fr;
1776
+ gap: 14px;
1777
+ background: var(--canvas-surface);
1778
+ border: 1px solid var(--canvas-border);
1779
+ border-radius: 10px;
1780
+ padding: 18px;
1781
+ }
1782
+ .canvas-page-with-sidebar .poster-banner { grid-column: 1 / -1; }
1783
+ .canvas-page-with-sidebar .poster-panel {
1784
+ background: var(--canvas-bg-2);
1785
+ border: 1px solid var(--canvas-border);
1786
+ border-radius: 8px;
1787
+ padding: 14px;
1788
+ display: flex;
1789
+ flex-direction: column;
1790
+ gap: 8px;
1791
+ }
1792
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .poster-panel {
1793
+ background: #ffffff;
1794
+ border-color: rgba(15, 15, 15, 0.08);
1795
+ }
1796
+ .canvas-page-with-sidebar .poster-panel-head {
1797
+ font-size: 13px;
1798
+ font-weight: 700;
1799
+ text-transform: uppercase;
1800
+ letter-spacing: 0.06em;
1801
+ color: var(--canvas-accent);
1802
+ border-bottom: 2px solid var(--canvas-accent-glow);
1803
+ padding-bottom: 6px;
1804
+ }
1805
+ .canvas-page-with-sidebar .poster-panel textarea {
1806
+ flex: 1;
1807
+ min-height: 120px;
1808
+ background: transparent;
1809
+ border: none;
1810
+ outline: none;
1811
+ resize: none;
1812
+ font-family: inherit;
1813
+ font-size: 13px;
1814
+ line-height: 1.5;
1815
+ color: var(--canvas-text);
1816
+ padding: 0;
1817
+ }
1818
+ @media (max-width: 768px) {
1819
+ .canvas-page-with-sidebar .poster-grid { grid-template-columns: 1fr; }
1820
+ }
1821
+
1822
+ /* ----- Rich editor block (click-to-edit + floating toolbar + LaTeX math) ----- */
1823
+ .canvas-page-with-sidebar .rich-block {
1824
+ position: relative;
1825
+ margin-top: 4px;
1826
+ }
1827
+
1828
+ .canvas-page-with-sidebar .rich-toolbar {
1829
+ position: absolute;
1830
+ bottom: calc(100% + 6px);
1831
+ left: 0;
1832
+ display: flex;
1833
+ flex-wrap: wrap;
1834
+ gap: 1px;
1835
+ padding: 4px;
1836
+ background: var(--canvas-surface);
1837
+ border: 1px solid var(--canvas-border-2);
1838
+ border-radius: 8px;
1839
+ box-shadow: var(--canvas-shadow-lg);
1840
+ z-index: 20;
1841
+ animation: canvas-view-in 140ms var(--canvas-ease);
1842
+ }
1843
+ .canvas-page-with-sidebar .rich-toolbar button {
1844
+ background: transparent;
1845
+ border: none;
1846
+ width: 30px;
1847
+ height: 28px;
1848
+ border-radius: 4px;
1849
+ display: grid;
1850
+ place-items: center;
1851
+ color: var(--canvas-text-2);
1852
+ font-family: inherit;
1853
+ font-size: 13px;
1854
+ cursor: pointer;
1855
+ transition: background .12s, color .12s;
1856
+ }
1857
+ .canvas-page-with-sidebar .rich-toolbar button:hover {
1858
+ background: var(--canvas-surface-2);
1859
+ color: var(--canvas-text);
1860
+ }
1861
+
1862
+ /* Rendered block (idle state — looks like real document text) */
1863
+ .canvas-page-with-sidebar .rich-rendered {
1864
+ cursor: text;
1865
+ padding: 4px 0;
1866
+ border-radius: 4px;
1867
+ transition: background .12s;
1868
+ outline: none;
1869
+ }
1870
+ .canvas-page-with-sidebar .rich-rendered:hover {
1871
+ background: rgba(255, 255, 255, 0.02);
1872
+ }
1873
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .rich-rendered:hover {
1874
+ background: rgba(0, 0, 0, 0.02);
1875
+ }
1876
+ .canvas-page-with-sidebar .rich-rendered:focus-visible {
1877
+ background: rgba(0, 0, 0, 0.025);
1878
+ box-shadow: 0 0 0 2px var(--canvas-accent-glow);
1879
+ }
1880
+ .canvas-page-with-sidebar .rich-placeholder {
1881
+ color: var(--canvas-text-4);
1882
+ font-style: italic;
1883
+ }
1884
+
1885
+ /* Inside the rendered block: typography matching the page */
1886
+ .canvas-page-with-sidebar .rich-rendered > * { margin: 0; }
1887
+ .canvas-page-with-sidebar .rich-rendered > * + * { margin-top: 12px; }
1888
+ .canvas-page-with-sidebar .rich-rendered p {
1889
+ font-size: 16px;
1890
+ line-height: 1.65;
1891
+ color: var(--canvas-text);
1892
+ }
1893
+ .canvas-page-with-sidebar .rich-rendered.serif p {
1894
+ font-family: 'Georgia', 'Times New Roman', serif;
1895
+ font-size: 14.5px;
1896
+ line-height: 1.7;
1897
+ text-align: justify;
1898
+ }
1899
+ .canvas-page-with-sidebar .rich-rendered h1,
1900
+ .canvas-page-with-sidebar .rich-rendered h2,
1901
+ .canvas-page-with-sidebar .rich-rendered h3,
1902
+ .canvas-page-with-sidebar .rich-rendered h4 {
1903
+ font-weight: 700;
1904
+ letter-spacing: -0.01em;
1905
+ color: var(--canvas-text);
1906
+ margin-top: 16px;
1907
+ }
1908
+ .canvas-page-with-sidebar .rich-rendered h1 { font-size: 24px; }
1909
+ .canvas-page-with-sidebar .rich-rendered h2 { font-size: 19px; }
1910
+ .canvas-page-with-sidebar .rich-rendered h3 { font-size: 16px; }
1911
+ .canvas-page-with-sidebar .rich-rendered ul,
1912
+ .canvas-page-with-sidebar .rich-rendered ol {
1913
+ padding-left: 22px;
1914
+ font-size: 16px;
1915
+ line-height: 1.65;
1916
+ color: var(--canvas-text);
1917
+ }
1918
+ .canvas-page-with-sidebar .rich-rendered.serif ul,
1919
+ .canvas-page-with-sidebar .rich-rendered.serif ol {
1920
+ font-family: 'Georgia', serif;
1921
+ font-size: 14.5px;
1922
+ }
1923
+ .canvas-page-with-sidebar .rich-rendered li { margin-bottom: 4px; }
1924
+ .canvas-page-with-sidebar .rich-rendered blockquote {
1925
+ border-left: 3px solid var(--canvas-accent);
1926
+ padding: 4px 14px;
1927
+ color: var(--canvas-text-2);
1928
+ margin-left: 0;
1929
+ background: rgba(99, 102, 241, 0.04);
1930
+ border-radius: 0 4px 4px 0;
1931
+ }
1932
+ .canvas-page-with-sidebar .rich-rendered code {
1933
+ background: var(--canvas-surface-2);
1934
+ padding: 1px 5px;
1935
+ border-radius: 3px;
1936
+ font-family: var(--canvas-mono);
1937
+ font-size: 13px;
1938
+ }
1939
+ .canvas-page-with-sidebar .rich-rendered pre {
1940
+ background: var(--canvas-bg-2);
1941
+ border: 1px solid var(--canvas-border);
1942
+ border-radius: 6px;
1943
+ padding: 12px;
1944
+ overflow-x: auto;
1945
+ font-family: var(--canvas-mono);
1946
+ font-size: 13px;
1947
+ }
1948
+ .canvas-page-with-sidebar .rich-rendered pre code { background: none; padding: 0; }
1949
+ .canvas-page-with-sidebar .rich-rendered a {
1950
+ color: var(--canvas-accent);
1951
+ text-decoration: underline;
1952
+ text-underline-offset: 2px;
1953
+ }
1954
+ .canvas-page-with-sidebar .rich-rendered strong { color: var(--canvas-text); font-weight: 700; }
1955
+ .canvas-page-with-sidebar .rich-rendered em { color: var(--canvas-text); }
1956
+ .canvas-page-with-sidebar .rich-rendered img {
1957
+ max-width: 100%;
1958
+ height: auto;
1959
+ border-radius: 4px;
1960
+ margin: 8px 0;
1961
+ display: block;
1962
+ }
1963
+
1964
+ /* KaTeX math overrides — match document text size */
1965
+ .canvas-page-with-sidebar .rich-rendered .katex {
1966
+ font-size: 1.05em;
1967
+ color: var(--canvas-text);
1968
+ }
1969
+ .canvas-page-with-sidebar .rich-rendered .katex-display {
1970
+ margin: 16px 0;
1971
+ padding: 8px 12px;
1972
+ background: var(--canvas-bg-2);
1973
+ border-radius: 6px;
1974
+ overflow-x: auto;
1975
+ }
1976
+ .canvas-page-with-sidebar[data-canvas-theme="light"] .rich-rendered .katex-display {
1977
+ background: rgba(0, 0, 0, 0.025);
1978
+ }
1979
+
1980
  /* ----- Notion-style single-surface deliverable editor ----- */
1981
  .canvas-page-with-sidebar .notion-deliverable-grid {
1982
  display: grid;