tfrere HF Staff commited on
Commit
d15d7f7
·
1 Parent(s): c68749f

refactor: remove MUI, unify publisher pipeline, add shared component registry

Browse files

Phase 1 - Publisher pipeline:
- Extract ~370 lines of inline CSS from html-renderer.ts into _publisher.css
- Create shared/component-defs.ts as single source of truth for component definitions
- Replace 6 regex blocks in postProcess() with linkedom DOM manipulation
- Add GET /api/preview/:docName for testing renders without publishing
- Add 12 snapshot tests for each postProcess transformation (46 tests total)

Phase 2 - Remove MUI:
- Replace all MUI components with native HTML elements + custom CSS
- Create _ui.css with editor chrome styles using --ed-* CSS tokens
- Create Tooltip.tsx using Floating UI (lightweight replacement)
- Replace @mui /icons-material with lucide-react
- Delete theme.ts, remove @mui /* and @emotion /* dependencies

Phase 3 - Documentation:
- Add docs/ARCHITECTURE.md covering CSS layers, HF Spaces constraints,
shared registry, publisher pipeline, and test coverage

Made-with: Cursor

Dockerfile CHANGED
@@ -5,6 +5,7 @@ WORKDIR /app/frontend
5
  COPY frontend/package.json frontend/package-lock.json* frontend/.npmrc* ./
6
  RUN npm install
7
  COPY frontend/ ./
 
8
  RUN npm run build
9
 
10
  # --- Stage 2: Build backend ---
 
5
  COPY frontend/package.json frontend/package-lock.json* frontend/.npmrc* ./
6
  RUN npm install
7
  COPY frontend/ ./
8
+ COPY backend/src/shared/ ../backend/src/shared/
9
  RUN npm run build
10
 
11
  # --- Stage 2: Build backend ---
backend/package-lock.json CHANGED
@@ -34,6 +34,7 @@
34
  "ai": "^6.0.158",
35
  "dotenv": "^17.4.1",
36
  "express": "^4.21.0",
 
37
  "lowlight": "^3.3.0",
38
  "multer": "^2.1.1",
39
  "playwright": "^1.59.1",
@@ -2014,6 +2015,12 @@
2014
  "npm": "1.2.8000 || >= 1.4.16"
2015
  }
2016
  },
 
 
 
 
 
 
2017
  "node_modules/buffer": {
2018
  "version": "5.7.1",
2019
  "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -2195,6 +2202,40 @@
2195
  "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
2196
  "license": "MIT"
2197
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2198
  "node_modules/debug": {
2199
  "version": "2.6.9",
2200
  "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -2255,6 +2296,73 @@
2255
  "url": "https://github.com/sponsors/wooorm"
2256
  }
2257
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2258
  "node_modules/dotenv": {
2259
  "version": "17.4.1",
2260
  "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
@@ -2697,6 +2805,31 @@
2697
  "node": ">=12.0.0"
2698
  }
2699
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2700
  "node_modules/http-errors": {
2701
  "version": "2.0.1",
2702
  "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3098,6 +3231,30 @@
3098
  "url": "https://opencollective.com/parcel"
3099
  }
3100
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3101
  "node_modules/linkify-it": {
3102
  "version": "5.0.0",
3103
  "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -3322,6 +3479,18 @@
3322
  }
3323
  }
3324
  },
 
 
 
 
 
 
 
 
 
 
 
 
3325
  "node_modules/object-inspect": {
3326
  "version": "1.13.4",
3327
  "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -4174,6 +4343,12 @@
4174
  "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
4175
  "license": "MIT"
4176
  },
 
 
 
 
 
 
4177
  "node_modules/undici-types": {
4178
  "version": "6.21.0",
4179
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
 
34
  "ai": "^6.0.158",
35
  "dotenv": "^17.4.1",
36
  "express": "^4.21.0",
37
+ "linkedom": "^0.18.12",
38
  "lowlight": "^3.3.0",
39
  "multer": "^2.1.1",
40
  "playwright": "^1.59.1",
 
2015
  "npm": "1.2.8000 || >= 1.4.16"
2016
  }
2017
  },
2018
+ "node_modules/boolbase": {
2019
+ "version": "1.0.0",
2020
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
2021
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
2022
+ "license": "ISC"
2023
+ },
2024
  "node_modules/buffer": {
2025
  "version": "5.7.1",
2026
  "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
 
2202
  "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
2203
  "license": "MIT"
2204
  },
2205
+ "node_modules/css-select": {
2206
+ "version": "5.2.2",
2207
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
2208
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
2209
+ "license": "BSD-2-Clause",
2210
+ "dependencies": {
2211
+ "boolbase": "^1.0.0",
2212
+ "css-what": "^6.1.0",
2213
+ "domhandler": "^5.0.2",
2214
+ "domutils": "^3.0.1",
2215
+ "nth-check": "^2.0.1"
2216
+ },
2217
+ "funding": {
2218
+ "url": "https://github.com/sponsors/fb55"
2219
+ }
2220
+ },
2221
+ "node_modules/css-what": {
2222
+ "version": "6.2.2",
2223
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
2224
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
2225
+ "license": "BSD-2-Clause",
2226
+ "engines": {
2227
+ "node": ">= 6"
2228
+ },
2229
+ "funding": {
2230
+ "url": "https://github.com/sponsors/fb55"
2231
+ }
2232
+ },
2233
+ "node_modules/cssom": {
2234
+ "version": "0.5.0",
2235
+ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
2236
+ "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
2237
+ "license": "MIT"
2238
+ },
2239
  "node_modules/debug": {
2240
  "version": "2.6.9",
2241
  "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
 
2296
  "url": "https://github.com/sponsors/wooorm"
2297
  }
2298
  },
2299
+ "node_modules/dom-serializer": {
2300
+ "version": "2.0.0",
2301
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
2302
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
2303
+ "license": "MIT",
2304
+ "dependencies": {
2305
+ "domelementtype": "^2.3.0",
2306
+ "domhandler": "^5.0.2",
2307
+ "entities": "^4.2.0"
2308
+ },
2309
+ "funding": {
2310
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
2311
+ }
2312
+ },
2313
+ "node_modules/dom-serializer/node_modules/entities": {
2314
+ "version": "4.5.0",
2315
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
2316
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
2317
+ "license": "BSD-2-Clause",
2318
+ "engines": {
2319
+ "node": ">=0.12"
2320
+ },
2321
+ "funding": {
2322
+ "url": "https://github.com/fb55/entities?sponsor=1"
2323
+ }
2324
+ },
2325
+ "node_modules/domelementtype": {
2326
+ "version": "2.3.0",
2327
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
2328
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
2329
+ "funding": [
2330
+ {
2331
+ "type": "github",
2332
+ "url": "https://github.com/sponsors/fb55"
2333
+ }
2334
+ ],
2335
+ "license": "BSD-2-Clause"
2336
+ },
2337
+ "node_modules/domhandler": {
2338
+ "version": "5.0.3",
2339
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
2340
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
2341
+ "license": "BSD-2-Clause",
2342
+ "dependencies": {
2343
+ "domelementtype": "^2.3.0"
2344
+ },
2345
+ "engines": {
2346
+ "node": ">= 4"
2347
+ },
2348
+ "funding": {
2349
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
2350
+ }
2351
+ },
2352
+ "node_modules/domutils": {
2353
+ "version": "3.2.2",
2354
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
2355
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
2356
+ "license": "BSD-2-Clause",
2357
+ "dependencies": {
2358
+ "dom-serializer": "^2.0.0",
2359
+ "domelementtype": "^2.3.0",
2360
+ "domhandler": "^5.0.3"
2361
+ },
2362
+ "funding": {
2363
+ "url": "https://github.com/fb55/domutils?sponsor=1"
2364
+ }
2365
+ },
2366
  "node_modules/dotenv": {
2367
  "version": "17.4.1",
2368
  "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
 
2805
  "node": ">=12.0.0"
2806
  }
2807
  },
2808
+ "node_modules/html-escaper": {
2809
+ "version": "3.0.3",
2810
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
2811
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
2812
+ "license": "MIT"
2813
+ },
2814
+ "node_modules/htmlparser2": {
2815
+ "version": "10.1.0",
2816
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
2817
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
2818
+ "funding": [
2819
+ "https://github.com/fb55/htmlparser2?sponsor=1",
2820
+ {
2821
+ "type": "github",
2822
+ "url": "https://github.com/sponsors/fb55"
2823
+ }
2824
+ ],
2825
+ "license": "MIT",
2826
+ "dependencies": {
2827
+ "domelementtype": "^2.3.0",
2828
+ "domhandler": "^5.0.3",
2829
+ "domutils": "^3.2.2",
2830
+ "entities": "^7.0.1"
2831
+ }
2832
+ },
2833
  "node_modules/http-errors": {
2834
  "version": "2.0.1",
2835
  "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
 
3231
  "url": "https://opencollective.com/parcel"
3232
  }
3233
  },
3234
+ "node_modules/linkedom": {
3235
+ "version": "0.18.12",
3236
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
3237
+ "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
3238
+ "license": "ISC",
3239
+ "dependencies": {
3240
+ "css-select": "^5.1.0",
3241
+ "cssom": "^0.5.0",
3242
+ "html-escaper": "^3.0.3",
3243
+ "htmlparser2": "^10.0.0",
3244
+ "uhyphen": "^0.2.0"
3245
+ },
3246
+ "engines": {
3247
+ "node": ">=16"
3248
+ },
3249
+ "peerDependencies": {
3250
+ "canvas": ">= 2"
3251
+ },
3252
+ "peerDependenciesMeta": {
3253
+ "canvas": {
3254
+ "optional": true
3255
+ }
3256
+ }
3257
+ },
3258
  "node_modules/linkify-it": {
3259
  "version": "5.0.0",
3260
  "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
 
3479
  }
3480
  }
3481
  },
3482
+ "node_modules/nth-check": {
3483
+ "version": "2.1.1",
3484
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
3485
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
3486
+ "license": "BSD-2-Clause",
3487
+ "dependencies": {
3488
+ "boolbase": "^1.0.0"
3489
+ },
3490
+ "funding": {
3491
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
3492
+ }
3493
+ },
3494
  "node_modules/object-inspect": {
3495
  "version": "1.13.4",
3496
  "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
 
4343
  "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
4344
  "license": "MIT"
4345
  },
4346
+ "node_modules/uhyphen": {
4347
+ "version": "0.2.0",
4348
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
4349
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
4350
+ "license": "ISC"
4351
+ },
4352
  "node_modules/undici-types": {
4353
  "version": "6.21.0",
4354
  "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
backend/package.json CHANGED
@@ -37,6 +37,7 @@
37
  "ai": "^6.0.158",
38
  "dotenv": "^17.4.1",
39
  "express": "^4.21.0",
 
40
  "lowlight": "^3.3.0",
41
  "multer": "^2.1.1",
42
  "playwright": "^1.59.1",
 
37
  "ai": "^6.0.158",
38
  "dotenv": "^17.4.1",
39
  "express": "^4.21.0",
40
+ "linkedom": "^0.18.12",
41
  "lowlight": "^3.3.0",
42
  "multer": "^2.1.1",
43
  "playwright": "^1.59.1",
backend/src/publisher/extensions.ts CHANGED
@@ -8,6 +8,7 @@
8
  */
9
 
10
  import { Node, mergeAttributes, type Extensions } from "@tiptap/core";
 
11
  import StarterKit from "@tiptap/starter-kit";
12
  import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
13
  import Mathematics from "@tiptap/extension-mathematics";
@@ -173,89 +174,9 @@ const StackServer = Node.create({
173
  },
174
  });
175
 
176
- // ---- Generic wrapper/atomic components from the registry ----
177
 
178
- interface ComponentDefLite {
179
- name: string;
180
- kind: "wrapper" | "atomic";
181
- fields: { name: string; type: string; default?: unknown }[];
182
- content?: string;
183
- }
184
-
185
- const COMPONENT_DEFS: ComponentDefLite[] = [
186
- {
187
- name: "accordion",
188
- kind: "wrapper",
189
- fields: [
190
- { name: "title", type: "string", default: "Details" },
191
- { name: "open", type: "boolean", default: false },
192
- ],
193
- },
194
- {
195
- name: "note",
196
- kind: "wrapper",
197
- fields: [
198
- { name: "title", type: "string", default: "" },
199
- { name: "emoji", type: "string", default: "" },
200
- { name: "variant", type: "select", default: "neutral" },
201
- ],
202
- },
203
- {
204
- name: "quoteBlock",
205
- kind: "wrapper",
206
- fields: [
207
- { name: "author", type: "string", default: "" },
208
- { name: "source", type: "string", default: "" },
209
- ],
210
- },
211
- { name: "wide", kind: "wrapper", fields: [] },
212
- { name: "fullWidth", kind: "wrapper", fields: [] },
213
- { name: "sidenote", kind: "wrapper", fields: [] },
214
- {
215
- name: "reference",
216
- kind: "wrapper",
217
- fields: [
218
- { name: "id", type: "string", default: "" },
219
- { name: "caption", type: "string", default: "" },
220
- ],
221
- },
222
- {
223
- name: "htmlEmbed",
224
- kind: "atomic",
225
- fields: [
226
- { name: "src", type: "string", default: "" },
227
- { name: "title", type: "string", default: "" },
228
- { name: "desc", type: "string", default: "" },
229
- { name: "wide", type: "boolean", default: false },
230
- { name: "downloadable", type: "boolean", default: false },
231
- ],
232
- },
233
- {
234
- name: "hfUser",
235
- kind: "atomic",
236
- fields: [
237
- { name: "username", type: "string", default: "" },
238
- { name: "name", type: "string", default: "" },
239
- { name: "url", type: "string", default: "" },
240
- ],
241
- },
242
- {
243
- name: "rawHtml",
244
- kind: "atomic",
245
- fields: [
246
- { name: "html", type: "string", default: "" },
247
- ],
248
- },
249
- {
250
- name: "mermaid",
251
- kind: "atomic",
252
- fields: [
253
- { name: "code", type: "string", default: "" },
254
- ],
255
- },
256
- ];
257
-
258
- function makeServerWrapperExt(def: ComponentDefLite) {
259
  return Node.create({
260
  name: def.name,
261
  group: "block",
@@ -280,7 +201,7 @@ function makeServerWrapperExt(def: ComponentDefLite) {
280
  });
281
  }
282
 
283
- function makeServerAtomicExt(def: ComponentDefLite) {
284
  return Node.create({
285
  name: def.name,
286
  group: "block",
@@ -303,8 +224,8 @@ function makeServerAtomicExt(def: ComponentDefLite) {
303
  }
304
 
305
  export function getServerExtensions(): Extensions {
306
- const wrappers = COMPONENT_DEFS.filter((d) => d.kind === "wrapper").map(makeServerWrapperExt);
307
- const atomics = COMPONENT_DEFS.filter((d) => d.kind === "atomic").map(makeServerAtomicExt);
308
 
309
  return [
310
  StarterKit.configure({
 
8
  */
9
 
10
  import { Node, mergeAttributes, type Extensions } from "@tiptap/core";
11
+ import { SHARED_COMPONENT_DEFS, type SharedComponentDef } from "../shared/component-defs.js";
12
  import StarterKit from "@tiptap/starter-kit";
13
  import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
14
  import Mathematics from "@tiptap/extension-mathematics";
 
174
  },
175
  });
176
 
177
+ // ---- Generic wrapper/atomic components from the shared registry ----
178
 
179
+ function makeServerWrapperExt(def: SharedComponentDef) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  return Node.create({
181
  name: def.name,
182
  group: "block",
 
201
  });
202
  }
203
 
204
+ function makeServerAtomicExt(def: SharedComponentDef) {
205
  return Node.create({
206
  name: def.name,
207
  group: "block",
 
224
  }
225
 
226
  export function getServerExtensions(): Extensions {
227
+ const wrappers = SHARED_COMPONENT_DEFS.filter((d) => d.kind === "wrapper").map(makeServerWrapperExt);
228
+ const atomics = SHARED_COMPONENT_DEFS.filter((d) => d.kind === "atomic").map(makeServerAtomicExt);
229
 
230
  return [
231
  StarterKit.configure({
backend/src/publisher/html-renderer.ts CHANGED
@@ -6,6 +6,7 @@
6
  */
7
 
8
  import { generateHTML } from "@tiptap/html";
 
9
  import { getServerExtensions } from "./extensions.js";
10
  import type { PublishCSS } from "./index.js";
11
 
@@ -105,411 +106,16 @@ export function renderArticleHTML(
105
  </script>
106
 
107
  <style>
108
- /* Template foundation */
109
  ${css.variables}
110
  ${css.reset}
111
-
112
- /* Published page global reset (not needed in editor where MUI handles body) */
113
- body { font-family: var(--default-font-family); color: var(--text-color); }
114
- html { font-size: 16px; line-height: 1.6; background-color: var(--page-bg); overflow-x: hidden; scroll-behavior: smooth; scroll-padding-top: 80px; }
115
-
116
  ${css.editorTokens}
117
  ${css.base}
118
  ${css.layout}
119
  ${css.components}
120
  ${css.article}
121
  ${css.print}
 
122
 
123
- /* Publisher-only overrides */
124
-
125
- /* Theme toggle icon wrapper (from ThemeToggle.astro) */
126
- #theme-toggle .icon-wrapper {
127
- display: grid;
128
- place-items: center;
129
- width: 20px;
130
- height: 20px;
131
- }
132
- #theme-toggle .icon-wrapper .icon {
133
- grid-area: 1 / 1;
134
- filter: none !important;
135
- }
136
- #theme-toggle .icon-wrapper.animated .icon {
137
- transition: opacity 0.35s ease;
138
- }
139
- #theme-toggle .icon-wrapper.spin-cw { animation: spin-cw 0.5s cubic-bezier(0.4,0,0.2,1); }
140
- #theme-toggle .icon-wrapper.spin-ccw { animation: spin-ccw 0.5s cubic-bezier(0.4,0,0.2,1); }
141
- @keyframes spin-cw { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
142
- @keyframes spin-ccw { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
143
-
144
- /* Note component - aligned with template Note.astro */
145
- div[data-component="note"] {
146
- background: var(--surface-bg);
147
- border-left: 2px solid var(--border-color);
148
- border-top-right-radius: 8px;
149
- border-bottom-right-radius: 8px;
150
- padding: 20px 18px;
151
- margin: var(--block-spacing-y, 1em) 0;
152
- }
153
- div[data-component="note"][variant="info"] {
154
- border-left-color: #f39c12;
155
- background: color-mix(in oklab, #f39c12 10%, var(--surface-bg));
156
- }
157
- div[data-component="note"][variant="success"] {
158
- border-left-color: #2ecc71;
159
- background: color-mix(in oklab, #2ecc71 8%, var(--surface-bg));
160
- }
161
- div[data-component="note"][variant="danger"] {
162
- border-left-color: #e74c3c;
163
- background: color-mix(in oklab, #e74c3c 8%, var(--surface-bg));
164
- }
165
- div[data-component="note"] .note__title {
166
- font-size: 16px;
167
- font-weight: 600;
168
- color: var(--text-color);
169
- margin-bottom: 6px;
170
- }
171
- div[data-component="note"] > *:first-child { margin-top: 0; }
172
- div[data-component="note"] > *:last-child { margin-bottom: 0; }
173
-
174
- /* Quote component - aligned with template Quote.astro */
175
- div[data-component="quoteBlock"] {
176
- position: relative;
177
- margin: 32px 0;
178
- max-width: 600px;
179
- padding: 0;
180
- border: none;
181
- background: none;
182
- }
183
- div[data-component="quoteBlock"]::before {
184
- content: '\\201C';
185
- position: absolute;
186
- top: -24px;
187
- left: -30px;
188
- font-size: 8rem;
189
- font-weight: 400;
190
- color: var(--text-color);
191
- opacity: 0.05;
192
- z-index: -1;
193
- line-height: 1;
194
- pointer-events: none;
195
- }
196
- div[data-component="quoteBlock"] .quote-text {
197
- font-size: 1.5rem;
198
- line-height: 1.4;
199
- font-weight: 400;
200
- letter-spacing: -0.01em;
201
- color: var(--text-color);
202
- }
203
- div[data-component="quoteBlock"] .quote-footer {
204
- font-size: 0.875rem;
205
- color: var(--muted-color);
206
- margin-top: 12px;
207
- }
208
- div[data-component="quoteBlock"] .quote-author::before {
209
- content: '\\2014\\00A0';
210
- font-style: normal;
211
- }
212
- div[data-component="quoteBlock"] .quote-author {
213
- font-weight: 500;
214
- font-style: italic;
215
- color: var(--text-color);
216
- opacity: 0.85;
217
- }
218
- div[data-component="quoteBlock"] .quote-source {
219
- font-weight: 500;
220
- font-style: italic;
221
- color: var(--text-color);
222
- opacity: 0.85;
223
- }
224
- div[data-component="quoteBlock"] > *:first-child { margin-top: 0; }
225
- div[data-component="quoteBlock"] > *:last-child { margin-bottom: 0; }
226
-
227
- /* Sidenote component - aligned with template Sidenote.astro */
228
- div[data-component="sidenote"] {
229
- font-size: 0.9rem;
230
- color: var(--muted-color);
231
- padding: 0 30px;
232
- margin: 1em 0;
233
- }
234
- div[data-component="sidenote"] > *:first-child { margin-top: 0; }
235
- div[data-component="sidenote"] > *:last-child { margin-bottom: 0; }
236
-
237
- /* Reference wrapper - aligned with template Reference.astro */
238
- div[data-component="reference"] {
239
- margin: 0 0 var(--spacing-4, 1rem);
240
- }
241
- div[data-component="reference"] .reference__caption {
242
- text-align: left;
243
- font-size: 0.9rem;
244
- color: var(--muted-color);
245
- margin-top: 6px;
246
- }
247
- div[data-component="reference"] > *:first-child { margin-top: 0; }
248
- div[data-component="reference"] > *:last-child { margin-bottom: 0; }
249
-
250
- /* Stack / multi-column layout - aligned with template Stack.astro */
251
- div[data-component="stack"] {
252
- display: grid;
253
- gap: 1rem;
254
- margin: var(--block-spacing-y, 1.5em) 0;
255
- width: 100%;
256
- max-width: 100%;
257
- box-sizing: border-box;
258
- }
259
- div[data-component="stack"][data-layout="2-column"] { grid-template-columns: repeat(2, 1fr); }
260
- div[data-component="stack"][data-layout="3-column"] { grid-template-columns: repeat(3, 1fr); }
261
- div[data-component="stack"][data-layout="4-column"] { grid-template-columns: repeat(4, 1fr); }
262
- div[data-component="stack"][data-layout="auto"] { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
263
- div[data-component="stack"]:not([data-layout]) { grid-template-columns: repeat(2, 1fr); }
264
- div[data-component="stack"][data-gap="small"] { gap: 0.5rem; }
265
- div[data-component="stack"][data-gap="large"] { gap: 2rem; }
266
- div[data-type="stack-column"] { min-width: 0; overflow: hidden; word-wrap: break-word; overflow-wrap: break-word; }
267
- div[data-type="stack-column"] > *:first-child { margin-top: 0; }
268
- div[data-type="stack-column"] > *:last-child { margin-bottom: 0; }
269
- @media (max-width: 768px) {
270
- div[data-component="stack"][data-layout="2-column"],
271
- div[data-component="stack"][data-layout="3-column"],
272
- div[data-component="stack"][data-layout="4-column"],
273
- div[data-component="stack"][data-layout="auto"],
274
- div[data-component="stack"]:not([data-layout]) { grid-template-columns: 1fr !important; }
275
- }
276
- @media (min-width: 769px) and (max-width: 1100px) {
277
- div[data-component="stack"][data-layout="3-column"],
278
- div[data-component="stack"][data-layout="4-column"],
279
- div[data-component="stack"][data-layout="auto"] { grid-template-columns: repeat(2, 1fr) !important; }
280
- }
281
-
282
- /* Wide / full-width helpers */
283
- div[data-component="wide"] {
284
- width: min(1100px, 100vw - 64px);
285
- margin-left: 50%;
286
- transform: translateX(-50%);
287
- }
288
- div[data-component="fullWidth"] {
289
- width: 100vw;
290
- margin-left: calc(50% - 50vw);
291
- }
292
-
293
- /* Accordion - aligned with template Accordion.astro */
294
- details[data-component="accordion"] {
295
- border: 1px solid var(--border-color);
296
- border-radius: var(--table-border-radius, 8px);
297
- background: var(--surface-bg);
298
- padding: 0;
299
- margin: 0 0 var(--spacing-4, 1em);
300
- transition: box-shadow 180ms ease, border-color 180ms ease;
301
- }
302
- details[data-component="accordion"][open] {
303
- border-color: color-mix(in oklab, var(--border-color), var(--primary-color) 20%);
304
- }
305
- details[data-component="accordion"] > summary {
306
- display: flex;
307
- align-items: center;
308
- justify-content: space-between;
309
- gap: 4px;
310
- padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
311
- cursor: pointer;
312
- font-weight: 600;
313
- color: var(--text-color);
314
- list-style: none;
315
- user-select: none;
316
- }
317
- details[data-component="accordion"][open] > summary {
318
- border-bottom: 1px solid var(--border-color);
319
- }
320
- details[data-component="accordion"] > summary::-webkit-details-marker { display: none; }
321
- details[data-component="accordion"] > summary::marker { content: ""; }
322
- details[data-component="accordion"] > summary::after {
323
- content: '';
324
- display: inline-block;
325
- width: 0.5em;
326
- height: 0.5em;
327
- border-right: 2px solid currentColor;
328
- border-bottom: 2px solid currentColor;
329
- transform: rotate(-45deg);
330
- transition: transform 220ms ease;
331
- opacity: 0.6;
332
- flex-shrink: 0;
333
- }
334
- details[data-component="accordion"][open] > summary::after {
335
- transform: rotate(45deg);
336
- }
337
- details[data-component="accordion"] > .accordion-content {
338
- padding: 8px;
339
- }
340
- details[data-component="accordion"] > .accordion-content > *:first-child { margin-top: 0; }
341
- details[data-component="accordion"] > .accordion-content > *:last-child { margin-bottom: 0; }
342
-
343
- /* Footer */
344
- .footer {
345
- contain: layout style;
346
- font-size: 0.8em;
347
- line-height: 1.7em;
348
- margin-top: 60px;
349
- margin-bottom: 0;
350
- border-top: 1px solid rgba(0, 0, 0, 0.1);
351
- color: rgba(0, 0, 0, 0.5);
352
- }
353
-
354
- .footer-inner {
355
- max-width: 1280px;
356
- margin: 0 auto;
357
- padding: 60px 16px 48px;
358
- display: grid;
359
- grid-template-columns: 220px minmax(0, 680px) 260px;
360
- gap: 32px;
361
- align-items: start;
362
- }
363
-
364
- .citation-block,
365
- .references-block,
366
- .reuse-block,
367
- .doi-block {
368
- display: contents;
369
- }
370
-
371
- .citation-block > .footer-heading,
372
- .references-block > .footer-heading,
373
- .reuse-block > .footer-heading,
374
- .doi-block > .footer-heading {
375
- grid-column: 1;
376
- font-size: 15px;
377
- font-weight: 600;
378
- margin: 0;
379
- text-align: right;
380
- padding-right: 30px;
381
- }
382
-
383
- .citation-block > :not(.footer-heading),
384
- .references-block > :not(.footer-heading),
385
- .reuse-block > :not(.footer-heading),
386
- .doi-block > :not(.footer-heading) {
387
- grid-column: 2;
388
- }
389
-
390
- .citation-block .footer-heading { margin: 0 0 8px; }
391
-
392
- .citation-block p,
393
- .reuse-block p,
394
- .doi-block p,
395
- .footnotes ol,
396
- .footnotes ol p,
397
- .references { margin-top: 0; }
398
-
399
- .footnote-ref a {
400
- color: var(--primary-color, #958DF1);
401
- text-decoration: none;
402
- font-size: 0.8em;
403
- }
404
- .footnote-ref a:hover { text-decoration: underline; }
405
- .footnotes ol { padding-left: 1.5em; }
406
- .footnotes li { margin-bottom: 0.5em; font-size: 0.9rem; color: var(--muted-color, #888); }
407
- .footnotes li p { margin: 0; }
408
- .footnote-backref { text-decoration: none; margin-left: 4px; }
409
-
410
- .citation {
411
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
412
- font-size: 11px;
413
- line-height: 15px;
414
- border: 1px solid rgba(0, 0, 0, 0.1);
415
- background: rgba(0, 0, 0, 0.02);
416
- padding: 10px 18px;
417
- border-radius: 3px;
418
- color: rgba(150, 150, 150, 1);
419
- overflow: hidden;
420
- margin-top: -12px;
421
- white-space: pre-wrap;
422
- word-wrap: break-word;
423
- }
424
-
425
- .citation a { color: rgba(0, 0, 0, 0.6); text-decoration: underline; }
426
- .citation.short { margin-top: -4px; }
427
-
428
- .citation-inline {
429
- color: var(--primary-color, #958df1);
430
- text-decoration: none;
431
- text-decoration-line: underline;
432
- text-decoration-color: transparent;
433
- text-underline-offset: 2px;
434
- cursor: pointer;
435
- transition: text-decoration-color 0.15s;
436
- }
437
- .citation-inline:hover {
438
- text-decoration-color: currentColor;
439
- }
440
-
441
- .bibliography-content .csl-entry {
442
- margin-bottom: 0.75em;
443
- padding-left: 1.5em;
444
- text-indent: -1.5em;
445
- font-size: 0.9em;
446
- line-height: 1.5;
447
- }
448
- .bibliography-content .csl-entry:target {
449
- background: rgba(149, 141, 241, 0.1);
450
- border-radius: 4px;
451
- padding: 4px 4px 4px 1.5em;
452
- }
453
-
454
- .references-block .footer-heading { margin: 0; }
455
- .references-block ol { padding: 0 0 0 15px; }
456
- .references-block li { margin-bottom: 1em; }
457
- .references-block a { color: var(--text-color); }
458
-
459
- .footer a {
460
- color: var(--primary-color);
461
- border-bottom: 1px solid var(--link-underline);
462
- text-decoration: none;
463
- }
464
- .footer a:hover {
465
- color: var(--primary-color-hover);
466
- border-bottom-color: var(--link-underline-hover);
467
- }
468
-
469
- .template-credit { display: contents; }
470
- .template-credit p {
471
- grid-column: 2;
472
- margin: 24px 0 0 0;
473
- font-size: 0.85em;
474
- color: rgba(0, 0, 0, 0.5);
475
- }
476
- .template-credit a { color: rgba(0, 0, 0, 0.6); border-bottom: 1px solid rgba(0, 0, 0, 0.15); }
477
- .template-credit a:hover { color: rgba(0, 0, 0, 0.8); border-bottom-color: rgba(0, 0, 0, 0.3); }
478
-
479
- [data-theme="dark"] .footer { border-top-color: rgba(255, 255, 255, 0.15); color: rgba(200, 200, 200, 0.8); }
480
- [data-theme="dark"] .citation { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.15); color: rgba(200, 200, 200, 1); }
481
- [data-theme="dark"] .citation a { color: rgba(255, 255, 255, 0.75); }
482
- [data-theme="dark"] .bibliography-content .csl-entry:target { background: rgba(149, 141, 241, 0.15); }
483
- [data-theme="dark"] .footer a { color: var(--primary-color); }
484
- [data-theme="dark"] .template-credit p { color: rgba(200, 200, 200, 0.6); }
485
- [data-theme="dark"] .template-credit a { color: rgba(200, 200, 200, 0.7); border-bottom-color: rgba(255, 255, 255, 0.2); }
486
- [data-theme="dark"] .template-credit a:hover { color: rgba(200, 200, 200, 0.9); border-bottom-color: rgba(255, 255, 255, 0.35); }
487
-
488
- @media (max-width: 1100px) {
489
- .footer-inner { grid-template-columns: 1fr; gap: 16px; display: block; padding: 40px 16px; }
490
- .footer-inner > .footer-heading { grid-column: auto; margin-top: 16px; }
491
- }
492
-
493
- @media (min-width: 768px) {
494
- .references-block ol { padding: 0 0 0 30px; margin-left: -30px; }
495
- }
496
-
497
- /* PDF download link */
498
- a.pdf-link {
499
- display: inline-flex;
500
- align-items: center;
501
- gap: 0.4em;
502
- color: var(--accent-color, #4493f8);
503
- text-decoration: none;
504
- font-weight: 500;
505
- }
506
- a.pdf-link:hover { text-decoration: underline; }
507
- a.pdf-link::before { content: "\\1F4C4"; }
508
-
509
- /* Image lightbox dialog */
510
- dialog.lightbox { border: none; background: transparent; padding: 0; max-width: 95vw; max-height: 95vh; }
511
- dialog.lightbox::backdrop { background: rgba(0, 0, 0, 0.85); }
512
- dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; border-radius: 4px; }
513
  </style>
514
  </head>
515
  <body>
@@ -944,120 +550,132 @@ function extractBibliographyHtml(json: Record<string, unknown>): string {
944
  }
945
 
946
  /**
947
- * Post-process the raw generateHTML output:
948
- * - Inject bibliography HTML (extracted before generateHTML)
949
  * - Transform accordion divs into <details><summary>
950
- * - Transform htmlEmbed into iframes
 
 
 
 
951
  */
952
  function postProcess(html: string, biblioHtml: string, citationData?: CitationData): string {
953
- let result = html;
954
-
955
- // Accordion: div[data-component="accordion"] <details><summary>
956
- result = result.replace(
957
- /<div[^>]*data-component="accordion"[^>]*>([\s\S]*?)<\/div>/g,
958
- (_match, inner) => {
959
- const titleMatch = inner.match(/data-title="([^"]*)"/);
960
- const openMatch = inner.match(/data-open="true"/);
961
- const title = titleMatch ? titleMatch[1] : "Details";
962
- return `<details data-component="accordion"${openMatch ? " open" : ""}>
963
- <summary>${escapeHtml(title)}</summary>
964
- <div class="accordion-content">${inner}</div>
965
- </details>`;
966
- }
967
- );
 
 
 
 
 
 
 
968
 
969
- // Inline citations: replace <span data-type="citation" ...>label</span>
970
- // with proper numbered links pointing to bibliography entries.
971
  const NUMERIC_STYLES = new Set(["ieee", "vancouver"]);
972
  const isNumeric = citationData ? NUMERIC_STYLES.has(citationData.style) : false;
973
  const citationKeyOrder: string[] = [];
974
 
975
- result = result.replace(
976
- /<span[^>]*data-type="citation"[^>]*>[\s\S]*?<\/span>/g,
977
- (match) => {
978
- const keyMatch = match.match(/(?:\skey="|data-key=")([^"]*)"/);
979
- if (!keyMatch) return match;
980
- const key = keyMatch[1];
981
 
982
- if (!citationKeyOrder.includes(key)) citationKeyOrder.push(key);
983
- const idx = citationKeyOrder.indexOf(key) + 1;
984
 
985
- // Extract the label text from the original HTML
986
- const labelMatch = match.match(/>([^<]*)<\/span>/);
987
- const originalLabel = labelMatch ? labelMatch[1] : `[${key}]`;
988
 
989
- // For numeric styles, always use number; for author-date, keep original label
990
- const displayLabel = isNumeric ? `[${idx}]` : originalLabel;
 
 
 
 
991
 
992
- return `<a href="#ref-${escapeHtml(key)}" class="citation-inline" id="cite-${escapeHtml(key)}-${idx}" title="${escapeHtml(key)}">${displayLabel}</a>`;
993
- }
994
- );
995
-
996
- // Bibliography: replace empty placeholder with the pre-extracted HTML.
997
- // Add id anchors to bibliography entries for citation linking.
998
- result = result.replace(
999
- /<div([^>]*data-type="bibliography"[^>]*)><\/div>/gi,
1000
- (_match, attrs: string) => {
1001
- const cleanAttrs = attrs.replace(/\s*renderedhtml="[^"]*"/i, "");
1002
- if (biblioHtml) {
1003
- // Wrap bibliography HTML with entry anchors
1004
- let enrichedBiblio = biblioHtml;
1005
- if (citationData && citationData.orderedKeys.length > 0) {
1006
- enrichedBiblio = addBibliographyAnchors(biblioHtml, citationData.orderedKeys);
1007
- }
1008
- return `<div${cleanAttrs}><h2 class="bibliography-title">References</h2><div class="bibliography-content">${enrichedBiblio}</div></div>`;
1009
  }
1010
- return `<div${cleanAttrs}><p class="bibliography-empty">No citations</p></div>`;
1011
- }
1012
- );
1013
-
1014
- // Mermaid: div[data-component="mermaid"] → <pre class="mermaid">code</pre>
1015
- result = result.replace(
1016
- /<div[^>]*data-component="mermaid"[^>]*><\/div>/g,
1017
- (match) => {
1018
- const codeMatch = match.match(/data-code="([^"]*)"/);
1019
- const code = codeMatch ? codeMatch[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#039;/g, "'") : "";
1020
- return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
1021
- }
1022
- );
1023
-
1024
- // HtmlEmbed: div[data-component="htmlEmbed"] → iframe with srcdoc
1025
- result = result.replace(
1026
- /<div[^>]*data-component="htmlEmbed"[^>]*data-src="([^"]*)"[^>]*><\/div>/g,
1027
- (_match, src) => {
1028
- return `<div class="html-embed-container">
1029
- <iframe srcdoc="" data-embed-src="${escapeHtml(src)}"
1030
- style="width:100%;border:none;min-height:400px;"
1031
- loading="lazy" sandbox="allow-scripts"></iframe>
1032
- </div>`;
1033
- }
1034
- );
1035
-
1036
- // Footnotes: collect all <span data-type="footnote">, number them,
1037
- // replace inline markers with superscript links, and append a
1038
- // footnotes section at the bottom.
1039
- const footnotes: string[] = [];
1040
- result = result.replace(
1041
- /<span[^>]*data-type="footnote"[^>]*>[\s\S]*?<\/span>/g,
1042
- (match) => {
1043
- const contentMatch = match.match(/(?:data-content|title)="([^"]*)"/);
1044
- const raw = contentMatch ? contentMatch[1] : "";
1045
- const text = raw
1046
- .replace(/&amp;/g, "&").replace(/&lt;/g, "<")
1047
- .replace(/&gt;/g, ">").replace(/&quot;/g, '"')
1048
- .replace(/&#039;/g, "'");
1049
- footnotes.push(text);
1050
- const idx = footnotes.length;
1051
- return `<sup class="footnote-ref"><a href="#fn-${idx}" id="fnref-${idx}">[${idx}]</a></sup>`;
1052
  }
1053
- );
 
 
 
 
 
 
 
 
 
1054
 
1055
- if (footnotes.length > 0) {
1056
- const items = footnotes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  .map((text, i) => `<li id="fn-${i + 1}"><p>${escapeHtml(text)} <a href="#fnref-${i + 1}" class="footnote-backref" aria-label="Back to text">\u21A9</a></p></li>`)
1058
  .join("\n");
1059
- const footnotesSection = `<section class="footnotes"><h2>Footnotes</h2><ol>${items}</ol></section>`;
1060
- result += footnotesSection;
1061
  }
1062
 
1063
  return result;
@@ -1066,18 +684,16 @@ function postProcess(html: string, biblioHtml: string, citationData?: CitationDa
1066
  /**
1067
  * Add id anchors to bibliography entries so inline citations can link to them.
1068
  * citation-js outputs entries as <div class="csl-entry"> elements.
1069
- * We wrap each one with an id matching the citation key.
1070
  */
1071
  function addBibliographyAnchors(html: string, orderedKeys: string[]): string {
1072
- let idx = 0;
1073
- return html.replace(
1074
- /<div class="csl-entry">/g,
1075
- () => {
1076
- const key = orderedKeys[idx] || `entry-${idx}`;
1077
- idx++;
1078
- return `<div class="csl-entry" id="ref-${key}">`;
1079
- }
1080
- );
1081
  }
1082
 
1083
  function escapeHtml(str: string): string {
 
6
  */
7
 
8
  import { generateHTML } from "@tiptap/html";
9
+ import { parseHTML } from "linkedom";
10
  import { getServerExtensions } from "./extensions.js";
11
  import type { PublishCSS } from "./index.js";
12
 
 
106
  </script>
107
 
108
  <style>
 
109
  ${css.variables}
110
  ${css.reset}
 
 
 
 
 
111
  ${css.editorTokens}
112
  ${css.base}
113
  ${css.layout}
114
  ${css.components}
115
  ${css.article}
116
  ${css.print}
117
+ ${css.publisher}
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  </style>
120
  </head>
121
  <body>
 
550
  }
551
 
552
  /**
553
+ * Post-process the raw generateHTML output using linkedom DOM manipulation:
 
554
  * - Transform accordion divs into <details><summary>
555
+ * - Convert inline citation spans into anchor links
556
+ * - Inject bibliography HTML with entry anchors
557
+ * - Transform mermaid divs into <pre class="mermaid">
558
+ * - Transform htmlEmbed divs into iframes
559
+ * - Collect footnotes and append a footnotes section
560
  */
561
  function postProcess(html: string, biblioHtml: string, citationData?: CitationData): string {
562
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
563
+
564
+ // 1. Accordion: div[data-component="accordion"] -> <details><summary>
565
+ for (const div of [...document.querySelectorAll('div[data-component="accordion"]')]) {
566
+ const title = div.getAttribute("title") || "Details";
567
+ const isOpen = div.getAttribute("open") === "true";
568
+
569
+ const details = document.createElement("details");
570
+ details.setAttribute("data-component", "accordion");
571
+ if (isOpen) details.setAttribute("open", "");
572
+
573
+ const summary = document.createElement("summary");
574
+ summary.textContent = title;
575
+ details.appendChild(summary);
576
+
577
+ const content = document.createElement("div");
578
+ content.className = "accordion-content";
579
+ while (div.firstChild) content.appendChild(div.firstChild);
580
+ details.appendChild(content);
581
+
582
+ div.replaceWith(details);
583
+ }
584
 
585
+ // 2. Inline citations: span[data-type="citation"] -> <a> links
 
586
  const NUMERIC_STYLES = new Set(["ieee", "vancouver"]);
587
  const isNumeric = citationData ? NUMERIC_STYLES.has(citationData.style) : false;
588
  const citationKeyOrder: string[] = [];
589
 
590
+ for (const span of [...document.querySelectorAll('span[data-type="citation"]')]) {
591
+ const key = span.getAttribute("key") || span.getAttribute("data-key");
592
+ if (!key) continue;
 
 
 
593
 
594
+ if (!citationKeyOrder.includes(key)) citationKeyOrder.push(key);
595
+ const idx = citationKeyOrder.indexOf(key) + 1;
596
 
597
+ const originalLabel = span.textContent || `[${key}]`;
598
+ const displayLabel = isNumeric ? `[${idx}]` : originalLabel;
 
599
 
600
+ const a = document.createElement("a");
601
+ a.setAttribute("href", `#ref-${key}`);
602
+ a.className = "citation-inline";
603
+ a.setAttribute("id", `cite-${key}-${idx}`);
604
+ a.setAttribute("title", key);
605
+ a.textContent = displayLabel;
606
 
607
+ span.replaceWith(a);
608
+ }
609
+
610
+ // 3. Bibliography: inject pre-formatted HTML into the placeholder
611
+ for (const div of [...document.querySelectorAll('div[data-type="bibliography"]')]) {
612
+ div.removeAttribute("renderedhtml");
613
+
614
+ if (biblioHtml) {
615
+ let enrichedBiblio = biblioHtml;
616
+ if (citationData && citationData.orderedKeys.length > 0) {
617
+ enrichedBiblio = addBibliographyAnchors(biblioHtml, citationData.orderedKeys);
 
 
 
 
 
 
618
  }
619
+ div.innerHTML = `<h2 class="bibliography-title">References</h2><div class="bibliography-content">${enrichedBiblio}</div>`;
620
+ } else {
621
+ div.innerHTML = `<p class="bibliography-empty">No citations</p>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  }
623
+ }
624
+
625
+ // 4. Mermaid: div[data-component="mermaid"] -> <pre class="mermaid">
626
+ for (const div of [...document.querySelectorAll('div[data-component="mermaid"]')]) {
627
+ const code = div.getAttribute("code") || div.getAttribute("data-code") || "";
628
+ const pre = document.createElement("pre");
629
+ pre.className = "mermaid";
630
+ pre.textContent = code;
631
+ div.replaceWith(pre);
632
+ }
633
 
634
+ // 5. HtmlEmbed: div[data-component="htmlEmbed"] -> iframe with srcdoc
635
+ for (const div of [...document.querySelectorAll('div[data-component="htmlEmbed"]')]) {
636
+ const src = div.getAttribute("src") || div.getAttribute("data-src") || "";
637
+
638
+ const container = document.createElement("div");
639
+ container.className = "html-embed-container";
640
+
641
+ const iframe = document.createElement("iframe");
642
+ iframe.setAttribute("srcdoc", "");
643
+ iframe.setAttribute("data-embed-src", src);
644
+ iframe.setAttribute("style", "width:100%;border:none;min-height:400px;");
645
+ iframe.setAttribute("loading", "lazy");
646
+ iframe.setAttribute("sandbox", "allow-scripts");
647
+ container.appendChild(iframe);
648
+
649
+ div.replaceWith(container);
650
+ }
651
+
652
+ // 6. Footnotes: collect span[data-type="footnote"], replace with superscript links
653
+ const footnoteSpans = [...document.querySelectorAll('span[data-type="footnote"]')];
654
+ const footnoteTexts: string[] = [];
655
+
656
+ for (const span of footnoteSpans) {
657
+ const text = span.getAttribute("content") || span.getAttribute("data-content") || span.getAttribute("title") || "";
658
+ footnoteTexts.push(text);
659
+ const idx = footnoteTexts.length;
660
+
661
+ const sup = document.createElement("sup");
662
+ sup.className = "footnote-ref";
663
+ const a = document.createElement("a");
664
+ a.setAttribute("href", `#fn-${idx}`);
665
+ a.setAttribute("id", `fnref-${idx}`);
666
+ a.textContent = `[${idx}]`;
667
+ sup.appendChild(a);
668
+
669
+ span.replaceWith(sup);
670
+ }
671
+
672
+ let result = document.body.innerHTML;
673
+
674
+ if (footnoteTexts.length > 0) {
675
+ const items = footnoteTexts
676
  .map((text, i) => `<li id="fn-${i + 1}"><p>${escapeHtml(text)} <a href="#fnref-${i + 1}" class="footnote-backref" aria-label="Back to text">\u21A9</a></p></li>`)
677
  .join("\n");
678
+ result += `<section class="footnotes"><h2>Footnotes</h2><ol>${items}</ol></section>`;
 
679
  }
680
 
681
  return result;
 
684
  /**
685
  * Add id anchors to bibliography entries so inline citations can link to them.
686
  * citation-js outputs entries as <div class="csl-entry"> elements.
687
+ * Uses linkedom for reliable DOM manipulation.
688
  */
689
  function addBibliographyAnchors(html: string, orderedKeys: string[]): string {
690
+ const { document } = parseHTML(`<!DOCTYPE html><html><body>${html}</body></html>`);
691
+ const entries = document.querySelectorAll(".csl-entry");
692
+ entries.forEach((entry, idx) => {
693
+ const key = orderedKeys[idx] || `entry-${idx}`;
694
+ entry.setAttribute("id", `ref-${key}`);
695
+ });
696
+ return document.body.innerHTML;
 
 
697
  }
698
 
699
  function escapeHtml(str: string): string {
backend/src/publisher/index.ts CHANGED
@@ -32,6 +32,7 @@ export interface PublishCSS {
32
  editorTokens: string;
33
  article: string;
34
  components: string;
 
35
  }
36
 
37
  /**
@@ -82,7 +83,6 @@ function loadCSS(): PublishCSS {
82
  .map((f) => readSafe(join(dir, "components", f)))
83
  .join("\n");
84
 
85
- // Read all CSS, resolve @custom-media, then split back
86
  const variablesRaw = readFileSync(varsPath, "utf-8");
87
  const resetRaw = readSafe(join(dir, "_reset.css"));
88
  const baseRaw = readFileSync(basePath, "utf-8");
@@ -90,9 +90,10 @@ function loadCSS(): PublishCSS {
90
  const printRaw = readSafe(join(dir, "_print.css"));
91
  const editorTokensRaw = readSafe(join(dir, "_editor-tokens.css"));
92
  const articleRaw = readSafe(join(dir, "article.css"));
 
93
 
94
  // @custom-media declarations live in _variables.css - resolve across all files
95
- const allRaw = [variablesRaw, resetRaw, baseRaw, layoutRaw, printRaw, editorTokensRaw, articleRaw, componentsCss].join("\n/* __CSS_BOUNDARY__ */\n");
96
  const resolved = resolveCustomMedia(allRaw);
97
  const parts = resolved.split("/* __CSS_BOUNDARY__ */");
98
 
@@ -105,12 +106,13 @@ function loadCSS(): PublishCSS {
105
  editorTokens: parts[5]?.trim() ?? "",
106
  article: parts[6]?.trim() ?? "",
107
  components: parts[7]?.trim() ?? "",
 
108
  };
109
  }
110
  }
111
 
112
  console.warn("[publish] CSS files not found, tried:", candidates);
113
- return { variables: "", reset: "", base: "", layout: "", print: "", editorTokens: "", article: "", components: "" };
114
  }
115
 
116
  interface AuthorObj {
@@ -234,6 +236,54 @@ export interface PublishResult {
234
  error?: string;
235
  }
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  /**
238
  * Run the full publish pipeline for a document.
239
  */
 
32
  editorTokens: string;
33
  article: string;
34
  components: string;
35
+ publisher: string;
36
  }
37
 
38
  /**
 
83
  .map((f) => readSafe(join(dir, "components", f)))
84
  .join("\n");
85
 
 
86
  const variablesRaw = readFileSync(varsPath, "utf-8");
87
  const resetRaw = readSafe(join(dir, "_reset.css"));
88
  const baseRaw = readFileSync(basePath, "utf-8");
 
90
  const printRaw = readSafe(join(dir, "_print.css"));
91
  const editorTokensRaw = readSafe(join(dir, "_editor-tokens.css"));
92
  const articleRaw = readSafe(join(dir, "article.css"));
93
+ const publisherRaw = readSafe(join(dir, "_publisher.css"));
94
 
95
  // @custom-media declarations live in _variables.css - resolve across all files
96
+ const allRaw = [variablesRaw, resetRaw, baseRaw, layoutRaw, printRaw, editorTokensRaw, articleRaw, componentsCss, publisherRaw].join("\n/* __CSS_BOUNDARY__ */\n");
97
  const resolved = resolveCustomMedia(allRaw);
98
  const parts = resolved.split("/* __CSS_BOUNDARY__ */");
99
 
 
106
  editorTokens: parts[5]?.trim() ?? "",
107
  article: parts[6]?.trim() ?? "",
108
  components: parts[7]?.trim() ?? "",
109
+ publisher: parts[8]?.trim() ?? "",
110
  };
111
  }
112
  }
113
 
114
  console.warn("[publish] CSS files not found, tried:", candidates);
115
+ return { variables: "", reset: "", base: "", layout: "", print: "", editorTokens: "", article: "", components: "", publisher: "" };
116
  }
117
 
118
  interface AuthorObj {
 
236
  error?: string;
237
  }
238
 
239
+ /**
240
+ * Render a preview HTML of the document without saving or uploading anything.
241
+ * Useful for testing the publisher pipeline.
242
+ */
243
+ export async function previewDocument(docName: string): Promise<{ html: string } | { error: string }> {
244
+ const p = docPath(docName);
245
+ if (!existsSync(p)) return { error: "Document not found" };
246
+
247
+ const ydoc = new Y.Doc();
248
+ const data = readFileSync(p);
249
+ Y.applyUpdate(ydoc, new Uint8Array(data));
250
+
251
+ const { json, frontmatter, authors, affiliations } = extractFromYDoc(ydoc);
252
+ const citationData = extractCitationsFromYDoc(ydoc, json);
253
+
254
+ const meta: PublishMeta = {
255
+ title: (frontmatter.title as string) || docName,
256
+ subtitle: (frontmatter.subtitle as string) || undefined,
257
+ description: (frontmatter.description as string) || "",
258
+ authors: authors.map((a) => ({
259
+ name: a.name,
260
+ url: a.url,
261
+ affiliationIndices: a.affiliations || [],
262
+ affiliationNames: (a.affiliations || [])
263
+ .map((idx) => affiliations[idx - 1]?.name)
264
+ .filter(Boolean) as string[],
265
+ })),
266
+ affiliations: affiliations.map((a) => ({ name: a.name, url: a.url })),
267
+ date: (frontmatter.published as string) || (frontmatter.date as string) || new Date().toISOString(),
268
+ doi: (frontmatter.doi as string) || undefined,
269
+ licence: (frontmatter.licence as string) || (frontmatter.license as string) || undefined,
270
+ };
271
+
272
+ const css = loadCSS();
273
+
274
+ let biblioHtml = "";
275
+ if (citationData.entries.length > 0) {
276
+ try {
277
+ biblioHtml = await formatBibliographyServer(citationData.entries, citationData.style);
278
+ } catch (err) {
279
+ console.error("[preview] bibliography formatting failed:", err);
280
+ }
281
+ }
282
+
283
+ const html = renderArticleHTML(json, meta, css, citationData, biblioHtml);
284
+ return { html };
285
+ }
286
+
287
  /**
288
  * Run the full publish pipeline for a document.
289
  */
backend/src/server.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
  flushAll,
24
  } from "./hf-storage.js";
25
  import { resolveUser, extractToken, isOAuthEnabled, handleOAuthAuthorize, handleOAuthCallback } from "./auth.js";
26
- import { publishDocument } from "./publisher/index.js";
27
  import { DATA_DIR, docPath, sanitizeName } from "./utils.js";
28
 
29
  const PORT = parseInt(process.env.PORT || "8080", 10);
@@ -222,6 +222,42 @@ app.post("/api/chat", handleChat);
222
 
223
  app.use("/api/citations", citationsRouter);
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  // ---------- Publish ----------
226
 
227
  app.post("/api/publish", async (req, res) => {
 
23
  flushAll,
24
  } from "./hf-storage.js";
25
  import { resolveUser, extractToken, isOAuthEnabled, handleOAuthAuthorize, handleOAuthCallback } from "./auth.js";
26
+ import { publishDocument, previewDocument } from "./publisher/index.js";
27
  import { DATA_DIR, docPath, sanitizeName } from "./utils.js";
28
 
29
  const PORT = parseInt(process.env.PORT || "8080", 10);
 
222
 
223
  app.use("/api/citations", citationsRouter);
224
 
225
+ // ---------- Preview (render without saving) ----------
226
+
227
+ app.get("/api/preview/:docName", async (req, res) => {
228
+ const docName = req.params.docName || DEFAULT_DOC_NAME;
229
+
230
+ if (oauthEnabled) {
231
+ const token = extractToken(req.headers.cookie);
232
+ const user = await resolveUser(token);
233
+ if (!user || !user.canEdit) {
234
+ res.status(403).json({ error: "Unauthorized" });
235
+ return;
236
+ }
237
+ }
238
+
239
+ try {
240
+ // Flush the live Hocuspocus doc to disk first
241
+ const conn = await hocuspocus.openDirectConnection(docName);
242
+ if (conn.document) {
243
+ const update = Y.encodeStateAsUpdate(conn.document);
244
+ writeFileSync(docPath(docName), Buffer.from(update));
245
+ }
246
+ await conn.disconnect();
247
+
248
+ const result = await previewDocument(docName);
249
+ if ("error" in result) {
250
+ res.status(404).json({ error: result.error });
251
+ return;
252
+ }
253
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
254
+ res.send(result.html);
255
+ } catch (err: any) {
256
+ console.error("[preview] error:", err);
257
+ res.status(500).json({ error: err.message || "Preview failed" });
258
+ }
259
+ });
260
+
261
  // ---------- Publish ----------
262
 
263
  app.post("/api/publish", async (req, res) => {
backend/src/shared/component-defs.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // Shared component definitions
3
+ //
4
+ // Single source of truth for the MDX component registry.
5
+ // Both the frontend editor and the backend publisher import this file.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface SharedField {
9
+ name: string;
10
+ type: "string" | "boolean" | "select";
11
+ default?: unknown;
12
+ options?: string[];
13
+ }
14
+
15
+ export interface SharedComponentDef {
16
+ name: string;
17
+ kind: "wrapper" | "atomic";
18
+ fields: SharedField[];
19
+ /** ProseMirror content expression (wrapper only, defaults to "block+") */
20
+ content?: string;
21
+ }
22
+
23
+ export const SHARED_COMPONENT_DEFS: SharedComponentDef[] = [
24
+ {
25
+ name: "accordion",
26
+ kind: "wrapper",
27
+ fields: [
28
+ { name: "title", type: "string", default: "Details" },
29
+ { name: "open", type: "boolean", default: false },
30
+ ],
31
+ },
32
+ {
33
+ name: "note",
34
+ kind: "wrapper",
35
+ fields: [
36
+ { name: "title", type: "string", default: "" },
37
+ { name: "emoji", type: "string", default: "" },
38
+ { name: "variant", type: "select", default: "neutral", options: ["neutral", "info", "success", "danger"] },
39
+ ],
40
+ },
41
+ {
42
+ name: "quoteBlock",
43
+ kind: "wrapper",
44
+ fields: [
45
+ { name: "author", type: "string", default: "" },
46
+ { name: "source", type: "string", default: "" },
47
+ ],
48
+ },
49
+ { name: "wide", kind: "wrapper", fields: [] },
50
+ { name: "fullWidth", kind: "wrapper", fields: [] },
51
+ { name: "sidenote", kind: "wrapper", fields: [] },
52
+ {
53
+ name: "reference",
54
+ kind: "wrapper",
55
+ fields: [
56
+ { name: "id", type: "string", default: "" },
57
+ { name: "caption", type: "string", default: "" },
58
+ ],
59
+ },
60
+ {
61
+ name: "htmlEmbed",
62
+ kind: "atomic",
63
+ fields: [
64
+ { name: "src", type: "string", default: "" },
65
+ { name: "title", type: "string", default: "" },
66
+ { name: "desc", type: "string", default: "" },
67
+ { name: "wide", type: "boolean", default: false },
68
+ { name: "downloadable", type: "boolean", default: false },
69
+ ],
70
+ },
71
+ {
72
+ name: "hfUser",
73
+ kind: "atomic",
74
+ fields: [
75
+ { name: "username", type: "string", default: "" },
76
+ { name: "name", type: "string", default: "" },
77
+ { name: "url", type: "string", default: "" },
78
+ ],
79
+ },
80
+ {
81
+ name: "rawHtml",
82
+ kind: "atomic",
83
+ fields: [
84
+ { name: "html", type: "string", default: "" },
85
+ ],
86
+ },
87
+ {
88
+ name: "mermaid",
89
+ kind: "atomic",
90
+ fields: [
91
+ { name: "code", type: "string", default: "" },
92
+ ],
93
+ },
94
+ ];
backend/tests/__snapshots__/html-renderer-snapshot.test.ts.snap ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`snapshot - full render > matches snapshot for a typical article 1`] = `
4
+ "<!DOCTYPE html>
5
+ <html lang="en" data-theme="dark">
6
+ <head>
7
+ <meta charset="UTF-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
9
+ <title>Test Article</title>
10
+ <meta name="description" content="A test article">
11
+ <meta name="author" content="Alice">
12
+
13
+ <!-- Open Graph -->
14
+ <meta property="og:type" content="article">
15
+ <meta property="og:title" content="Test Article">
16
+ <meta property="og:description" content="A test article">
17
+
18
+ <meta property="article:published_time" content="2025-01-01">
19
+ <meta property="article:author" content="Alice">
20
+
21
+ <!-- Twitter Card -->
22
+ <meta name="twitter:card" content="summary_large_image">
23
+ <meta name="twitter:title" content="Test Article">
24
+ <meta name="twitter:description" content="A test article">
25
+
26
+
27
+ <!-- KaTeX CSS -->
28
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css"
29
+ integrity="sha384-zh0CIslj3dQfMxK3pZnPJAb3bprHitCE2vBz9TyOTICAn3fso5GYa90qPNMBclov"
30
+ crossorigin="anonymous">
31
+
32
+ <!-- highlight.js for code syntax highlighting -->
33
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css">
34
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
35
+ <script>hljs.highlightAll();</script>
36
+
37
+ <!-- Mermaid (renders <pre class="mermaid"> blocks) -->
38
+ <script type="module">
39
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
40
+ mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
41
+ </script>
42
+
43
+ <style>
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <button id="theme-toggle" aria-label="Toggle color theme">
58
+ <span class="icon-wrapper">
59
+ <svg class="icon icon--sun" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
60
+ <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="23"/><line x1="1" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="23" y2="12"/><line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/><line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/><line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/><line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
61
+ </svg>
62
+ <svg class="icon icon--moon" style="opacity:0" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
63
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
64
+ </svg>
65
+ </span>
66
+ </button>
67
+
68
+ <!-- Mobile TOC toggle -->
69
+ <button class="toc-mobile-toggle" aria-label="Open table of contents" aria-expanded="false">
70
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
71
+ <line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
72
+ </svg>
73
+ </button>
74
+ <div class="toc-mobile-backdrop" aria-hidden="true"></div>
75
+ <aside class="toc-mobile-sidebar" aria-label="Table of Contents">
76
+ <div class="toc-mobile-sidebar__header">
77
+ <span class="toc-mobile-sidebar__title">Table of Contents</span>
78
+ <button class="toc-mobile-sidebar__close" aria-label="Close">
79
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
80
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
81
+ </svg>
82
+ </button>
83
+ </div>
84
+ <div class="toc-mobile-sidebar__body" id="toc-mobile-placeholder"></div>
85
+ </aside>
86
+
87
+ <!-- Hero -->
88
+ <section class="hero">
89
+ <h1 class="hero-title">Test Article</h1>
90
+
91
+ </section>
92
+
93
+ <!-- Meta bar -->
94
+ <header class="meta"><div class="meta-container"><div class="meta-container-cell"><h3>Authors</h3><ul class="authors"><li>Alice</li></ul></div><div class="meta-container-cell"><h3>Affiliations</h3><p>MIT</p></div><div class="meta-container-cell"><h3>Published</h3><p>January 1, 2025</p></div></div></header>
95
+
96
+ <section class="content-grid">
97
+ <nav class="table-of-contents" aria-label="Table of Contents">
98
+ <div class="title">Table of Contents</div>
99
+ <div id="toc-placeholder"></div>
100
+ </nav>
101
+
102
+ <main>
103
+ <div class="tiptap">
104
+ <h2>Introduction</h2><p>This is a test article with a <a title="ref1" id="cite-ref1-1" class="citation-inline" href="#ref-ref1">Ref (2024)</a> citation.</p><p>A footnote here<sup class="footnote-ref"><a id="fnref-1" href="#fn-1">[1]</a></sup></p><div data-type="bibliography" class="bibliography-block"><h2 class="bibliography-title">References</h2><div class="bibliography-content"><div id="ref-ref1" class="csl-entry">Reference One. 2024.</div></div></div><section class="footnotes"><h2>Footnotes</h2><ol><li id="fn-1"><p>Important detail <a href="#fnref-1" class="footnote-backref" aria-label="Back to text">↩</a></p></li></ol></section>
105
+ </div>
106
+ </main>
107
+ </section>
108
+
109
+ <!-- Footer -->
110
+ <footer class="footer"><div class="footer-inner">
111
+ <section class="citation-block">
112
+ <p class="footer-heading" role="heading" aria-level="2">Citation</p>
113
+ <p>For attribution in academic contexts, please cite this work as</p>
114
+ <pre class="citation short">Alice (2025). &quot;Test Article&quot;.</pre>
115
+ <p>BibTeX citation</p>
116
+ <pre class="citation long">@misc{alice2025_test_article,
117
+ title={Test Article},
118
+ author={Alice},
119
+ year={2025}
120
+ }</pre>
121
+ </section>
122
+ <section class="references-block"></section>
123
+ <div class="template-credit">
124
+ <p>Made with &#10084;&#65039; with <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">research article template</a></p>
125
+ </div></div></footer>
126
+
127
+ <!-- Image lightbox -->
128
+ <dialog class="lightbox" id="lightbox">
129
+ <img id="lightbox-img" src="" alt="">
130
+ </dialog>
131
+
132
+ <script>
133
+ (function() {
134
+ // Theme toggle (matches template ThemeToggle.astro)
135
+ var btn = document.getElementById('theme-toggle');
136
+ var sunIcon = btn && btn.querySelector('.icon--sun');
137
+ var moonIcon = btn && btn.querySelector('.icon--moon');
138
+ var wrapper = btn && btn.querySelector('.icon-wrapper');
139
+ var media = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
140
+ var prefersDark = media && media.matches;
141
+ var saved = localStorage.getItem('theme');
142
+
143
+ function applyTheme(mode) {
144
+ document.documentElement.dataset.theme = mode;
145
+ if (sunIcon && moonIcon) {
146
+ sunIcon.style.opacity = mode === 'dark' ? '0' : '1';
147
+ moonIcon.style.opacity = mode === 'dark' ? '1' : '0';
148
+ }
149
+ }
150
+ applyTheme(saved || (prefersDark ? 'dark' : 'light'));
151
+ requestAnimationFrame(function() { if (wrapper) wrapper.classList.add('animated'); });
152
+
153
+ if (!saved && media) {
154
+ var syncSystem = function(e) { applyTheme(e.matches ? 'dark' : 'light'); };
155
+ if (media.addEventListener) media.addEventListener('change', syncSystem);
156
+ }
157
+
158
+ if (btn) {
159
+ btn.addEventListener('click', function() {
160
+ var next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
161
+ localStorage.setItem('theme', next);
162
+ if (wrapper) {
163
+ var cls = next === 'dark' ? 'spin-cw' : 'spin-ccw';
164
+ wrapper.classList.remove('spin-cw', 'spin-ccw');
165
+ void wrapper.offsetWidth;
166
+ wrapper.classList.add(cls);
167
+ wrapper.addEventListener('animationend', function() { wrapper.classList.remove(cls); }, { once: true });
168
+ }
169
+ applyTheme(next);
170
+ });
171
+ }
172
+
173
+ // Lightbox
174
+ document.querySelectorAll('.tiptap img').forEach(function(img) {
175
+ img.style.cursor = 'zoom-in';
176
+ img.addEventListener('click', function() {
177
+ var lbImg = document.getElementById('lightbox-img');
178
+ var lbDlg = document.getElementById('lightbox');
179
+ if (lbImg) lbImg.src = img.src;
180
+ if (lbDlg && lbDlg.showModal) lbDlg.showModal();
181
+ });
182
+ });
183
+ var lb = document.getElementById('lightbox');
184
+ if (lb) lb.addEventListener('click', function(e) { if (e.target === this) this.close(); });
185
+
186
+ // Glossary
187
+ document.querySelectorAll('[data-type="glossary"]').forEach(function(el) { el.setAttribute('tabindex', '0'); });
188
+
189
+ // ---- Table of Contents ----
190
+ var holder = document.getElementById('toc-placeholder');
191
+ var holderMobile = document.getElementById('toc-mobile-placeholder');
192
+ var articleRoot = document.querySelector('.content-grid main');
193
+ if (!articleRoot) return;
194
+ var headings = articleRoot.querySelectorAll('h2, h3, h4');
195
+ if (!headings.length) return;
196
+ var headingsArr = Array.from(headings);
197
+
198
+ // Unique IDs
199
+ var usedIds = {};
200
+ var slugify = function(s) { return String(s||'').toLowerCase().trim().replace(/\\s+/g,'_').replace(/[^a-z0-9_-]/g,''); };
201
+ headingsArr.forEach(function(h) {
202
+ var id = (h.id||'').trim() || slugify(h.textContent) || 'section';
203
+ var c = id, n = 2;
204
+ while (usedIds[c]) c = id+'-'+(n++);
205
+ if (h.id !== c) h.id = c;
206
+ usedIds[c] = true;
207
+ });
208
+
209
+ // Build nested nav with data-heading-idx
210
+ var nav = document.createElement('nav');
211
+ var ulStack = [document.createElement('ul')];
212
+ nav.appendChild(ulStack[0]);
213
+ var levelOf = function(tag) { return tag==='H2'?2:tag==='H3'?3:4; };
214
+ var prev = 2, hIdx = 0;
215
+ headingsArr.forEach(function(h) {
216
+ var lvl = levelOf(h.tagName);
217
+ while (lvl > prev) { var ul = document.createElement('ul'); var last = ulStack[ulStack.length-1].lastElementChild; if (last) last.appendChild(ul); ulStack.push(ul); prev++; }
218
+ while (lvl < prev) { ulStack.pop(); prev--; }
219
+ var li = document.createElement('li');
220
+ var a = document.createElement('a');
221
+ a.href = '#'+h.id; a.textContent = h.textContent;
222
+ li.appendChild(a);
223
+ li.setAttribute('data-heading-idx', String(hIdx++));
224
+ ulStack[ulStack.length-1].appendChild(li);
225
+ });
226
+
227
+ if (holder) holder.appendChild(nav);
228
+ var navClone = nav.cloneNode(true);
229
+ if (holderMobile) holderMobile.appendChild(navClone);
230
+
231
+ // Mark navs as collapsible
232
+ nav.classList.add('toc-collapsible');
233
+ navClone.classList.add('toc-collapsible');
234
+
235
+ var allLinks = [].concat(
236
+ holder ? Array.from(holder.querySelectorAll('a')) : [],
237
+ holderMobile ? Array.from(holderMobile.querySelectorAll('a')) : []
238
+ );
239
+
240
+ // Click handler for smooth scroll with offset
241
+ var SCROLL_OFFSET = 80;
242
+ allLinks.forEach(function(link) {
243
+ link.addEventListener('click', function(e) {
244
+ var href = link.getAttribute('href');
245
+ if (!href || href.charAt(0) !== '#') return;
246
+ var el = document.getElementById(href.slice(1));
247
+ if (!el) return;
248
+ e.preventDefault();
249
+ var top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
250
+ window.scrollTo({ top: top, behavior: 'smooth' });
251
+ history.pushState(null, '', href);
252
+ });
253
+ });
254
+
255
+ // ---- Auto-collapse logic ----
256
+ var isMobile = window.matchMedia('(max-width: 1100px)');
257
+
258
+ function getItemsWithChildren(navEl) {
259
+ if (!navEl) return [];
260
+ return Array.from(navEl.querySelectorAll('li[data-heading-idx]')).filter(function(li) {
261
+ return li.querySelector(':scope > ul');
262
+ });
263
+ }
264
+
265
+ function getAncestorIndices(items, targetIdx) {
266
+ var toExpand = {};
267
+ var activeLi = null;
268
+ function find(li) {
269
+ if (Number(li.getAttribute('data-heading-idx')) === targetIdx) return li;
270
+ var ul = li.querySelector(':scope > ul');
271
+ if (!ul) return null;
272
+ var children = ul.querySelectorAll(':scope > li[data-heading-idx]');
273
+ for (var i = 0; i < children.length; i++) { var f = find(children[i]); if (f) return f; }
274
+ return null;
275
+ }
276
+ for (var i = 0; i < items.length; i++) { activeLi = find(items[i]); if (activeLi) break; }
277
+ if (!activeLi) return toExpand;
278
+ toExpand[targetIdx] = true;
279
+ var cur = activeLi;
280
+ while (cur) {
281
+ var parent = cur.parentElement ? cur.parentElement.closest('li[data-heading-idx]') : null;
282
+ if (parent) { toExpand[Number(parent.getAttribute('data-heading-idx'))] = true; cur = parent; }
283
+ else break;
284
+ }
285
+ return toExpand;
286
+ }
287
+
288
+ function applyCollapseState(navEl, activeIdx) {
289
+ if (!navEl) return;
290
+ var items = getItemsWithChildren(navEl);
291
+ var ancestors = getAncestorIndices(items, activeIdx);
292
+
293
+ items.forEach(function(li) {
294
+ var sub = li.querySelector(':scope > ul');
295
+ if (!sub) return;
296
+ var idx = Number(li.getAttribute('data-heading-idx'));
297
+ var allDesc = li.querySelectorAll('li[data-heading-idx]');
298
+ var related = [idx];
299
+ allDesc.forEach(function(d) { related.push(Number(d.getAttribute('data-heading-idx'))); });
300
+ var shouldExpand = related.some(function(i) { return ancestors[i]; });
301
+
302
+ if (shouldExpand) {
303
+ li.classList.remove('collapsed');
304
+ sub.style.height = 'auto';
305
+ } else {
306
+ li.classList.add('collapsed');
307
+ sub.style.height = '0px';
308
+ }
309
+ });
310
+ }
311
+
312
+ function expandAll(navEl) {
313
+ if (!navEl) return;
314
+ getItemsWithChildren(navEl).forEach(function(li) {
315
+ li.classList.remove('collapsed');
316
+ var sub = li.querySelector(':scope > ul');
317
+ if (sub) sub.style.height = 'auto';
318
+ });
319
+ }
320
+
321
+ // Initial state: collapse all except first section
322
+ function initCollapse() {
323
+ if (isMobile.matches) {
324
+ expandAll(holder ? holder.querySelector('nav') : null);
325
+ expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
326
+ } else {
327
+ applyCollapseState(holder ? holder.querySelector('nav') : null, 0);
328
+ applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, 0);
329
+ }
330
+ }
331
+ initCollapse();
332
+
333
+ isMobile.addEventListener('change', function() {
334
+ if (isMobile.matches) {
335
+ expandAll(holder ? holder.querySelector('nav') : null);
336
+ expandAll(holderMobile ? holderMobile.querySelector('nav') : null);
337
+ } else {
338
+ applyCollapseState(holder ? holder.querySelector('nav') : null, prevActiveIdx);
339
+ applyCollapseState(holderMobile ? holderMobile.querySelector('nav') : null, prevActiveIdx);
340
+ }
341
+ });
342
+
343
+ // ---- Scroll tracking ----
344
+ var OFFSET = 60, lastScrollTime = 0, prevActiveIdx = 0;
345
+
346
+ function onScroll() {
347
+ var now = performance.now();
348
+ if (now - lastScrollTime < 50) return;
349
+ lastScrollTime = now;
350
+ requestAnimationFrame(function() {
351
+ var activeIdx = -1, activeId = null;
352
+ for (var i = headingsArr.length - 1; i >= 0; i--) {
353
+ if (headingsArr[i].getBoundingClientRect().top - OFFSET <= 0) {
354
+ activeIdx = i; activeId = headingsArr[i].id; break;
355
+ }
356
+ }
357
+ allLinks.forEach(function(l) {
358
+ if (activeId && l.getAttribute('href') === '#'+activeId) l.classList.add('active');
359
+ else l.classList.remove('active');
360
+ });
361
+ if (activeIdx !== prevActiveIdx && activeIdx >= 0 && !isMobile.matches) {
362
+ prevActiveIdx = activeIdx;
363
+ applyCollapseState(holder ? holder.querySelector('nav') : null, activeIdx);
364
+ }
365
+ if (activeIdx >= 0) prevActiveIdx = activeIdx;
366
+ });
367
+ }
368
+ window.addEventListener('scroll', onScroll, { passive: true });
369
+ onScroll();
370
+
371
+ // ---- Mobile sidebar ----
372
+ var sidebar = document.querySelector('.toc-mobile-sidebar');
373
+ var backdrop = document.querySelector('.toc-mobile-backdrop');
374
+ var toggleBtn = document.querySelector('.toc-mobile-toggle');
375
+ var closeBtn = document.querySelector('.toc-mobile-sidebar__close');
376
+
377
+ function openSidebar() {
378
+ sidebar.classList.add('open'); backdrop.classList.add('open');
379
+ toggleBtn.setAttribute('aria-expanded','true');
380
+ document.body.style.overflow = 'hidden';
381
+ requestAnimationFrame(function() {
382
+ var active = sidebar.querySelector('a.active');
383
+ if (active) {
384
+ var body = sidebar.querySelector('.toc-mobile-sidebar__body');
385
+ if (body) body.scrollTop = Math.max(0, active.offsetTop - body.offsetTop - body.clientHeight/3);
386
+ }
387
+ });
388
+ }
389
+ function closeSidebar() {
390
+ sidebar.classList.remove('open'); backdrop.classList.remove('open');
391
+ toggleBtn.setAttribute('aria-expanded','false');
392
+ document.body.style.overflow = '';
393
+ }
394
+ if (toggleBtn) toggleBtn.addEventListener('click', openSidebar);
395
+ if (closeBtn) closeBtn.addEventListener('click', closeSidebar);
396
+ if (backdrop) backdrop.addEventListener('click', closeSidebar);
397
+ if (holderMobile) holderMobile.addEventListener('click', function(e) { if (e.target.closest && e.target.closest('a')) closeSidebar(); });
398
+ document.addEventListener('keydown', function(e) { if (e.key==='Escape' && sidebar.classList.contains('open')) closeSidebar(); });
399
+
400
+ // ---- Footer: move references & footnotes ----
401
+ (function() {
402
+ var footer = document.querySelector('footer.footer');
403
+ if (!footer) return;
404
+ var target = footer.querySelector('.references-block');
405
+ if (!target) return;
406
+ var contentRoot = document.querySelector('.content-grid main') || document.body;
407
+
408
+ var ensureHeading = function(text) {
409
+ var exists = Array.from(target.children).some(function(c) {
410
+ return c.classList.contains('footer-heading') && c.textContent.trim().toLowerCase() === text.toLowerCase();
411
+ });
412
+ if (!exists) {
413
+ var h = document.createElement('p');
414
+ h.className = 'footer-heading';
415
+ h.setAttribute('role', 'heading');
416
+ h.setAttribute('aria-level', '2');
417
+ h.textContent = text;
418
+ target.appendChild(h);
419
+ }
420
+ };
421
+
422
+ var moveIntoFooter = function(el, headingText) {
423
+ if (!el) return false;
424
+ var firstH = el.querySelector(':scope > h1, :scope > h2, :scope > h3, :scope > .footer-heading, :scope > .bibliography-title');
425
+ if (firstH) {
426
+ var t = (firstH.textContent || '').trim().toLowerCase();
427
+ if (t === headingText.toLowerCase() || t.includes('reference') || t.includes('bibliograph')) firstH.remove();
428
+ }
429
+ ensureHeading(headingText);
430
+ target.appendChild(el);
431
+ return true;
432
+ };
433
+
434
+ var findAllOutsideFooter = function(selectors) {
435
+ var results = [];
436
+ for (var i = 0; i < selectors.length; i++) {
437
+ var els = contentRoot.querySelectorAll(selectors[i]);
438
+ els.forEach(function(el) { if (!footer.contains(el) && results.indexOf(el) === -1) results.push(el); });
439
+ }
440
+ return results;
441
+ };
442
+
443
+ var findFirst = function(selectors) { var a = findAllOutsideFooter(selectors); return a.length ? a[0] : null; };
444
+
445
+ var run = function() {
446
+ if (footer.dataset.processed === 'true') return;
447
+ var refs = findAllOutsideFooter(['[data-type="bibliography"]', '#bibliography-references-list', '#references', '#refs', '.bibliography']);
448
+ var notes = findFirst(['.footnotes', 'section.footnotes', 'div.footnotes']);
449
+ var moved = false;
450
+ refs.forEach(function(el) { if (moveIntoFooter(el, 'References')) moved = true; });
451
+ if (moveIntoFooter(notes, 'Footnotes')) moved = true;
452
+ if (moved) footer.dataset.processed = 'true';
453
+ };
454
+
455
+ run();
456
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true });
457
+ window.addEventListener('load', function() { setTimeout(run, 100); }, { once: true });
458
+ setTimeout(run, 300);
459
+ })();
460
+
461
+ // Hash navigation
462
+ if (window.location.hash) {
463
+ var target = document.querySelector(window.location.hash);
464
+ if (target) setTimeout(function() { target.scrollIntoView({block:'start'}); }, 100);
465
+ }
466
+ window.addEventListener('popstate', function() {
467
+ var h = window.location.hash;
468
+ if (h) { var t = document.querySelector(h); if (t) t.scrollIntoView({block:'start'}); }
469
+ else window.scrollTo({top:0});
470
+ });
471
+ })();
472
+ </script>
473
+ </body>
474
+ </html>"
475
+ `;
backend/tests/html-renderer-snapshot.test.ts ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderArticleHTML, type PublishMeta, type CitationData } from "../src/publisher/html-renderer.js";
3
+ import type { PublishCSS } from "../src/publisher/index.js";
4
+
5
+ const EMPTY_CSS: PublishCSS = {
6
+ variables: "",
7
+ reset: "",
8
+ base: "",
9
+ layout: "",
10
+ print: "",
11
+ editorTokens: "",
12
+ article: "",
13
+ components: "",
14
+ publisher: "",
15
+ };
16
+
17
+ const BASIC_META: PublishMeta = {
18
+ title: "Test Article",
19
+ description: "A test article",
20
+ authors: [{ name: "Alice", affiliationIndices: [1], affiliationNames: ["MIT"] }],
21
+ affiliations: [{ name: "MIT" }],
22
+ date: "2025-01-01",
23
+ };
24
+
25
+ function minimalDoc(content: any[]) {
26
+ return { type: "doc", content };
27
+ }
28
+
29
+ describe("renderArticleHTML", () => {
30
+ it("produces a complete HTML document", () => {
31
+ const json = minimalDoc([{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }]);
32
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
33
+ expect(html).toContain("<!DOCTYPE html>");
34
+ expect(html).toContain("<title>Test Article</title>");
35
+ expect(html).toContain("Hello world");
36
+ });
37
+
38
+ it("escapes HTML in meta fields", () => {
39
+ const meta: PublishMeta = { ...BASIC_META, title: 'Title with "quotes" & <tags>' };
40
+ const json = minimalDoc([{ type: "paragraph" }]);
41
+ const html = renderArticleHTML(json, meta, EMPTY_CSS);
42
+ expect(html).toContain("&amp;");
43
+ expect(html).toContain("&lt;tags&gt;");
44
+ expect(html).not.toContain('<tags>');
45
+ });
46
+
47
+ it("renders PDF download link when pdfUrl is set", () => {
48
+ const meta: PublishMeta = { ...BASIC_META, pdfUrl: "/published/test/article.pdf" };
49
+ const json = minimalDoc([{ type: "paragraph" }]);
50
+ const html = renderArticleHTML(json, meta, EMPTY_CSS);
51
+ expect(html).toContain("Download PDF");
52
+ expect(html).toContain("/published/test/article.pdf");
53
+ });
54
+ });
55
+
56
+ describe("postProcess - accordion", () => {
57
+ it("transforms accordion div into details/summary", () => {
58
+ const json = minimalDoc([{
59
+ type: "accordion",
60
+ attrs: { title: "My Section", open: false },
61
+ content: [{ type: "paragraph", content: [{ type: "text", text: "Inner content" }] }],
62
+ }]);
63
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
64
+ expect(html).toContain("<details");
65
+ expect(html).toContain("<summary>");
66
+ expect(html).toContain("Inner content");
67
+ });
68
+ });
69
+
70
+ describe("postProcess - citations", () => {
71
+ it("replaces citation spans with anchor links", () => {
72
+ const json = minimalDoc([{
73
+ type: "paragraph",
74
+ content: [
75
+ { type: "text", text: "See " },
76
+ { type: "citation", attrs: { key: "smith2024", label: "Smith (2024)" } },
77
+ ],
78
+ }]);
79
+ const citationData: CitationData = {
80
+ entries: [{ id: "smith2024", title: "Test Paper" }],
81
+ orderedKeys: ["smith2024"],
82
+ style: "apa",
83
+ };
84
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData);
85
+ expect(html).toContain('href="#ref-smith2024"');
86
+ expect(html).toContain("citation-inline");
87
+ });
88
+
89
+ it("uses numeric labels for IEEE style", () => {
90
+ const json = minimalDoc([{
91
+ type: "paragraph",
92
+ content: [
93
+ { type: "citation", attrs: { key: "doe2023", label: "Doe (2023)" } },
94
+ ],
95
+ }]);
96
+ const citationData: CitationData = {
97
+ entries: [{ id: "doe2023" }],
98
+ orderedKeys: ["doe2023"],
99
+ style: "ieee",
100
+ };
101
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData);
102
+ expect(html).toContain("[1]");
103
+ });
104
+ });
105
+
106
+ describe("postProcess - footnotes", () => {
107
+ it("collects footnotes and appends a footnotes section", () => {
108
+ const json = minimalDoc([{
109
+ type: "paragraph",
110
+ content: [
111
+ { type: "text", text: "Text" },
112
+ { type: "footnote", attrs: { content: "This is a footnote" } },
113
+ ],
114
+ }]);
115
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
116
+ expect(html).toContain('class="footnote-ref"');
117
+ expect(html).toContain('id="fn-1"');
118
+ expect(html).toContain("This is a footnote");
119
+ expect(html).toContain('class="footnotes"');
120
+ });
121
+
122
+ it("numbers multiple footnotes sequentially", () => {
123
+ const json = minimalDoc([{
124
+ type: "paragraph",
125
+ content: [
126
+ { type: "footnote", attrs: { content: "First note" } },
127
+ { type: "text", text: " and " },
128
+ { type: "footnote", attrs: { content: "Second note" } },
129
+ ],
130
+ }]);
131
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
132
+ expect(html).toContain('id="fn-1"');
133
+ expect(html).toContain('id="fn-2"');
134
+ expect(html).toContain("First note");
135
+ expect(html).toContain("Second note");
136
+ });
137
+ });
138
+
139
+ describe("postProcess - mermaid", () => {
140
+ it("transforms mermaid div into pre.mermaid", () => {
141
+ const json = minimalDoc([{
142
+ type: "mermaid",
143
+ attrs: { code: "graph TD\n A --> B" },
144
+ }]);
145
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
146
+ expect(html).toContain('class="mermaid"');
147
+ expect(html).toContain("graph TD");
148
+ });
149
+ });
150
+
151
+ describe("postProcess - htmlEmbed", () => {
152
+ it("transforms htmlEmbed div into iframe", () => {
153
+ const json = minimalDoc([{
154
+ type: "htmlEmbed",
155
+ attrs: { src: "d3-chart.html", title: "Chart", desc: "" },
156
+ }]);
157
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS);
158
+ expect(html).toContain("html-embed-container");
159
+ expect(html).toContain('data-embed-src="d3-chart.html"');
160
+ expect(html).toContain("<iframe");
161
+ });
162
+ });
163
+
164
+ describe("postProcess - bibliography", () => {
165
+ it("injects bibliography HTML into the placeholder", () => {
166
+ const json = minimalDoc([
167
+ { type: "paragraph", content: [{ type: "citation", attrs: { key: "test2024", label: "[1]" } }] },
168
+ { type: "bibliography", attrs: { renderedHtml: "" } },
169
+ ]);
170
+ const citationData: CitationData = {
171
+ entries: [{ id: "test2024" }],
172
+ orderedKeys: ["test2024"],
173
+ style: "apa",
174
+ };
175
+ const biblioHtml = '<div class="csl-entry">Test entry</div>';
176
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml);
177
+ expect(html).toContain("bibliography-content");
178
+ expect(html).toContain('id="ref-test2024"');
179
+ expect(html).toContain("Test entry");
180
+ });
181
+ });
182
+
183
+ describe("snapshot - full render", () => {
184
+ it("matches snapshot for a typical article", () => {
185
+ const json = minimalDoc([
186
+ { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Introduction" }] },
187
+ { type: "paragraph", content: [
188
+ { type: "text", text: "This is a test article with a " },
189
+ { type: "citation", attrs: { key: "ref1", label: "Ref (2024)" } },
190
+ { type: "text", text: " citation." },
191
+ ] },
192
+ { type: "paragraph", content: [
193
+ { type: "text", text: "A footnote here" },
194
+ { type: "footnote", attrs: { content: "Important detail" } },
195
+ ] },
196
+ { type: "bibliography", attrs: { renderedHtml: "" } },
197
+ ]);
198
+
199
+ const citationData: CitationData = {
200
+ entries: [{ id: "ref1", title: "Reference One" }],
201
+ orderedKeys: ["ref1"],
202
+ style: "apa",
203
+ };
204
+ const biblioHtml = '<div class="csl-entry">Reference One. 2024.</div>';
205
+
206
+ const html = renderArticleHTML(json, BASIC_META, EMPTY_CSS, citationData, biblioHtml);
207
+ expect(html).toMatchSnapshot();
208
+ });
209
+ });
backend/tests/publisher.test.ts CHANGED
@@ -39,6 +39,7 @@ const EMPTY_CSS = {
39
  editorTokens: "",
40
  article: "",
41
  components: "",
 
42
  };
43
 
44
  // ── 1.1 Y.Doc Extraction ──────────────────────────────────────────
 
39
  editorTokens: "",
40
  article: "",
41
  components: "",
42
+ publisher: "",
43
  };
44
 
45
  // ── 1.1 Y.Doc Extraction ──────────────────────────────────────────
backend/tests/security.test.ts CHANGED
@@ -15,6 +15,7 @@ const EMPTY_CSS = {
15
  editorTokens: "",
16
  article: "",
17
  components: "",
 
18
  };
19
 
20
  const BASE_META: PublishMeta = {
 
15
  editorTokens: "",
16
  article: "",
17
  components: "",
18
+ publisher: "",
19
  };
20
 
21
  const BASE_META: PublishMeta = {
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture
2
+
3
+ ## Overview
4
+
5
+ The collab-editor is a collaborative article editor built for Hugging Face Spaces. It runs as a single Docker container serving both the backend (Express + Hocuspocus) and the frontend (React + TipTap).
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────────┐
9
+ │ Docker Container (port 8080) │
10
+ │ │
11
+ │ ┌──────────────────┐ ┌────────────────────────────┐ │
12
+ │ │ Express Server │ │ Hocuspocus (Y.js collab) │ │
13
+ │ │ │ │ │ │
14
+ │ │ /api/* │ │ /collab (WebSocket) │ │
15
+ │ │ /published/* │ │ │ │
16
+ │ │ /editor │ │ │ │
17
+ │ │ / (published) │ │ │ │
18
+ │ └──────────────────┘ └────────────────────────────┘ │
19
+ │ │
20
+ │ ┌──────────────────┐ ┌────────────────────────────┐ │
21
+ │ │ Publisher │ │ Frontend (static) │ │
22
+ │ │ (HTML renderer) │ │ /editor -> SPA │ │
23
+ │ │ (PDF generator) │ │ React + TipTap │ │
24
+ │ └──────────────────┘ └────────────────────────────┘ │
25
+ └─────────────────────────────────────────────────────────┘
26
+ ```
27
+
28
+ ## Key directories
29
+
30
+ | Path | Description |
31
+ |------|-------------|
32
+ | `backend/src/server.ts` | Express server, routes, WebSocket setup |
33
+ | `backend/src/publisher/` | HTML rendering, PDF generation, bibliography formatting |
34
+ | `backend/src/publisher/html-renderer.ts` | Converts TipTap JSON to static HTML page |
35
+ | `backend/src/publisher/extensions.ts` | Server-side TipTap extensions (mirrors frontend) |
36
+ | `backend/src/shared/component-defs.ts` | Shared component definitions (single source of truth) |
37
+ | `frontend/src/editor/` | TipTap editor, toolbars, components |
38
+ | `frontend/src/editor/components/registry.ts` | Component registry (imports from shared defs) |
39
+ | `frontend/src/styles/` | All CSS files |
40
+ | `frontend/src/styles/_publisher.css` | Publisher-only CSS (injected into static HTML) |
41
+ | `frontend/src/styles/_ui.css` | Editor chrome CSS (buttons, dialogs, drawers) |
42
+
43
+ ## Styling architecture
44
+
45
+ ### CSS layers
46
+
47
+ The project uses three CSS layers:
48
+
49
+ 1. **Template CSS** (`_variables.css`, `_base.css`, `_layout.css`, `article.css`, `components/`)
50
+ - Defines the article's visual identity
51
+ - Uses CSS custom properties (`--text-color`, `--surface-bg`, `--primary-color`, etc.)
52
+ - Shared between the editor preview and the published output
53
+
54
+ 2. **Editor chrome CSS** (`_ui.css`)
55
+ - Styles the editor UI: toolbars, sidebars, dialogs, forms
56
+ - Uses `--ed-*` custom properties for the dark editor theme
57
+ - Only loaded in the editor, never in published output
58
+
59
+ 3. **Publisher CSS** (`_publisher.css`)
60
+ - Styles specific to the published static HTML page
61
+ - Footer layout, bibliography, footnotes, citation links, theme toggle
62
+ - Only injected by the HTML renderer, never loaded in the editor
63
+
64
+ ### No CSS-in-JS
65
+
66
+ The project does not use MUI, Emotion, or any CSS-in-JS library. All styling is done via:
67
+ - CSS custom properties for theming
68
+ - Vanilla CSS files with BEM-like class naming
69
+ - `Floating UI` for tooltip positioning (lightweight, no CSS-in-JS)
70
+
71
+ ### Color theming
72
+
73
+ - The article area uses `data-theme="light"` / `data-theme="dark"` with CSS variable overrides
74
+ - The editor chrome is always dark, using `--ed-*` tokens
75
+ - The primary accent color is controlled via `--primary-color` (synced via Yjs settings)
76
+
77
+ ## HF Spaces constraints
78
+
79
+ ### Iframe embedding
80
+
81
+ When deployed as a HF Space, the app runs inside an iframe. This affects:
82
+
83
+ - **Viewport width**: the iframe is ~968px wide, not the full browser width
84
+ - **No `target="_top"` navigation**: links open within the iframe unless using `target="_blank"`
85
+ - **OAuth flow**: the OAuth callback URL must match the Space URL
86
+ - **CSP restrictions**: sandboxed iframes may restrict certain APIs
87
+
88
+ ### Two-page architecture
89
+
90
+ | URL | What it serves |
91
+ |-----|----------------|
92
+ | `/` | Published article (static HTML) or login prompt |
93
+ | `/editor` | The SPA editor (requires authentication) |
94
+
95
+ This means:
96
+ - The published article is a completely standalone HTML file
97
+ - It does NOT load React or any JS framework
98
+ - The editor is a separate React SPA at `/editor`
99
+
100
+ ### CSS cascade
101
+
102
+ Because the published HTML is self-contained (all CSS inlined in `<style>`), there are no CSS conflicts with the HF Spaces iframe CSS. The editor uses Vite's CSS pipeline.
103
+
104
+ ## Shared component registry
105
+
106
+ Component definitions (name, kind, fields, defaults) are defined once in `backend/src/shared/component-defs.ts`. This file is the single source of truth.
107
+
108
+ - **Backend** (`extensions.ts`): imports `SHARED_COMPONENT_DEFS` to generate TipTap server extensions for `generateHTML()`
109
+ - **Frontend** (`registry.ts`): imports `SHARED_COMPONENT_DEFS` via Vite alias `#shared` and decorates each entry with UI metadata (icon, label, description, placeholders)
110
+
111
+ Adding a new component:
112
+ 1. Add the entry to `shared/component-defs.ts`
113
+ 2. Add UI metadata to `frontend/src/editor/components/registry.ts` in `UI_META`
114
+ 3. Add CSS for the published view to `frontend/src/styles/_publisher.css`
115
+
116
+ ## Publisher pipeline
117
+
118
+ ```
119
+ Y.Doc -> TiptapTransformer -> JSON -> generateHTML() -> postProcess() -> full HTML page
120
+ |
121
+ v
122
+ PDF (Playwright)
123
+ |
124
+ v
125
+ Upload to HF dataset
126
+ ```
127
+
128
+ ### Post-processing (linkedom)
129
+
130
+ The `postProcess()` function uses `linkedom` for DOM manipulation instead of regex:
131
+ - Accordion `<div>` -> `<details>/<summary>`
132
+ - Citation `<span>` -> `<a>` links with bibliography anchors
133
+ - Bibliography placeholder -> formatted HTML with entry IDs
134
+ - Mermaid `<div>` -> `<pre class="mermaid">`
135
+ - HtmlEmbed `<div>` -> `<iframe>`
136
+ - Footnotes -> superscript links + appended section
137
+
138
+ ### Preview endpoint
139
+
140
+ `GET /api/preview/:docName` renders the HTML without saving or uploading. Useful for testing the publisher pipeline.
141
+
142
+ ## Testing
143
+
144
+ Tests use Vitest. Run with `npm test` from `backend/`.
145
+
146
+ | Test file | What it covers |
147
+ |-----------|----------------|
148
+ | `tests/publisher.test.ts` | Y.Doc extraction, HTML generation, post-processing, idempotency |
149
+ | `tests/html-renderer-snapshot.test.ts` | Snapshot tests for each postProcess transformation |
150
+ | `tests/security.test.ts` | XSS prevention in published HTML |
151
+ | `tests/css-resolution.test.ts` | @custom-media resolution |
152
+ | `tests/utils.test.ts` | Path sanitization utilities |
153
+ | `tests/auth.test.ts` | Token extraction, OAuth configuration |
154
+ | `tests/hf-storage.test.ts` | HF dataset storage configuration |
frontend/package-lock.json CHANGED
@@ -9,12 +9,8 @@
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "@ai-sdk/react": "^3.0.160",
12
- "@emotion/react": "^11.14.0",
13
- "@emotion/styled": "^11.14.1",
14
  "@floating-ui/dom": "^1.7.6",
15
  "@hocuspocus/provider": "^3.4.4",
16
- "@mui/icons-material": "^9.0.0",
17
- "@mui/material": "^9.0.0",
18
  "@tiptap/core": "^3.22.3",
19
  "@tiptap/extension-code-block-lowlight": "^3.22.3",
20
  "@tiptap/extension-collaboration": "^3.22.3",
@@ -36,6 +32,7 @@
36
  "ai": "^6.0.158",
37
  "katex": "^0.16.45",
38
  "lowlight": "^3.2.0",
 
39
  "mermaid": "^11.14.0",
40
  "react": "^18.3.0",
41
  "react-dom": "^18.3.0",
@@ -132,6 +129,7 @@
132
  "version": "7.29.0",
133
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
134
  "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
 
135
  "license": "MIT",
136
  "dependencies": {
137
  "@babel/helper-validator-identifier": "^7.28.5",
@@ -195,6 +193,7 @@
195
  "version": "7.29.1",
196
  "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
197
  "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
 
198
  "license": "MIT",
199
  "dependencies": {
200
  "@babel/parser": "^7.29.0",
@@ -228,6 +227,7 @@
228
  "version": "7.28.0",
229
  "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
230
  "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
 
231
  "license": "MIT",
232
  "engines": {
233
  "node": ">=6.9.0"
@@ -237,6 +237,7 @@
237
  "version": "7.28.6",
238
  "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
239
  "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
 
240
  "license": "MIT",
241
  "dependencies": {
242
  "@babel/traverse": "^7.28.6",
@@ -278,6 +279,7 @@
278
  "version": "7.27.1",
279
  "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
280
  "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
 
281
  "license": "MIT",
282
  "engines": {
283
  "node": ">=6.9.0"
@@ -287,6 +289,7 @@
287
  "version": "7.28.5",
288
  "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
289
  "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
 
290
  "license": "MIT",
291
  "engines": {
292
  "node": ">=6.9.0"
@@ -320,6 +323,7 @@
320
  "version": "7.29.2",
321
  "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
322
  "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
 
323
  "license": "MIT",
324
  "dependencies": {
325
  "@babel/types": "^7.29.0"
@@ -363,19 +367,11 @@
363
  "@babel/core": "^7.0.0-0"
364
  }
365
  },
366
- "node_modules/@babel/runtime": {
367
- "version": "7.29.2",
368
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
369
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
370
- "license": "MIT",
371
- "engines": {
372
- "node": ">=6.9.0"
373
- }
374
- },
375
  "node_modules/@babel/template": {
376
  "version": "7.28.6",
377
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
378
  "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
 
379
  "license": "MIT",
380
  "dependencies": {
381
  "@babel/code-frame": "^7.28.6",
@@ -390,6 +386,7 @@
390
  "version": "7.29.0",
391
  "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
392
  "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
 
393
  "license": "MIT",
394
  "dependencies": {
395
  "@babel/code-frame": "^7.29.0",
@@ -408,6 +405,7 @@
408
  "version": "7.29.0",
409
  "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
410
  "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
 
411
  "license": "MIT",
412
  "dependencies": {
413
  "@babel/helper-string-parser": "^7.27.1",
@@ -553,154 +551,6 @@
553
  "@csstools/css-tokenizer": "^4.0.0"
554
  }
555
  },
556
- "node_modules/@emotion/babel-plugin": {
557
- "version": "11.13.5",
558
- "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
559
- "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
560
- "license": "MIT",
561
- "dependencies": {
562
- "@babel/helper-module-imports": "^7.16.7",
563
- "@babel/runtime": "^7.18.3",
564
- "@emotion/hash": "^0.9.2",
565
- "@emotion/memoize": "^0.9.0",
566
- "@emotion/serialize": "^1.3.3",
567
- "babel-plugin-macros": "^3.1.0",
568
- "convert-source-map": "^1.5.0",
569
- "escape-string-regexp": "^4.0.0",
570
- "find-root": "^1.1.0",
571
- "source-map": "^0.5.7",
572
- "stylis": "4.2.0"
573
- }
574
- },
575
- "node_modules/@emotion/cache": {
576
- "version": "11.14.0",
577
- "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
578
- "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
579
- "license": "MIT",
580
- "dependencies": {
581
- "@emotion/memoize": "^0.9.0",
582
- "@emotion/sheet": "^1.4.0",
583
- "@emotion/utils": "^1.4.2",
584
- "@emotion/weak-memoize": "^0.4.0",
585
- "stylis": "4.2.0"
586
- }
587
- },
588
- "node_modules/@emotion/hash": {
589
- "version": "0.9.2",
590
- "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
591
- "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
592
- "license": "MIT"
593
- },
594
- "node_modules/@emotion/is-prop-valid": {
595
- "version": "1.4.0",
596
- "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
597
- "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
598
- "license": "MIT",
599
- "dependencies": {
600
- "@emotion/memoize": "^0.9.0"
601
- }
602
- },
603
- "node_modules/@emotion/memoize": {
604
- "version": "0.9.0",
605
- "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
606
- "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
607
- "license": "MIT"
608
- },
609
- "node_modules/@emotion/react": {
610
- "version": "11.14.0",
611
- "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
612
- "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
613
- "license": "MIT",
614
- "peer": true,
615
- "dependencies": {
616
- "@babel/runtime": "^7.18.3",
617
- "@emotion/babel-plugin": "^11.13.5",
618
- "@emotion/cache": "^11.14.0",
619
- "@emotion/serialize": "^1.3.3",
620
- "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
621
- "@emotion/utils": "^1.4.2",
622
- "@emotion/weak-memoize": "^0.4.0",
623
- "hoist-non-react-statics": "^3.3.1"
624
- },
625
- "peerDependencies": {
626
- "react": ">=16.8.0"
627
- },
628
- "peerDependenciesMeta": {
629
- "@types/react": {
630
- "optional": true
631
- }
632
- }
633
- },
634
- "node_modules/@emotion/serialize": {
635
- "version": "1.3.3",
636
- "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
637
- "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
638
- "license": "MIT",
639
- "dependencies": {
640
- "@emotion/hash": "^0.9.2",
641
- "@emotion/memoize": "^0.9.0",
642
- "@emotion/unitless": "^0.10.0",
643
- "@emotion/utils": "^1.4.2",
644
- "csstype": "^3.0.2"
645
- }
646
- },
647
- "node_modules/@emotion/sheet": {
648
- "version": "1.4.0",
649
- "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
650
- "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
651
- "license": "MIT"
652
- },
653
- "node_modules/@emotion/styled": {
654
- "version": "11.14.1",
655
- "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
656
- "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
657
- "license": "MIT",
658
- "peer": true,
659
- "dependencies": {
660
- "@babel/runtime": "^7.18.3",
661
- "@emotion/babel-plugin": "^11.13.5",
662
- "@emotion/is-prop-valid": "^1.3.0",
663
- "@emotion/serialize": "^1.3.3",
664
- "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
665
- "@emotion/utils": "^1.4.2"
666
- },
667
- "peerDependencies": {
668
- "@emotion/react": "^11.0.0-rc.0",
669
- "react": ">=16.8.0"
670
- },
671
- "peerDependenciesMeta": {
672
- "@types/react": {
673
- "optional": true
674
- }
675
- }
676
- },
677
- "node_modules/@emotion/unitless": {
678
- "version": "0.10.0",
679
- "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
680
- "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
681
- "license": "MIT"
682
- },
683
- "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
684
- "version": "1.2.0",
685
- "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
686
- "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
687
- "license": "MIT",
688
- "peerDependencies": {
689
- "react": ">=16.8.0"
690
- }
691
- },
692
- "node_modules/@emotion/utils": {
693
- "version": "1.4.2",
694
- "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
695
- "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
696
- "license": "MIT"
697
- },
698
- "node_modules/@emotion/weak-memoize": {
699
- "version": "0.4.0",
700
- "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
701
- "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
702
- "license": "MIT"
703
- },
704
  "node_modules/@esbuild/aix-ppc64": {
705
  "version": "0.25.12",
706
  "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1236,6 +1086,7 @@
1236
  "version": "0.3.13",
1237
  "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
1238
  "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
 
1239
  "license": "MIT",
1240
  "dependencies": {
1241
  "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1257,6 +1108,7 @@
1257
  "version": "3.1.2",
1258
  "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
1259
  "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
 
1260
  "license": "MIT",
1261
  "engines": {
1262
  "node": ">=6.0.0"
@@ -1266,12 +1118,14 @@
1266
  "version": "1.5.5",
1267
  "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
1268
  "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
 
1269
  "license": "MIT"
1270
  },
1271
  "node_modules/@jridgewell/trace-mapping": {
1272
  "version": "0.3.31",
1273
  "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
1274
  "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
 
1275
  "license": "MIT",
1276
  "dependencies": {
1277
  "@jridgewell/resolve-uri": "^3.1.0",
@@ -1293,240 +1147,6 @@
1293
  "langium": "^4.0.0"
1294
  }
1295
  },
1296
- "node_modules/@mui/core-downloads-tracker": {
1297
- "version": "9.0.0",
1298
- "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz",
1299
- "integrity": "sha512-uwQNGkhv0lf7ufxw6QXev77BW6pWbW+7uxYjU5+rfp4lBkFtMEgJCsarTM3Tn+i0lGx6+Ol2u88JdGXr0GDskA==",
1300
- "license": "MIT",
1301
- "funding": {
1302
- "type": "opencollective",
1303
- "url": "https://opencollective.com/mui-org"
1304
- }
1305
- },
1306
- "node_modules/@mui/icons-material": {
1307
- "version": "9.0.0",
1308
- "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.0.tgz",
1309
- "integrity": "sha512-oDwyvI6LgjWRC9MBcSGvLkPud9S9ELgSBQFYxa1rYcZn6Br55dn22SyvsPDMsn0G8OndFk53iMT45W5mNqrogw==",
1310
- "license": "MIT",
1311
- "dependencies": {
1312
- "@babel/runtime": "^7.29.2"
1313
- },
1314
- "engines": {
1315
- "node": ">=14.0.0"
1316
- },
1317
- "funding": {
1318
- "type": "opencollective",
1319
- "url": "https://opencollective.com/mui-org"
1320
- },
1321
- "peerDependencies": {
1322
- "@mui/material": "^9.0.0",
1323
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
1324
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
1325
- },
1326
- "peerDependenciesMeta": {
1327
- "@types/react": {
1328
- "optional": true
1329
- }
1330
- }
1331
- },
1332
- "node_modules/@mui/material": {
1333
- "version": "9.0.0",
1334
- "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz",
1335
- "integrity": "sha512-+VP/oQCDhDR87NQQgXnNBG8dwy6GNuQLnenS1pZvkbn2dKFSxRSRMybTpH9xUxXP+316mlYDy5CSbYtusnCWtw==",
1336
- "license": "MIT",
1337
- "peer": true,
1338
- "dependencies": {
1339
- "@babel/runtime": "^7.29.2",
1340
- "@mui/core-downloads-tracker": "^9.0.0",
1341
- "@mui/system": "^9.0.0",
1342
- "@mui/types": "^9.0.0",
1343
- "@mui/utils": "^9.0.0",
1344
- "@popperjs/core": "^2.11.8",
1345
- "@types/react-transition-group": "^4.4.12",
1346
- "clsx": "^2.1.1",
1347
- "csstype": "^3.2.3",
1348
- "prop-types": "^15.8.1",
1349
- "react-is": "^19.2.4",
1350
- "react-transition-group": "^4.4.5"
1351
- },
1352
- "engines": {
1353
- "node": ">=14.0.0"
1354
- },
1355
- "funding": {
1356
- "type": "opencollective",
1357
- "url": "https://opencollective.com/mui-org"
1358
- },
1359
- "peerDependencies": {
1360
- "@emotion/react": "^11.5.0",
1361
- "@emotion/styled": "^11.3.0",
1362
- "@mui/material-pigment-css": "^9.0.0",
1363
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
1364
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
1365
- "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
1366
- },
1367
- "peerDependenciesMeta": {
1368
- "@emotion/react": {
1369
- "optional": true
1370
- },
1371
- "@emotion/styled": {
1372
- "optional": true
1373
- },
1374
- "@mui/material-pigment-css": {
1375
- "optional": true
1376
- },
1377
- "@types/react": {
1378
- "optional": true
1379
- }
1380
- }
1381
- },
1382
- "node_modules/@mui/private-theming": {
1383
- "version": "9.0.0",
1384
- "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.0.tgz",
1385
- "integrity": "sha512-JtuZoaiCqwD6vjgYu6Xp3T7DZkrxJlgtDz5yESzhI34fEX5hHMh2VJUbuL9UOg8xrfIFMrq6dcYoH/7Zi4G0RA==",
1386
- "license": "MIT",
1387
- "dependencies": {
1388
- "@babel/runtime": "^7.29.2",
1389
- "@mui/utils": "^9.0.0",
1390
- "prop-types": "^15.8.1"
1391
- },
1392
- "engines": {
1393
- "node": ">=14.0.0"
1394
- },
1395
- "funding": {
1396
- "type": "opencollective",
1397
- "url": "https://opencollective.com/mui-org"
1398
- },
1399
- "peerDependencies": {
1400
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
1401
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
1402
- },
1403
- "peerDependenciesMeta": {
1404
- "@types/react": {
1405
- "optional": true
1406
- }
1407
- }
1408
- },
1409
- "node_modules/@mui/styled-engine": {
1410
- "version": "9.0.0",
1411
- "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz",
1412
- "integrity": "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg==",
1413
- "license": "MIT",
1414
- "dependencies": {
1415
- "@babel/runtime": "^7.29.2",
1416
- "@emotion/cache": "^11.14.0",
1417
- "@emotion/serialize": "^1.3.3",
1418
- "@emotion/sheet": "^1.4.0",
1419
- "csstype": "^3.2.3",
1420
- "prop-types": "^15.8.1"
1421
- },
1422
- "engines": {
1423
- "node": ">=14.0.0"
1424
- },
1425
- "funding": {
1426
- "type": "opencollective",
1427
- "url": "https://opencollective.com/mui-org"
1428
- },
1429
- "peerDependencies": {
1430
- "@emotion/react": "^11.4.1",
1431
- "@emotion/styled": "^11.3.0",
1432
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
1433
- },
1434
- "peerDependenciesMeta": {
1435
- "@emotion/react": {
1436
- "optional": true
1437
- },
1438
- "@emotion/styled": {
1439
- "optional": true
1440
- }
1441
- }
1442
- },
1443
- "node_modules/@mui/system": {
1444
- "version": "9.0.0",
1445
- "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.0.0.tgz",
1446
- "integrity": "sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g==",
1447
- "license": "MIT",
1448
- "dependencies": {
1449
- "@babel/runtime": "^7.29.2",
1450
- "@mui/private-theming": "^9.0.0",
1451
- "@mui/styled-engine": "^9.0.0",
1452
- "@mui/types": "^9.0.0",
1453
- "@mui/utils": "^9.0.0",
1454
- "clsx": "^2.1.1",
1455
- "csstype": "^3.2.3",
1456
- "prop-types": "^15.8.1"
1457
- },
1458
- "engines": {
1459
- "node": ">=14.0.0"
1460
- },
1461
- "funding": {
1462
- "type": "opencollective",
1463
- "url": "https://opencollective.com/mui-org"
1464
- },
1465
- "peerDependencies": {
1466
- "@emotion/react": "^11.5.0",
1467
- "@emotion/styled": "^11.3.0",
1468
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
1469
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
1470
- },
1471
- "peerDependenciesMeta": {
1472
- "@emotion/react": {
1473
- "optional": true
1474
- },
1475
- "@emotion/styled": {
1476
- "optional": true
1477
- },
1478
- "@types/react": {
1479
- "optional": true
1480
- }
1481
- }
1482
- },
1483
- "node_modules/@mui/types": {
1484
- "version": "9.0.0",
1485
- "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz",
1486
- "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==",
1487
- "license": "MIT",
1488
- "dependencies": {
1489
- "@babel/runtime": "^7.29.2"
1490
- },
1491
- "peerDependencies": {
1492
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
1493
- },
1494
- "peerDependenciesMeta": {
1495
- "@types/react": {
1496
- "optional": true
1497
- }
1498
- }
1499
- },
1500
- "node_modules/@mui/utils": {
1501
- "version": "9.0.0",
1502
- "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz",
1503
- "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==",
1504
- "license": "MIT",
1505
- "dependencies": {
1506
- "@babel/runtime": "^7.29.2",
1507
- "@mui/types": "^9.0.0",
1508
- "@types/prop-types": "^15.7.15",
1509
- "clsx": "^2.1.1",
1510
- "prop-types": "^15.8.1",
1511
- "react-is": "^19.2.4"
1512
- },
1513
- "engines": {
1514
- "node": ">=14.0.0"
1515
- },
1516
- "funding": {
1517
- "type": "opencollective",
1518
- "url": "https://opencollective.com/mui-org"
1519
- },
1520
- "peerDependencies": {
1521
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
1522
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
1523
- },
1524
- "peerDependenciesMeta": {
1525
- "@types/react": {
1526
- "optional": true
1527
- }
1528
- }
1529
- },
1530
  "node_modules/@opentelemetry/api": {
1531
  "version": "1.9.0",
1532
  "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -2865,12 +2485,6 @@
2865
  "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
2866
  "license": "MIT"
2867
  },
2868
- "node_modules/@types/parse-json": {
2869
- "version": "4.0.2",
2870
- "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
2871
- "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
2872
- "license": "MIT"
2873
- },
2874
  "node_modules/@types/prop-types": {
2875
  "version": "15.7.15",
2876
  "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2898,15 +2512,6 @@
2898
  "@types/react": "^18.0.0"
2899
  }
2900
  },
2901
- "node_modules/@types/react-transition-group": {
2902
- "version": "4.4.12",
2903
- "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
2904
- "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
2905
- "license": "MIT",
2906
- "peerDependencies": {
2907
- "@types/react": "*"
2908
- }
2909
- },
2910
  "node_modules/@types/trusted-types": {
2911
  "version": "2.0.7",
2912
  "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -3002,21 +2607,6 @@
3002
  "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
3003
  "license": "Python-2.0"
3004
  },
3005
- "node_modules/babel-plugin-macros": {
3006
- "version": "3.1.0",
3007
- "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
3008
- "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
3009
- "license": "MIT",
3010
- "dependencies": {
3011
- "@babel/runtime": "^7.12.5",
3012
- "cosmiconfig": "^7.0.0",
3013
- "resolve": "^1.19.0"
3014
- },
3015
- "engines": {
3016
- "node": ">=10",
3017
- "npm": ">=6"
3018
- }
3019
- },
3020
  "node_modules/baseline-browser-mapping": {
3021
  "version": "2.10.18",
3022
  "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz",
@@ -3065,15 +2655,6 @@
3065
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
3066
  }
3067
  },
3068
- "node_modules/callsites": {
3069
- "version": "3.1.0",
3070
- "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
3071
- "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
3072
- "license": "MIT",
3073
- "engines": {
3074
- "node": ">=6"
3075
- }
3076
- },
3077
  "node_modules/caniuse-lite": {
3078
  "version": "1.0.30001787",
3079
  "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@@ -3124,15 +2705,6 @@
3124
  "chevrotain": "^12.0.0"
3125
  }
3126
  },
3127
- "node_modules/clsx": {
3128
- "version": "2.1.1",
3129
- "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
3130
- "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
3131
- "license": "MIT",
3132
- "engines": {
3133
- "node": ">=6"
3134
- }
3135
- },
3136
  "node_modules/commander": {
3137
  "version": "8.3.0",
3138
  "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
@@ -3148,12 +2720,6 @@
3148
  "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
3149
  "license": "MIT"
3150
  },
3151
- "node_modules/convert-source-map": {
3152
- "version": "1.9.0",
3153
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
3154
- "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
3155
- "license": "MIT"
3156
- },
3157
  "node_modules/cose-base": {
3158
  "version": "1.0.3",
3159
  "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -3163,31 +2729,6 @@
3163
  "layout-base": "^1.0.0"
3164
  }
3165
  },
3166
- "node_modules/cosmiconfig": {
3167
- "version": "7.1.0",
3168
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
3169
- "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
3170
- "license": "MIT",
3171
- "dependencies": {
3172
- "@types/parse-json": "^4.0.0",
3173
- "import-fresh": "^3.2.1",
3174
- "parse-json": "^5.0.0",
3175
- "path-type": "^4.0.0",
3176
- "yaml": "^1.10.0"
3177
- },
3178
- "engines": {
3179
- "node": ">=10"
3180
- }
3181
- },
3182
- "node_modules/cosmiconfig/node_modules/yaml": {
3183
- "version": "1.10.3",
3184
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
3185
- "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
3186
- "license": "ISC",
3187
- "engines": {
3188
- "node": ">= 6"
3189
- }
3190
- },
3191
  "node_modules/crelt": {
3192
  "version": "1.0.6",
3193
  "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -3720,6 +3261,7 @@
3720
  "version": "4.4.3",
3721
  "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
3722
  "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
 
3723
  "license": "MIT",
3724
  "dependencies": {
3725
  "ms": "^2.1.3"
@@ -3764,16 +3306,6 @@
3764
  "url": "https://github.com/sponsors/wooorm"
3765
  }
3766
  },
3767
- "node_modules/dom-helpers": {
3768
- "version": "5.2.1",
3769
- "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
3770
- "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
3771
- "license": "MIT",
3772
- "dependencies": {
3773
- "@babel/runtime": "^7.8.7",
3774
- "csstype": "^3.0.2"
3775
- }
3776
- },
3777
  "node_modules/dompurify": {
3778
  "version": "3.3.3",
3779
  "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
@@ -3802,24 +3334,6 @@
3802
  "url": "https://github.com/fb55/entities?sponsor=1"
3803
  }
3804
  },
3805
- "node_modules/error-ex": {
3806
- "version": "1.3.4",
3807
- "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
3808
- "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
3809
- "license": "MIT",
3810
- "dependencies": {
3811
- "is-arrayish": "^0.2.1"
3812
- }
3813
- },
3814
- "node_modules/es-errors": {
3815
- "version": "1.3.0",
3816
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
3817
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
3818
- "license": "MIT",
3819
- "engines": {
3820
- "node": ">= 0.4"
3821
- }
3822
- },
3823
  "node_modules/esbuild": {
3824
  "version": "0.25.12",
3825
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -3920,12 +3434,6 @@
3920
  }
3921
  }
3922
  },
3923
- "node_modules/find-root": {
3924
- "version": "1.1.0",
3925
- "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
3926
- "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
3927
- "license": "MIT"
3928
- },
3929
  "node_modules/fsevents": {
3930
  "version": "2.3.3",
3931
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3941,15 +3449,6 @@
3941
  "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
3942
  }
3943
  },
3944
- "node_modules/function-bind": {
3945
- "version": "1.1.2",
3946
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
3947
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
3948
- "license": "MIT",
3949
- "funding": {
3950
- "url": "https://github.com/sponsors/ljharb"
3951
- }
3952
- },
3953
  "node_modules/gensync": {
3954
  "version": "1.0.0-beta.2",
3955
  "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3966,18 +3465,6 @@
3966
  "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
3967
  "license": "MIT"
3968
  },
3969
- "node_modules/hasown": {
3970
- "version": "2.0.2",
3971
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
3972
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
3973
- "license": "MIT",
3974
- "dependencies": {
3975
- "function-bind": "^1.1.2"
3976
- },
3977
- "engines": {
3978
- "node": ">= 0.4"
3979
- }
3980
- },
3981
  "node_modules/highlight.js": {
3982
  "version": "11.11.1",
3983
  "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
@@ -3988,21 +3475,6 @@
3988
  "node": ">=12.0.0"
3989
  }
3990
  },
3991
- "node_modules/hoist-non-react-statics": {
3992
- "version": "3.3.2",
3993
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
3994
- "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
3995
- "license": "BSD-3-Clause",
3996
- "dependencies": {
3997
- "react-is": "^16.7.0"
3998
- }
3999
- },
4000
- "node_modules/hoist-non-react-statics/node_modules/react-is": {
4001
- "version": "16.13.1",
4002
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
4003
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
4004
- "license": "MIT"
4005
- },
4006
  "node_modules/iconv-lite": {
4007
  "version": "0.6.3",
4008
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -4015,22 +3487,6 @@
4015
  "node": ">=0.10.0"
4016
  }
4017
  },
4018
- "node_modules/import-fresh": {
4019
- "version": "3.3.1",
4020
- "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
4021
- "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
4022
- "license": "MIT",
4023
- "dependencies": {
4024
- "parent-module": "^1.0.0",
4025
- "resolve-from": "^4.0.0"
4026
- },
4027
- "engines": {
4028
- "node": ">=6"
4029
- },
4030
- "funding": {
4031
- "url": "https://github.com/sponsors/sindresorhus"
4032
- }
4033
- },
4034
  "node_modules/internmap": {
4035
  "version": "2.0.3",
4036
  "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -4040,27 +3496,6 @@
4040
  "node": ">=12"
4041
  }
4042
  },
4043
- "node_modules/is-arrayish": {
4044
- "version": "0.2.1",
4045
- "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
4046
- "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
4047
- "license": "MIT"
4048
- },
4049
- "node_modules/is-core-module": {
4050
- "version": "2.16.1",
4051
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
4052
- "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
4053
- "license": "MIT",
4054
- "dependencies": {
4055
- "hasown": "^2.0.2"
4056
- },
4057
- "engines": {
4058
- "node": ">= 0.4"
4059
- },
4060
- "funding": {
4061
- "url": "https://github.com/sponsors/ljharb"
4062
- }
4063
- },
4064
  "node_modules/isomorphic.js": {
4065
  "version": "0.2.5",
4066
  "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
@@ -4081,6 +3516,7 @@
4081
  "version": "3.1.0",
4082
  "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
4083
  "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
 
4084
  "license": "MIT",
4085
  "bin": {
4086
  "jsesc": "bin/jsesc"
@@ -4089,12 +3525,6 @@
4089
  "node": ">=6"
4090
  }
4091
  },
4092
- "node_modules/json-parse-even-better-errors": {
4093
- "version": "2.3.1",
4094
- "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
4095
- "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
4096
- "license": "MIT"
4097
- },
4098
  "node_modules/json-schema": {
4099
  "version": "0.4.0",
4100
  "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
@@ -4181,12 +3611,6 @@
4181
  "url": "https://github.com/sponsors/dmonad"
4182
  }
4183
  },
4184
- "node_modules/lines-and-columns": {
4185
- "version": "1.2.4",
4186
- "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
4187
- "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
4188
- "license": "MIT"
4189
- },
4190
  "node_modules/linkify-it": {
4191
  "version": "5.0.0",
4192
  "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -4246,6 +3670,15 @@
4246
  "yallist": "^3.0.2"
4247
  }
4248
  },
 
 
 
 
 
 
 
 
 
4249
  "node_modules/markdown-it": {
4250
  "version": "14.1.1",
4251
  "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
@@ -4332,6 +3765,7 @@
4332
  "version": "2.1.3",
4333
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
4334
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
 
4335
  "license": "MIT"
4336
  },
4337
  "node_modules/nanoid": {
@@ -4360,15 +3794,6 @@
4360
  "dev": true,
4361
  "license": "MIT"
4362
  },
4363
- "node_modules/object-assign": {
4364
- "version": "4.1.1",
4365
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
4366
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
4367
- "license": "MIT",
4368
- "engines": {
4369
- "node": ">=0.10.0"
4370
- }
4371
- },
4372
  "node_modules/orderedmap": {
4373
  "version": "2.1.1",
4374
  "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
@@ -4381,57 +3806,12 @@
4381
  "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
4382
  "license": "MIT"
4383
  },
4384
- "node_modules/parent-module": {
4385
- "version": "1.0.1",
4386
- "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
4387
- "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
4388
- "license": "MIT",
4389
- "dependencies": {
4390
- "callsites": "^3.0.0"
4391
- },
4392
- "engines": {
4393
- "node": ">=6"
4394
- }
4395
- },
4396
- "node_modules/parse-json": {
4397
- "version": "5.2.0",
4398
- "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
4399
- "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
4400
- "license": "MIT",
4401
- "dependencies": {
4402
- "@babel/code-frame": "^7.0.0",
4403
- "error-ex": "^1.3.1",
4404
- "json-parse-even-better-errors": "^2.3.0",
4405
- "lines-and-columns": "^1.1.6"
4406
- },
4407
- "engines": {
4408
- "node": ">=8"
4409
- },
4410
- "funding": {
4411
- "url": "https://github.com/sponsors/sindresorhus"
4412
- }
4413
- },
4414
  "node_modules/path-data-parser": {
4415
  "version": "0.1.0",
4416
  "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
4417
  "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
4418
  "license": "MIT"
4419
  },
4420
- "node_modules/path-parse": {
4421
- "version": "1.0.7",
4422
- "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
4423
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
4424
- "license": "MIT"
4425
- },
4426
- "node_modules/path-type": {
4427
- "version": "4.0.0",
4428
- "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
4429
- "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
4430
- "license": "MIT",
4431
- "engines": {
4432
- "node": ">=8"
4433
- }
4434
- },
4435
  "node_modules/pathe": {
4436
  "version": "2.0.3",
4437
  "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -4442,6 +3822,7 @@
4442
  "version": "1.1.1",
4443
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
4444
  "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
 
4445
  "license": "ISC"
4446
  },
4447
  "node_modules/picomatch": {
@@ -4544,23 +3925,6 @@
4544
  "postcss": "^8.4"
4545
  }
4546
  },
4547
- "node_modules/prop-types": {
4548
- "version": "15.8.1",
4549
- "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
4550
- "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
4551
- "license": "MIT",
4552
- "dependencies": {
4553
- "loose-envify": "^1.4.0",
4554
- "object-assign": "^4.1.1",
4555
- "react-is": "^16.13.1"
4556
- }
4557
- },
4558
- "node_modules/prop-types/node_modules/react-is": {
4559
- "version": "16.13.1",
4560
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
4561
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
4562
- "license": "MIT"
4563
- },
4564
  "node_modules/prosemirror-changeset": {
4565
  "version": "2.4.0",
4566
  "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
@@ -4795,12 +4159,6 @@
4795
  "react": "^18.3.1"
4796
  }
4797
  },
4798
- "node_modules/react-is": {
4799
- "version": "19.2.5",
4800
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
4801
- "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
4802
- "license": "MIT"
4803
- },
4804
  "node_modules/react-refresh": {
4805
  "version": "0.17.0",
4806
  "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4811,52 +4169,6 @@
4811
  "node": ">=0.10.0"
4812
  }
4813
  },
4814
- "node_modules/react-transition-group": {
4815
- "version": "4.4.5",
4816
- "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
4817
- "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
4818
- "license": "BSD-3-Clause",
4819
- "dependencies": {
4820
- "@babel/runtime": "^7.5.5",
4821
- "dom-helpers": "^5.0.1",
4822
- "loose-envify": "^1.4.0",
4823
- "prop-types": "^15.6.2"
4824
- },
4825
- "peerDependencies": {
4826
- "react": ">=16.6.0",
4827
- "react-dom": ">=16.6.0"
4828
- }
4829
- },
4830
- "node_modules/resolve": {
4831
- "version": "1.22.12",
4832
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
4833
- "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
4834
- "license": "MIT",
4835
- "dependencies": {
4836
- "es-errors": "^1.3.0",
4837
- "is-core-module": "^2.16.1",
4838
- "path-parse": "^1.0.7",
4839
- "supports-preserve-symlinks-flag": "^1.0.0"
4840
- },
4841
- "bin": {
4842
- "resolve": "bin/resolve"
4843
- },
4844
- "engines": {
4845
- "node": ">= 0.4"
4846
- },
4847
- "funding": {
4848
- "url": "https://github.com/sponsors/ljharb"
4849
- }
4850
- },
4851
- "node_modules/resolve-from": {
4852
- "version": "4.0.0",
4853
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
4854
- "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
4855
- "license": "MIT",
4856
- "engines": {
4857
- "node": ">=4"
4858
- }
4859
- },
4860
  "node_modules/robust-predicates": {
4861
  "version": "3.0.3",
4862
  "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -4957,15 +4269,6 @@
4957
  "semver": "bin/semver.js"
4958
  }
4959
  },
4960
- "node_modules/source-map": {
4961
- "version": "0.5.7",
4962
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
4963
- "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
4964
- "license": "BSD-3-Clause",
4965
- "engines": {
4966
- "node": ">=0.10.0"
4967
- }
4968
- },
4969
  "node_modules/source-map-js": {
4970
  "version": "1.2.1",
4971
  "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4976,24 +4279,6 @@
4976
  "node": ">=0.10.0"
4977
  }
4978
  },
4979
- "node_modules/stylis": {
4980
- "version": "4.2.0",
4981
- "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
4982
- "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
4983
- "license": "MIT"
4984
- },
4985
- "node_modules/supports-preserve-symlinks-flag": {
4986
- "version": "1.0.0",
4987
- "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
4988
- "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
4989
- "license": "MIT",
4990
- "engines": {
4991
- "node": ">= 0.4"
4992
- },
4993
- "funding": {
4994
- "url": "https://github.com/sponsors/ljharb"
4995
- }
4996
- },
4997
  "node_modules/swr": {
4998
  "version": "2.4.1",
4999
  "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
 
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "@ai-sdk/react": "^3.0.160",
 
 
12
  "@floating-ui/dom": "^1.7.6",
13
  "@hocuspocus/provider": "^3.4.4",
 
 
14
  "@tiptap/core": "^3.22.3",
15
  "@tiptap/extension-code-block-lowlight": "^3.22.3",
16
  "@tiptap/extension-collaboration": "^3.22.3",
 
32
  "ai": "^6.0.158",
33
  "katex": "^0.16.45",
34
  "lowlight": "^3.2.0",
35
+ "lucide-react": "^1.8.0",
36
  "mermaid": "^11.14.0",
37
  "react": "^18.3.0",
38
  "react-dom": "^18.3.0",
 
129
  "version": "7.29.0",
130
  "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
131
  "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
132
+ "dev": true,
133
  "license": "MIT",
134
  "dependencies": {
135
  "@babel/helper-validator-identifier": "^7.28.5",
 
193
  "version": "7.29.1",
194
  "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
195
  "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
196
+ "dev": true,
197
  "license": "MIT",
198
  "dependencies": {
199
  "@babel/parser": "^7.29.0",
 
227
  "version": "7.28.0",
228
  "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
229
  "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
230
+ "dev": true,
231
  "license": "MIT",
232
  "engines": {
233
  "node": ">=6.9.0"
 
237
  "version": "7.28.6",
238
  "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
239
  "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
240
+ "dev": true,
241
  "license": "MIT",
242
  "dependencies": {
243
  "@babel/traverse": "^7.28.6",
 
279
  "version": "7.27.1",
280
  "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
281
  "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
282
+ "dev": true,
283
  "license": "MIT",
284
  "engines": {
285
  "node": ">=6.9.0"
 
289
  "version": "7.28.5",
290
  "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
291
  "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
292
+ "dev": true,
293
  "license": "MIT",
294
  "engines": {
295
  "node": ">=6.9.0"
 
323
  "version": "7.29.2",
324
  "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
325
  "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
326
+ "dev": true,
327
  "license": "MIT",
328
  "dependencies": {
329
  "@babel/types": "^7.29.0"
 
367
  "@babel/core": "^7.0.0-0"
368
  }
369
  },
 
 
 
 
 
 
 
 
 
370
  "node_modules/@babel/template": {
371
  "version": "7.28.6",
372
  "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
373
  "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
374
+ "dev": true,
375
  "license": "MIT",
376
  "dependencies": {
377
  "@babel/code-frame": "^7.28.6",
 
386
  "version": "7.29.0",
387
  "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
388
  "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
389
+ "dev": true,
390
  "license": "MIT",
391
  "dependencies": {
392
  "@babel/code-frame": "^7.29.0",
 
405
  "version": "7.29.0",
406
  "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
407
  "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
408
+ "dev": true,
409
  "license": "MIT",
410
  "dependencies": {
411
  "@babel/helper-string-parser": "^7.27.1",
 
551
  "@csstools/css-tokenizer": "^4.0.0"
552
  }
553
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  "node_modules/@esbuild/aix-ppc64": {
555
  "version": "0.25.12",
556
  "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
 
1086
  "version": "0.3.13",
1087
  "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
1088
  "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
1089
+ "dev": true,
1090
  "license": "MIT",
1091
  "dependencies": {
1092
  "@jridgewell/sourcemap-codec": "^1.5.0",
 
1108
  "version": "3.1.2",
1109
  "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
1110
  "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
1111
+ "dev": true,
1112
  "license": "MIT",
1113
  "engines": {
1114
  "node": ">=6.0.0"
 
1118
  "version": "1.5.5",
1119
  "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
1120
  "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
1121
+ "dev": true,
1122
  "license": "MIT"
1123
  },
1124
  "node_modules/@jridgewell/trace-mapping": {
1125
  "version": "0.3.31",
1126
  "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
1127
  "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
1128
+ "dev": true,
1129
  "license": "MIT",
1130
  "dependencies": {
1131
  "@jridgewell/resolve-uri": "^3.1.0",
 
1147
  "langium": "^4.0.0"
1148
  }
1149
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1150
  "node_modules/@opentelemetry/api": {
1151
  "version": "1.9.0",
1152
  "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
 
2485
  "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
2486
  "license": "MIT"
2487
  },
 
 
 
 
 
 
2488
  "node_modules/@types/prop-types": {
2489
  "version": "15.7.15",
2490
  "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
 
2512
  "@types/react": "^18.0.0"
2513
  }
2514
  },
 
 
 
 
 
 
 
 
 
2515
  "node_modules/@types/trusted-types": {
2516
  "version": "2.0.7",
2517
  "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
 
2607
  "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
2608
  "license": "Python-2.0"
2609
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2610
  "node_modules/baseline-browser-mapping": {
2611
  "version": "2.10.18",
2612
  "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz",
 
2655
  "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
2656
  }
2657
  },
 
 
 
 
 
 
 
 
 
2658
  "node_modules/caniuse-lite": {
2659
  "version": "1.0.30001787",
2660
  "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
 
2705
  "chevrotain": "^12.0.0"
2706
  }
2707
  },
 
 
 
 
 
 
 
 
 
2708
  "node_modules/commander": {
2709
  "version": "8.3.0",
2710
  "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
 
2720
  "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
2721
  "license": "MIT"
2722
  },
 
 
 
 
 
 
2723
  "node_modules/cose-base": {
2724
  "version": "1.0.3",
2725
  "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
 
2729
  "layout-base": "^1.0.0"
2730
  }
2731
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2732
  "node_modules/crelt": {
2733
  "version": "1.0.6",
2734
  "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
 
3261
  "version": "4.4.3",
3262
  "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
3263
  "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
3264
+ "dev": true,
3265
  "license": "MIT",
3266
  "dependencies": {
3267
  "ms": "^2.1.3"
 
3306
  "url": "https://github.com/sponsors/wooorm"
3307
  }
3308
  },
 
 
 
 
 
 
 
 
 
 
3309
  "node_modules/dompurify": {
3310
  "version": "3.3.3",
3311
  "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
 
3334
  "url": "https://github.com/fb55/entities?sponsor=1"
3335
  }
3336
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3337
  "node_modules/esbuild": {
3338
  "version": "0.25.12",
3339
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
 
3434
  }
3435
  }
3436
  },
 
 
 
 
 
 
3437
  "node_modules/fsevents": {
3438
  "version": "2.3.3",
3439
  "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
 
3449
  "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
3450
  }
3451
  },
 
 
 
 
 
 
 
 
 
3452
  "node_modules/gensync": {
3453
  "version": "1.0.0-beta.2",
3454
  "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
 
3465
  "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
3466
  "license": "MIT"
3467
  },
 
 
 
 
 
 
 
 
 
 
 
 
3468
  "node_modules/highlight.js": {
3469
  "version": "11.11.1",
3470
  "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
 
3475
  "node": ">=12.0.0"
3476
  }
3477
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3478
  "node_modules/iconv-lite": {
3479
  "version": "0.6.3",
3480
  "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
 
3487
  "node": ">=0.10.0"
3488
  }
3489
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3490
  "node_modules/internmap": {
3491
  "version": "2.0.3",
3492
  "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
 
3496
  "node": ">=12"
3497
  }
3498
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3499
  "node_modules/isomorphic.js": {
3500
  "version": "0.2.5",
3501
  "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
 
3516
  "version": "3.1.0",
3517
  "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
3518
  "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
3519
+ "dev": true,
3520
  "license": "MIT",
3521
  "bin": {
3522
  "jsesc": "bin/jsesc"
 
3525
  "node": ">=6"
3526
  }
3527
  },
 
 
 
 
 
 
3528
  "node_modules/json-schema": {
3529
  "version": "0.4.0",
3530
  "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
 
3611
  "url": "https://github.com/sponsors/dmonad"
3612
  }
3613
  },
 
 
 
 
 
 
3614
  "node_modules/linkify-it": {
3615
  "version": "5.0.0",
3616
  "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
 
3670
  "yallist": "^3.0.2"
3671
  }
3672
  },
3673
+ "node_modules/lucide-react": {
3674
+ "version": "1.8.0",
3675
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
3676
+ "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
3677
+ "license": "ISC",
3678
+ "peerDependencies": {
3679
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
3680
+ }
3681
+ },
3682
  "node_modules/markdown-it": {
3683
  "version": "14.1.1",
3684
  "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
 
3765
  "version": "2.1.3",
3766
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
3767
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
3768
+ "dev": true,
3769
  "license": "MIT"
3770
  },
3771
  "node_modules/nanoid": {
 
3794
  "dev": true,
3795
  "license": "MIT"
3796
  },
 
 
 
 
 
 
 
 
 
3797
  "node_modules/orderedmap": {
3798
  "version": "2.1.1",
3799
  "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
 
3806
  "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
3807
  "license": "MIT"
3808
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3809
  "node_modules/path-data-parser": {
3810
  "version": "0.1.0",
3811
  "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
3812
  "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
3813
  "license": "MIT"
3814
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3815
  "node_modules/pathe": {
3816
  "version": "2.0.3",
3817
  "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
 
3822
  "version": "1.1.1",
3823
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
3824
  "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
3825
+ "dev": true,
3826
  "license": "ISC"
3827
  },
3828
  "node_modules/picomatch": {
 
3925
  "postcss": "^8.4"
3926
  }
3927
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3928
  "node_modules/prosemirror-changeset": {
3929
  "version": "2.4.0",
3930
  "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
 
4159
  "react": "^18.3.1"
4160
  }
4161
  },
 
 
 
 
 
 
4162
  "node_modules/react-refresh": {
4163
  "version": "0.17.0",
4164
  "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
 
4169
  "node": ">=0.10.0"
4170
  }
4171
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4172
  "node_modules/robust-predicates": {
4173
  "version": "3.0.3",
4174
  "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
 
4269
  "semver": "bin/semver.js"
4270
  }
4271
  },
 
 
 
 
 
 
 
 
 
4272
  "node_modules/source-map-js": {
4273
  "version": "1.2.1",
4274
  "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
 
4279
  "node": ">=0.10.0"
4280
  }
4281
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4282
  "node_modules/swr": {
4283
  "version": "2.4.1",
4284
  "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
frontend/package.json CHANGED
@@ -10,12 +10,8 @@
10
  },
11
  "dependencies": {
12
  "@ai-sdk/react": "^3.0.160",
13
- "@emotion/react": "^11.14.0",
14
- "@emotion/styled": "^11.14.1",
15
  "@floating-ui/dom": "^1.7.6",
16
  "@hocuspocus/provider": "^3.4.4",
17
- "@mui/icons-material": "^9.0.0",
18
- "@mui/material": "^9.0.0",
19
  "@tiptap/core": "^3.22.3",
20
  "@tiptap/extension-code-block-lowlight": "^3.22.3",
21
  "@tiptap/extension-collaboration": "^3.22.3",
@@ -37,6 +33,7 @@
37
  "ai": "^6.0.158",
38
  "katex": "^0.16.45",
39
  "lowlight": "^3.2.0",
 
40
  "mermaid": "^11.14.0",
41
  "react": "^18.3.0",
42
  "react-dom": "^18.3.0",
 
10
  },
11
  "dependencies": {
12
  "@ai-sdk/react": "^3.0.160",
 
 
13
  "@floating-ui/dom": "^1.7.6",
14
  "@hocuspocus/provider": "^3.4.4",
 
 
15
  "@tiptap/core": "^3.22.3",
16
  "@tiptap/extension-code-block-lowlight": "^3.22.3",
17
  "@tiptap/extension-collaboration": "^3.22.3",
 
33
  "ai": "^6.0.158",
34
  "katex": "^0.16.45",
35
  "lowlight": "^3.2.0",
36
+ "lucide-react": "^1.8.0",
37
  "mermaid": "^11.14.0",
38
  "react": "^18.3.0",
39
  "react-dom": "^18.3.0",
frontend/src/App.tsx CHANGED
@@ -2,33 +2,16 @@ import { useRef, useState, useCallback, useEffect, useMemo } from "react";
2
  import { Editor as TiptapEditor } from "@tiptap/core";
3
  import { UndoManager } from "yjs";
4
  import type * as Y from "yjs";
5
- import { ThemeProvider } from "@mui/material/styles";
6
  import {
7
- Avatar,
8
- Box,
9
- Chip,
10
- IconButton,
11
- Tooltip,
12
- Typography,
13
- } from "@mui/material";
14
- import UndoIcon from "@mui/icons-material/Undo";
15
- import RedoIcon from "@mui/icons-material/Redo";
16
- import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
17
- import PublishOutlinedIcon from "@mui/icons-material/PublishOutlined";
18
- import { buildTheme, DEFAULT_PRIMARY, DEFAULT_HUE } from "./theme";
19
  import { oklchToHex, OKLCH_L, OKLCH_C } from "./editor/frontmatter/HueSlider";
20
- import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutlined";
21
- import CloseIcon from "@mui/icons-material/Close";
22
- import {
23
- Button,
24
- Dialog,
25
- DialogTitle,
26
- DialogContent,
27
- DialogContentText,
28
- DialogActions,
29
- CircularProgress,
30
- Badge,
31
- } from "@mui/material";
32
  import { Editor } from "./editor/Editor";
33
  import { CommentsSidebar } from "./components/CommentsSidebar";
34
  import { CommentDialog } from "./components/CommentDialog";
@@ -40,6 +23,8 @@ import type { FrontmatterStore } from "./editor/frontmatter/frontmatter-store";
40
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
41
  import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
42
 
 
 
43
  const COLORS = [
44
  "#958DF1", "#F98181", "#FBBC88", "#FAF594",
45
  "#70CFF8", "#94FADB", "#B9F18D", "#C4B5FD",
@@ -85,6 +70,7 @@ export default function App() {
85
 
86
  const editorRef = useRef<TiptapEditor | null>(null);
87
  const editorContainerRef = useRef<HTMLDivElement | null>(null);
 
88
  const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
89
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
90
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
@@ -92,25 +78,19 @@ export default function App() {
92
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
93
  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
94
  const [settingsOpen, setSettingsOpen] = useState(false);
95
- const [publishDialogOpen, setPublishDialogOpen] = useState(false);
96
  const [publishState, setPublishState] = useState<"idle" | "loading" | "success" | "error">("idle");
97
  const [publishError, setPublishError] = useState("");
98
  const selectionRange = useRef<{ from: number; to: number } | null>(null);
99
  const [hasSelection, setHasSelection] = useState(false);
100
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
101
  const [chatOpen, setChatOpen] = useState(false);
102
- const [primaryHue, setPrimaryHue] = useState(DEFAULT_HUE);
103
-
104
- const primaryHex = useMemo(() => oklchToHex(OKLCH_L, OKLCH_C, primaryHue), [primaryHue]);
105
- const muiTheme = useMemo(() => buildTheme(primaryHex), [primaryHex]);
106
 
107
- // Sync primary hue from Yjs settings + push to CSS variables
108
  useEffect(() => {
109
  if (!settingsMap) return;
110
  const sync = () => {
111
  const h = settingsMap.get("primaryHue") as number | undefined;
112
  if (h !== undefined) {
113
- setPrimaryHue(h);
114
  const oklch = `oklch(${OKLCH_L} ${OKLCH_C} ${h})`;
115
  document.documentElement.style.setProperty("--primary-color", oklch);
116
  document.documentElement.style.setProperty("--primary-color-hover", oklch);
@@ -155,6 +135,16 @@ export default function App() {
155
  setSettingsMap(map);
156
  }, []);
157
 
 
 
 
 
 
 
 
 
 
 
158
  const handlePublish = useCallback(async () => {
159
  setPublishState("loading");
160
  setPublishError("");
@@ -214,81 +204,64 @@ export default function App() {
214
  }, [commentStore, user]);
215
 
216
  return (
217
- <ThemeProvider theme={muiTheme}>
218
- <Box
219
  className="editor-app"
220
- sx={{
221
  height: "100vh",
222
  display: "flex",
223
  flexDirection: "column",
224
- bgcolor: "background.default",
225
  overflow: "hidden",
226
  }}
227
  >
228
- {/* Top bar - minimal */}
229
- <Box
230
- sx={{
231
- flexShrink: 0,
232
- display: "flex",
233
- alignItems: "center",
234
- justifyContent: "flex-end",
235
- px: 2,
236
- py: 0.75,
237
- gap: 0.5,
238
- }}
239
- >
240
- <Tooltip title="Undo" arrow>
241
- <IconButton
242
- size="small"
243
  onClick={() => editorInstance?.commands.undo()}
244
- sx={{ color: "text.disabled" }}
245
  >
246
- <UndoIcon sx={{ fontSize: 18 }} />
247
- </IconButton>
248
  </Tooltip>
249
- <Tooltip title="Redo" arrow>
250
- <IconButton
251
- size="small"
252
  onClick={() => editorInstance?.commands.redo()}
253
- sx={{ color: "text.disabled" }}
254
  >
255
- <RedoIcon sx={{ fontSize: 18 }} />
256
- </IconButton>
257
  </Tooltip>
258
- <Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
259
- <Tooltip title="Article settings" arrow>
260
- <IconButton size="small" onClick={() => setSettingsOpen(true)} sx={{ color: "text.disabled" }}>
261
- <SettingsOutlinedIcon sx={{ fontSize: 18 }} />
262
- </IconButton>
 
 
 
 
263
  </Tooltip>
264
- <Box sx={{ width: "1px", height: 16, bgcolor: "divider", mx: 0.5 }} />
265
- <Tooltip title="Publish article" arrow>
266
- <IconButton
267
- size="small"
268
- onClick={() => { setPublishState("idle"); setPublishDialogOpen(true); }}
269
- sx={{ color: "primary.main" }}
270
  >
271
- <PublishOutlinedIcon sx={{ fontSize: 18 }} />
272
- </IconButton>
273
  </Tooltip>
274
- <Chip
275
- avatar={
276
- user.avatarUrl
277
- ? <Avatar src={user.avatarUrl} sx={{ width: 18, height: 18 }} />
278
- : undefined
279
- }
280
- label={user.name}
281
- size="small"
282
- sx={{
283
- bgcolor: user.color,
284
- color: "#000",
285
- fontWeight: 600,
286
- fontSize: "0.65rem",
287
- height: 22,
288
- ml: 0.5,
289
- }}
290
- />
291
- </Box>
292
 
293
  {/* Single scroll container */}
294
  <div
@@ -296,19 +269,14 @@ export default function App() {
296
  style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}
297
  >
298
  <div className="content-grid">
299
- {/* Hero - center column only, row 1 */}
300
  <div className="content-grid__hero">
301
  <FrontmatterHero store={frontmatterStore} />
302
  </div>
303
-
304
- {/* TOC - left column, row 2, sticky */}
305
  <div className="content-grid__toc">
306
  <div className="table-of-contents--sticky">
307
  <TableOfContents editor={editorInstance} />
308
  </div>
309
  </div>
310
-
311
- {/* Editor - center column, row 2 */}
312
  <div className="content-grid__editor">
313
  <Editor
314
  docName={docName}
@@ -322,8 +290,6 @@ export default function App() {
322
  onAddComment={handleAddComment}
323
  />
324
  </div>
325
-
326
- {/* Comments - right column, row 2 */}
327
  <div className="content-grid__comments">
328
  <CommentsSidebar
329
  editor={editorInstance}
@@ -335,47 +301,16 @@ export default function App() {
335
  </div>
336
  </div>
337
 
338
- {/* Floating chat - bottom left */}
339
  {chatOpen ? (
340
- <Box
341
- sx={{
342
- position: "fixed",
343
- bottom: 16,
344
- left: 16,
345
- width: 360,
346
- height: 520,
347
- maxHeight: "calc(100vh - 80px)",
348
- borderRadius: 3,
349
- overflow: "hidden",
350
- display: "flex",
351
- flexDirection: "column",
352
- bgcolor: "background.paper",
353
- border: "1px solid",
354
- borderColor: "divider",
355
- boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
356
- zIndex: 1300,
357
- }}
358
- >
359
- <Box
360
- sx={{
361
- display: "flex",
362
- alignItems: "center",
363
- justifyContent: "space-between",
364
- px: 1.5,
365
- py: 0.75,
366
- borderBottom: "1px solid",
367
- borderColor: "divider",
368
- flexShrink: 0,
369
- }}
370
- >
371
- <Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", fontSize: "0.7rem" }}>
372
- AI Assistant
373
- </Typography>
374
- <IconButton size="small" onClick={() => setChatOpen(false)} sx={{ color: "text.disabled" }}>
375
- <CloseIcon sx={{ fontSize: 16 }} />
376
- </IconButton>
377
- </Box>
378
- <Box sx={{ flex: 1, overflow: "hidden", display: "flex" }}>
379
  <ChatPanel
380
  messages={agentChat.messages}
381
  isLoading={agentChat.isLoading}
@@ -388,33 +323,17 @@ export default function App() {
388
  onStop={agentChat.stop}
389
  onNewChat={() => window.location.reload()}
390
  />
391
- </Box>
392
- </Box>
393
  ) : (
394
- <Tooltip title="AI Assistant" placement="right" arrow>
395
- <IconButton
 
396
  onClick={() => setChatOpen(true)}
397
- sx={{
398
- position: "fixed",
399
- bottom: 20,
400
- left: 20,
401
- width: 48,
402
- height: 48,
403
- bgcolor: "primary.main",
404
- color: "primary.contrastText",
405
- boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
406
- zIndex: 1300,
407
- "&:hover": { bgcolor: "primary.dark" },
408
- }}
409
  >
410
- <Badge
411
- color="error"
412
- variant="dot"
413
- invisible={!agentChat.isLoading}
414
- >
415
- <ChatBubbleOutlineIcon sx={{ fontSize: 22 }} />
416
- </Badge>
417
- </IconButton>
418
  </Tooltip>
419
  )}
420
 
@@ -431,59 +350,53 @@ export default function App() {
431
  settingsMap={settingsMap}
432
  />
433
 
434
- <Dialog
435
- open={publishDialogOpen}
436
- onClose={() => setPublishDialogOpen(false)}
437
- maxWidth="xs"
438
- fullWidth
439
  >
440
- <DialogTitle>
441
  {publishState === "success" ? "Published!" : "Publish article"}
442
- </DialogTitle>
443
- <DialogContent>
444
  {publishState === "idle" && (
445
- <DialogContentText>
446
  This will generate a static HTML page, PDF, and thumbnail
447
  from the current document. Visitors without edit access
448
  will see the published version.
449
- </DialogContentText>
450
  )}
451
  {publishState === "loading" && (
452
- <Box sx={{ display: "flex", alignItems: "center", gap: 2, py: 2 }}>
453
- <CircularProgress size={24} />
454
- <DialogContentText sx={{ m: 0 }}>
455
- Publishing...
456
- </DialogContentText>
457
- </Box>
458
  )}
459
  {publishState === "success" && (
460
- <DialogContentText>
461
  Article published successfully. Visitors will now see
462
  the updated version.
463
- </DialogContentText>
464
  )}
465
  {publishState === "error" && (
466
- <DialogContentText color="error">
467
  {publishError || "An error occurred while publishing."}
468
- </DialogContentText>
469
  )}
470
- </DialogContent>
471
- <DialogActions>
472
  {publishState === "idle" && (
473
  <>
474
- <Button onClick={() => setPublishDialogOpen(false)}>Cancel</Button>
475
- <Button variant="contained" onClick={handlePublish}>
476
- Publish
477
- </Button>
478
  </>
479
  )}
480
- {publishState === "loading" && null}
481
  {(publishState === "success" || publishState === "error") && (
482
- <Button onClick={() => setPublishDialogOpen(false)}>Close</Button>
483
  )}
484
- </DialogActions>
485
- </Dialog>
486
- </Box>
487
- </ThemeProvider>
488
  );
489
  }
 
2
  import { Editor as TiptapEditor } from "@tiptap/core";
3
  import { UndoManager } from "yjs";
4
  import type * as Y from "yjs";
5
+ import { Tooltip } from "./components/Tooltip";
6
  import {
7
+ Undo2,
8
+ Redo2,
9
+ Settings,
10
+ Upload,
11
+ MessageCircle,
12
+ X,
13
+ } from "lucide-react";
 
 
 
 
 
14
  import { oklchToHex, OKLCH_L, OKLCH_C } from "./editor/frontmatter/HueSlider";
 
 
 
 
 
 
 
 
 
 
 
 
15
  import { Editor } from "./editor/Editor";
16
  import { CommentsSidebar } from "./components/CommentsSidebar";
17
  import { CommentDialog } from "./components/CommentDialog";
 
23
  import { FrontmatterHero } from "./editor/frontmatter/FrontmatterHero";
24
  import { SettingsDrawer } from "./editor/frontmatter/SettingsDrawer";
25
 
26
+ const DEFAULT_HUE = 47;
27
+
28
  const COLORS = [
29
  "#958DF1", "#F98181", "#FBBC88", "#FAF594",
30
  "#70CFF8", "#94FADB", "#B9F18D", "#C4B5FD",
 
70
 
71
  const editorRef = useRef<TiptapEditor | null>(null);
72
  const editorContainerRef = useRef<HTMLDivElement | null>(null);
73
+ const publishDialogRef = useRef<HTMLDialogElement>(null);
74
  const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(null);
75
  const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
76
  const [commentStore, setCommentStore] = useState<CommentStore | null>(null);
 
78
  const [settingsMap, setSettingsMap] = useState<Y.Map<any> | null>(null);
79
  const [commentDialogOpen, setCommentDialogOpen] = useState(false);
80
  const [settingsOpen, setSettingsOpen] = useState(false);
 
81
  const [publishState, setPublishState] = useState<"idle" | "loading" | "success" | "error">("idle");
82
  const [publishError, setPublishError] = useState("");
83
  const selectionRange = useRef<{ from: number; to: number } | null>(null);
84
  const [hasSelection, setHasSelection] = useState(false);
85
  const [undoManager, setUndoManager] = useState<UndoManager | null>(null);
86
  const [chatOpen, setChatOpen] = useState(false);
 
 
 
 
87
 
88
+ // Sync primary hue from Yjs settings to CSS variables
89
  useEffect(() => {
90
  if (!settingsMap) return;
91
  const sync = () => {
92
  const h = settingsMap.get("primaryHue") as number | undefined;
93
  if (h !== undefined) {
 
94
  const oklch = `oklch(${OKLCH_L} ${OKLCH_C} ${h})`;
95
  document.documentElement.style.setProperty("--primary-color", oklch);
96
  document.documentElement.style.setProperty("--primary-color-hover", oklch);
 
135
  setSettingsMap(map);
136
  }, []);
137
 
138
+ const openPublishDialog = useCallback(() => {
139
+ setPublishState("idle");
140
+ setPublishError("");
141
+ publishDialogRef.current?.showModal();
142
+ }, []);
143
+
144
+ const closePublishDialog = useCallback(() => {
145
+ publishDialogRef.current?.close();
146
+ }, []);
147
+
148
  const handlePublish = useCallback(async () => {
149
  setPublishState("loading");
150
  setPublishError("");
 
204
  }, [commentStore, user]);
205
 
206
  return (
207
+ <div
 
208
  className="editor-app"
209
+ style={{
210
  height: "100vh",
211
  display: "flex",
212
  flexDirection: "column",
213
+ background: "var(--ed-bg)",
214
  overflow: "hidden",
215
  }}
216
  >
217
+ {/* Top bar */}
218
+ <div className="top-bar">
219
+ <Tooltip title="Undo">
220
+ <button
221
+ className="icon-btn"
 
 
 
 
 
 
 
 
 
 
222
  onClick={() => editorInstance?.commands.undo()}
223
+ aria-label="Undo"
224
  >
225
+ <Undo2 size={18} />
226
+ </button>
227
  </Tooltip>
228
+ <Tooltip title="Redo">
229
+ <button
230
+ className="icon-btn"
231
  onClick={() => editorInstance?.commands.redo()}
232
+ aria-label="Redo"
233
  >
234
+ <Redo2 size={18} />
235
+ </button>
236
  </Tooltip>
237
+ <span className="divider-v" />
238
+ <Tooltip title="Article settings">
239
+ <button
240
+ className="icon-btn"
241
+ onClick={() => setSettingsOpen(true)}
242
+ aria-label="Article settings"
243
+ >
244
+ <Settings size={18} />
245
+ </button>
246
  </Tooltip>
247
+ <span className="divider-v" />
248
+ <Tooltip title="Publish article">
249
+ <button
250
+ className="icon-btn icon-btn--primary"
251
+ onClick={openPublishDialog}
252
+ aria-label="Publish article"
253
  >
254
+ <Upload size={18} />
255
+ </button>
256
  </Tooltip>
257
+ <span
258
+ className="chip"
259
+ style={{ backgroundColor: user.color, color: "#000", marginLeft: 4 }}
260
+ >
261
+ {user.avatarUrl && <img src={user.avatarUrl} alt="" />}
262
+ {user.name}
263
+ </span>
264
+ </div>
 
 
 
 
 
 
 
 
 
 
265
 
266
  {/* Single scroll container */}
267
  <div
 
269
  style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}
270
  >
271
  <div className="content-grid">
 
272
  <div className="content-grid__hero">
273
  <FrontmatterHero store={frontmatterStore} />
274
  </div>
 
 
275
  <div className="content-grid__toc">
276
  <div className="table-of-contents--sticky">
277
  <TableOfContents editor={editorInstance} />
278
  </div>
279
  </div>
 
 
280
  <div className="content-grid__editor">
281
  <Editor
282
  docName={docName}
 
290
  onAddComment={handleAddComment}
291
  />
292
  </div>
 
 
293
  <div className="content-grid__comments">
294
  <CommentsSidebar
295
  editor={editorInstance}
 
301
  </div>
302
  </div>
303
 
304
+ {/* Floating chat */}
305
  {chatOpen ? (
306
+ <div className="chat-floating">
307
+ <div className="chat-floating__header">
308
+ <span className="chat-floating__title">AI Assistant</span>
309
+ <button className="icon-btn" onClick={() => setChatOpen(false)} aria-label="Close chat">
310
+ <X size={16} />
311
+ </button>
312
+ </div>
313
+ <div className="chat-floating__body">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  <ChatPanel
315
  messages={agentChat.messages}
316
  isLoading={agentChat.isLoading}
 
323
  onStop={agentChat.stop}
324
  onNewChat={() => window.location.reload()}
325
  />
326
+ </div>
327
+ </div>
328
  ) : (
329
+ <Tooltip title="AI Assistant" placement="right">
330
+ <button
331
+ className={`chat-fab ${agentChat.isLoading ? "badge-dot" : "badge-dot badge-dot--hidden"}`}
332
  onClick={() => setChatOpen(true)}
333
+ aria-label="AI Assistant"
 
 
 
 
 
 
 
 
 
 
 
334
  >
335
+ <MessageCircle size={22} />
336
+ </button>
 
 
 
 
 
 
337
  </Tooltip>
338
  )}
339
 
 
350
  settingsMap={settingsMap}
351
  />
352
 
353
+ {/* Publish dialog (native <dialog>) */}
354
+ <dialog
355
+ ref={publishDialogRef}
356
+ className="ed-dialog"
357
+ onClose={closePublishDialog}
358
  >
359
+ <h2 className="ed-dialog__title">
360
  {publishState === "success" ? "Published!" : "Publish article"}
361
+ </h2>
362
+ <div className="ed-dialog__body">
363
  {publishState === "idle" && (
364
+ <p>
365
  This will generate a static HTML page, PDF, and thumbnail
366
  from the current document. Visitors without edit access
367
  will see the published version.
368
+ </p>
369
  )}
370
  {publishState === "loading" && (
371
+ <div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}>
372
+ <span className="spinner" />
373
+ <p style={{ margin: 0 }}>Publishing...</p>
374
+ </div>
 
 
375
  )}
376
  {publishState === "success" && (
377
+ <p>
378
  Article published successfully. Visitors will now see
379
  the updated version.
380
+ </p>
381
  )}
382
  {publishState === "error" && (
383
+ <p style={{ color: "var(--ed-error)" }}>
384
  {publishError || "An error occurred while publishing."}
385
+ </p>
386
  )}
387
+ </div>
388
+ <div className="ed-dialog__actions">
389
  {publishState === "idle" && (
390
  <>
391
+ <button className="btn" onClick={closePublishDialog}>Cancel</button>
392
+ <button className="btn btn--primary" onClick={handlePublish}>Publish</button>
 
 
393
  </>
394
  )}
 
395
  {(publishState === "success" || publishState === "error") && (
396
+ <button className="btn" onClick={closePublishDialog}>Close</button>
397
  )}
398
+ </div>
399
+ </dialog>
400
+ </div>
 
401
  );
402
  }
frontend/src/components/ChatPanel.tsx CHANGED
@@ -1,22 +1,16 @@
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
 
2
  import {
3
- Box,
4
- Typography,
5
- IconButton,
6
- TextField,
7
- Tooltip,
8
- Chip,
9
- CircularProgress,
10
- } from "@mui/material";
11
- import SendIcon from "@mui/icons-material/Send";
12
- import StopIcon from "@mui/icons-material/Stop";
13
- import AddIcon from "@mui/icons-material/Add";
14
- import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
15
- import ShortTextIcon from "@mui/icons-material/ShortText";
16
- import ExpandIcon from "@mui/icons-material/Expand";
17
- import SpellcheckIcon from "@mui/icons-material/Spellcheck";
18
- import TranslateIcon from "@mui/icons-material/Translate";
19
- import CompressIcon from "@mui/icons-material/Compress";
20
  import type { UIMessage } from "ai";
21
 
22
  interface ChatPanelProps {
@@ -33,12 +27,12 @@ interface ChatPanelProps {
33
  }
34
 
35
  const QUICK_ACTIONS = [
36
- { id: "rewrite", label: "Rewrite", icon: <AutoFixHighIcon sx={{ fontSize: 14 }} /> },
37
- { id: "expand", label: "Expand", icon: <ExpandIcon sx={{ fontSize: 14 }} /> },
38
- { id: "summarize", label: "Summarize", icon: <CompressIcon sx={{ fontSize: 14 }} /> },
39
- { id: "fix-grammar", label: "Fix grammar", icon: <SpellcheckIcon sx={{ fontSize: 14 }} /> },
40
- { id: "translate", label: "Translate", icon: <TranslateIcon sx={{ fontSize: 14 }} /> },
41
- { id: "simplify", label: "Simplify", icon: <ShortTextIcon sx={{ fontSize: 14 }} /> },
42
  ];
43
 
44
  export function ChatPanel({
@@ -77,56 +71,23 @@ export function ChatPanel({
77
  };
78
 
79
  return (
80
- <Box
81
- sx={{
82
- display: "flex",
83
- flexDirection: "column",
84
- height: "100%",
85
- width: "100%",
86
- }}
87
- >
88
  {/* Header */}
89
- <Box
90
- sx={{
91
- display: "flex",
92
- alignItems: "center",
93
- justifyContent: "space-between",
94
- px: 1.5,
95
- py: 1,
96
- borderBottom: "1px solid",
97
- borderColor: "divider",
98
- flexShrink: 0,
99
- }}
100
- >
101
- <Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", letterSpacing: "0.05em", textTransform: "uppercase" }}>
102
- Assistant
103
- </Typography>
104
- <Tooltip title="New conversation" arrow>
105
- <IconButton size="small" onClick={onNewChat} sx={{ color: "text.disabled" }}>
106
- <AddIcon sx={{ fontSize: 16 }} />
107
- </IconButton>
108
  </Tooltip>
109
- </Box>
110
 
111
  {/* Messages */}
112
- <Box
113
- ref={scrollRef}
114
- sx={{
115
- flex: 1,
116
- overflow: "auto",
117
- px: 1.5,
118
- py: 1,
119
- display: "flex",
120
- flexDirection: "column",
121
- gap: 1.5,
122
- }}
123
- >
124
  {messages.length === 0 && (
125
- <Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", flex: 1, opacity: 0.4 }}>
126
- <Typography variant="caption" sx={{ textAlign: "center", maxWidth: 200 }}>
127
- Ask me to write, edit, expand, or improve your article.
128
- </Typography>
129
- </Box>
130
  )}
131
 
132
  {messages.map((msg) => (
@@ -134,106 +95,65 @@ export function ChatPanel({
134
  ))}
135
 
136
  {isLoading && messages.length > 0 && !messages[messages.length - 1]?.content && (
137
- <Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1 }}>
138
- <CircularProgress size={12} sx={{ color: "text.disabled" }} />
139
- <Typography variant="caption" sx={{ color: "text.disabled" }}>
140
- Thinking...
141
- </Typography>
142
- </Box>
143
  )}
144
 
145
  {error && (
146
- <Box sx={{ px: 1, py: 0.5, bgcolor: "error.dark", borderRadius: 1, opacity: 0.8 }}>
147
- <Typography variant="caption" sx={{ color: "error.contrastText" }}>
148
- {error.message}
149
- </Typography>
150
- </Box>
151
  )}
152
- </Box>
153
 
154
  {/* Quick actions */}
155
  {hasSelection && (
156
- <Box
157
- sx={{
158
- display: "flex",
159
- flexWrap: "wrap",
160
- gap: 0.5,
161
- px: 1.5,
162
- py: 0.75,
163
- borderTop: "1px solid",
164
- borderColor: "divider",
165
- flexShrink: 0,
166
- }}
167
- >
168
  {QUICK_ACTIONS.map((action) => (
169
- <Chip
170
  key={action.id}
171
- label={action.label}
172
- icon={action.icon}
173
- size="small"
174
- variant="outlined"
175
  onClick={() => onQuickAction(action.id)}
176
- sx={{
177
- fontSize: "0.65rem",
178
- height: 24,
179
- cursor: "pointer",
180
- borderColor: "divider",
181
- "&:hover": { borderColor: "text.secondary", bgcolor: "action.hover" },
182
- }}
183
- />
184
  ))}
185
- </Box>
186
  )}
187
 
188
  {/* Input */}
189
- <Box
190
- sx={{
191
- px: 1.5,
192
- py: 1,
193
- borderTop: "1px solid",
194
- borderColor: "divider",
195
- flexShrink: 0,
196
- }}
197
- >
198
- <Box sx={{ display: "flex", gap: 0.5, alignItems: "flex-end" }}>
199
- <TextField
200
- multiline
201
- maxRows={4}
202
- fullWidth
203
- size="small"
204
  placeholder="Ask anything..."
205
  value={inputValue}
206
  onChange={(e) => onSetInput(e.target.value)}
207
  onKeyDown={handleKeyDown}
208
- variant="standard"
209
- slotProps={{
210
- input: {
211
- disableUnderline: true,
212
- sx: {
213
- fontSize: "0.85rem",
214
- lineHeight: 1.5,
215
- py: 0.5,
216
- },
217
- },
218
- }}
219
  />
220
  {isLoading ? (
221
- <IconButton size="small" onClick={onStop} sx={{ color: "warning.main", flexShrink: 0 }}>
222
- <StopIcon sx={{ fontSize: 18 }} />
223
- </IconButton>
224
  ) : (
225
- <IconButton
226
- size="small"
227
  onClick={handleSubmit}
228
  disabled={!inputValue.trim()}
229
- sx={{ color: inputValue.trim() ? "primary.main" : "text.disabled", flexShrink: 0 }}
 
230
  >
231
- <SendIcon sx={{ fontSize: 18 }} />
232
- </IconButton>
233
  )}
234
- </Box>
235
- </Box>
236
- </Box>
237
  );
238
  }
239
 
@@ -250,59 +170,30 @@ function MessageBubble({ message }: { message: UIMessage }) {
250
  if (!textContent && message.role === "assistant" && toolParts.length === 0) return null;
251
 
252
  return (
253
- <Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
254
  {textContent && (
255
- <Box
256
- sx={{
257
- alignSelf: isUser ? "flex-end" : "flex-start",
258
- maxWidth: "95%",
259
- px: 1.5,
260
- py: 0.75,
261
- borderRadius: 2,
262
- bgcolor: isUser ? "rgba(149, 141, 241, 0.15)" : "transparent",
263
- }}
264
- >
265
- <Typography
266
- variant="body2"
267
- sx={{
268
- fontSize: "0.8rem",
269
- lineHeight: 1.6,
270
- color: isUser ? "text.primary" : "text.secondary",
271
- whiteSpace: "pre-wrap",
272
- wordBreak: "break-word",
273
- }}
274
- >
275
- {textContent}
276
- </Typography>
277
- </Box>
278
  )}
279
 
280
  {toolParts.map((part, i) => {
281
  const tool = part as { type: "tool-invocation"; toolInvocation: { toolName: string; state: string; result?: unknown } };
282
  return (
283
- <Box
284
- key={i}
285
- sx={{
286
- display: "flex",
287
- alignItems: "center",
288
- gap: 0.5,
289
- px: 1.5,
290
- opacity: 0.6,
291
- }}
292
- >
293
- <AutoFixHighIcon sx={{ fontSize: 12 }} />
294
- <Typography variant="caption" sx={{ fontSize: "0.65rem" }}>
295
  {toolLabel(tool.toolInvocation.toolName)}
296
  {tool.toolInvocation.state === "result" && tool.toolInvocation.result
297
  ? ` - ${tool.toolInvocation.result}`
298
  : tool.toolInvocation.state === "call"
299
  ? " (executing...)"
300
  : ""}
301
- </Typography>
302
- </Box>
303
  );
304
  })}
305
- </Box>
306
  );
307
  }
308
 
 
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
+ import { Tooltip } from "./Tooltip";
3
  import {
4
+ Send,
5
+ Square,
6
+ Plus,
7
+ Sparkles,
8
+ AlignLeft,
9
+ Expand,
10
+ SpellCheck,
11
+ Languages,
12
+ Shrink,
13
+ } from "lucide-react";
 
 
 
 
 
 
 
14
  import type { UIMessage } from "ai";
15
 
16
  interface ChatPanelProps {
 
27
  }
28
 
29
  const QUICK_ACTIONS = [
30
+ { id: "rewrite", label: "Rewrite", Icon: Sparkles },
31
+ { id: "expand", label: "Expand", Icon: Expand },
32
+ { id: "summarize", label: "Summarize", Icon: Shrink },
33
+ { id: "fix-grammar", label: "Fix grammar", Icon: SpellCheck },
34
+ { id: "translate", label: "Translate", Icon: Languages },
35
+ { id: "simplify", label: "Simplify", Icon: AlignLeft },
36
  ];
37
 
38
  export function ChatPanel({
 
71
  };
72
 
73
  return (
74
+ <div className="chat-panel">
 
 
 
 
 
 
 
75
  {/* Header */}
76
+ <div className="chat-panel__header">
77
+ <span className="chat-panel__label">Assistant</span>
78
+ <Tooltip title="New conversation">
79
+ <button className="icon-btn" onClick={onNewChat} aria-label="New conversation">
80
+ <Plus size={16} />
81
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  </Tooltip>
83
+ </div>
84
 
85
  {/* Messages */}
86
+ <div ref={scrollRef} className="chat-panel__messages">
 
 
 
 
 
 
 
 
 
 
 
87
  {messages.length === 0 && (
88
+ <div className="chat-panel__empty">
89
+ Ask me to write, edit, expand, or improve your article.
90
+ </div>
 
 
91
  )}
92
 
93
  {messages.map((msg) => (
 
95
  ))}
96
 
97
  {isLoading && messages.length > 0 && !messages[messages.length - 1]?.content && (
98
+ <div className="chat-panel__thinking">
99
+ <span className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />
100
+ <span>Thinking...</span>
101
+ </div>
 
 
102
  )}
103
 
104
  {error && (
105
+ <div className="chat-panel__error">
106
+ {error.message}
107
+ </div>
 
 
108
  )}
109
+ </div>
110
 
111
  {/* Quick actions */}
112
  {hasSelection && (
113
+ <div className="chat-panel__actions">
 
 
 
 
 
 
 
 
 
 
 
114
  {QUICK_ACTIONS.map((action) => (
115
+ <button
116
  key={action.id}
117
+ className="chip chip--outlined chip--clickable"
 
 
 
118
  onClick={() => onQuickAction(action.id)}
119
+ style={{ height: 24, fontSize: "0.65rem", gap: 3 }}
120
+ >
121
+ <action.Icon size={12} />
122
+ {action.label}
123
+ </button>
 
 
 
124
  ))}
125
+ </div>
126
  )}
127
 
128
  {/* Input */}
129
+ <div className="chat-panel__input">
130
+ <div className="chat-panel__input-row">
131
+ <textarea
132
+ className="chat-panel__textarea"
133
+ rows={1}
 
 
 
 
 
 
 
 
 
 
134
  placeholder="Ask anything..."
135
  value={inputValue}
136
  onChange={(e) => onSetInput(e.target.value)}
137
  onKeyDown={handleKeyDown}
 
 
 
 
 
 
 
 
 
 
 
138
  />
139
  {isLoading ? (
140
+ <button className="icon-btn" onClick={onStop} aria-label="Stop" style={{ color: "#ff9800", flexShrink: 0 }}>
141
+ <Square size={18} />
142
+ </button>
143
  ) : (
144
+ <button
145
+ className="icon-btn"
146
  onClick={handleSubmit}
147
  disabled={!inputValue.trim()}
148
+ aria-label="Send"
149
+ style={{ color: inputValue.trim() ? "var(--primary-color)" : "var(--ed-text-disabled)", flexShrink: 0 }}
150
  >
151
+ <Send size={18} />
152
+ </button>
153
  )}
154
+ </div>
155
+ </div>
156
+ </div>
157
  );
158
  }
159
 
 
170
  if (!textContent && message.role === "assistant" && toolParts.length === 0) return null;
171
 
172
  return (
173
+ <div className="chat-bubble">
174
  {textContent && (
175
+ <div className={`chat-bubble__text ${isUser ? "chat-bubble__text--user" : ""}`}>
176
+ {textContent}
177
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  )}
179
 
180
  {toolParts.map((part, i) => {
181
  const tool = part as { type: "tool-invocation"; toolInvocation: { toolName: string; state: string; result?: unknown } };
182
  return (
183
+ <div key={i} className="chat-bubble__tool">
184
+ <Sparkles size={12} />
185
+ <span>
 
 
 
 
 
 
 
 
 
186
  {toolLabel(tool.toolInvocation.toolName)}
187
  {tool.toolInvocation.state === "result" && tool.toolInvocation.result
188
  ? ` - ${tool.toolInvocation.result}`
189
  : tool.toolInvocation.state === "call"
190
  ? " (executing...)"
191
  : ""}
192
+ </span>
193
+ </div>
194
  );
195
  })}
196
+ </div>
197
  );
198
  }
199
 
frontend/src/components/CommentDialog.tsx CHANGED
@@ -1,11 +1,4 @@
1
- import { useState, useEffect, useRef } from "react";
2
- import {
3
- Dialog,
4
- DialogContent,
5
- TextField,
6
- Button,
7
- Box,
8
- } from "@mui/material";
9
 
10
  interface CommentDialogProps {
11
  open: boolean;
@@ -15,66 +8,70 @@ interface CommentDialogProps {
15
 
16
  export function CommentDialog({ open, onClose, onSubmit }: CommentDialogProps) {
17
  const [text, setText] = useState("");
18
- const inputRef = useRef<HTMLInputElement>(null);
 
19
 
20
  useEffect(() => {
21
- if (open) {
 
 
 
 
22
  setText("");
23
- setTimeout(() => inputRef.current?.focus(), 100);
 
 
24
  }
25
  }, [open]);
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const handleSubmit = () => {
28
  if (!text.trim()) return;
29
  onSubmit(text.trim());
30
  setText("");
31
- onClose();
32
  };
33
 
34
  return (
35
- <Dialog
36
- open={open}
37
- onClose={onClose}
38
- maxWidth="xs"
39
- fullWidth
40
- PaperProps={{
41
- sx: {
42
- bgcolor: "background.paper",
43
- backgroundImage: "none",
44
- },
45
- }}
46
- >
47
- <DialogContent sx={{ p: 2 }}>
48
- <TextField
49
- inputRef={inputRef}
50
- multiline
51
- minRows={2}
52
- maxRows={4}
53
- fullWidth
54
- size="small"
55
  placeholder="Add your comment..."
56
  value={text}
57
  onChange={(e) => setText(e.target.value)}
58
  onKeyDown={(e) => {
59
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
60
  }}
61
- sx={{ mb: 1.5 }}
62
  />
63
- <Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
64
- <Button size="small" onClick={onClose} sx={{ textTransform: "none" }}>
65
  Cancel
66
- </Button>
67
- <Button
68
- size="small"
69
- variant="contained"
70
  onClick={handleSubmit}
71
  disabled={!text.trim()}
72
- sx={{ textTransform: "none" }}
73
  >
74
  Comment
75
- </Button>
76
- </Box>
77
- </DialogContent>
78
- </Dialog>
79
  );
80
  }
 
1
+ import { useState, useEffect, useRef, useCallback } from "react";
 
 
 
 
 
 
 
2
 
3
  interface CommentDialogProps {
4
  open: boolean;
 
8
 
9
  export function CommentDialog({ open, onClose, onSubmit }: CommentDialogProps) {
10
  const [text, setText] = useState("");
11
+ const dialogRef = useRef<HTMLDialogElement>(null);
12
+ const inputRef = useRef<HTMLTextAreaElement>(null);
13
 
14
  useEffect(() => {
15
+ const dialog = dialogRef.current;
16
+ if (!dialog) return;
17
+
18
+ if (open && !dialog.open) {
19
+ dialog.showModal();
20
  setText("");
21
+ setTimeout(() => inputRef.current?.focus(), 50);
22
+ } else if (!open && dialog.open) {
23
+ dialog.close();
24
  }
25
  }, [open]);
26
 
27
+ const handleClose = useCallback(() => {
28
+ dialogRef.current?.close();
29
+ onClose();
30
+ }, [onClose]);
31
+
32
+ useEffect(() => {
33
+ const dialog = dialogRef.current;
34
+ if (!dialog) return;
35
+ const onCancel = () => onClose();
36
+ dialog.addEventListener("close", onCancel);
37
+ return () => dialog.removeEventListener("close", onCancel);
38
+ }, [onClose]);
39
+
40
  const handleSubmit = () => {
41
  if (!text.trim()) return;
42
  onSubmit(text.trim());
43
  setText("");
44
+ handleClose();
45
  };
46
 
47
  return (
48
+ <dialog ref={dialogRef} className="ed-dialog">
49
+ <div className="ed-dialog__body" style={{ padding: "16px" }}>
50
+ <textarea
51
+ ref={inputRef}
52
+ className="form-input"
53
+ rows={3}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  placeholder="Add your comment..."
55
  value={text}
56
  onChange={(e) => setText(e.target.value)}
57
  onKeyDown={(e) => {
58
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
59
  }}
60
+ style={{ marginBottom: 12 }}
61
  />
62
+ <div className="ed-dialog__actions" style={{ padding: 0 }}>
63
+ <button className="btn" onClick={handleClose}>
64
  Cancel
65
+ </button>
66
+ <button
67
+ className="btn btn--primary"
 
68
  onClick={handleSubmit}
69
  disabled={!text.trim()}
 
70
  >
71
  Comment
72
+ </button>
73
+ </div>
74
+ </div>
75
+ </dialog>
76
  );
77
  }
frontend/src/components/CommentsSidebar.tsx CHANGED
@@ -1,15 +1,6 @@
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
- import {
3
- Box,
4
- Typography,
5
- IconButton,
6
- Chip,
7
- Paper,
8
- Tooltip,
9
- Fade,
10
- } from "@mui/material";
11
- import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
12
- import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined";
13
  import type { Editor } from "@tiptap/core";
14
  import type { CommentStore, CommentData } from "../editor/comments";
15
 
@@ -41,7 +32,6 @@ export function CommentsSidebar({ editor, commentStore, user, editorContainer }:
41
  return commentStore.observe(refresh);
42
  }, [commentStore, refresh]);
43
 
44
- // Compute Y positions of comment marks in the editor
45
  const updatePositions = useCallback(() => {
46
  if (!editor || !editorContainer) return;
47
 
@@ -74,7 +64,6 @@ export function CommentsSidebar({ editor, commentStore, user, editorContainer }:
74
  }
75
  }
76
 
77
- // Avoid overlaps: push comments down if they'd overlap the previous one
78
  result.sort((a, b) => a.top - b.top);
79
  const MIN_GAP = 80;
80
  for (let i = 1; i < result.length; i++) {
@@ -118,11 +107,6 @@ export function CommentsSidebar({ editor, commentStore, user, editorContainer }:
118
  commentStore.resolve(id, user.name);
119
  };
120
 
121
- const handleUnresolve = (id: string) => {
122
- if (!commentStore) return;
123
- commentStore.unresolve(id);
124
- };
125
-
126
  const handleDelete = (id: string) => {
127
  if (!commentStore || !editor) return;
128
  commentStore.remove(id);
@@ -140,30 +124,28 @@ export function CommentsSidebar({ editor, commentStore, user, editorContainer }:
140
  };
141
 
142
  return (
143
- <Box ref={sidebarRef} sx={{ position: "relative", width: "100%", minHeight: "100%" }}>
144
- {/* Positioned comments */}
145
  {positioned.map((c) => (
146
- <Fade in key={c.id}>
147
- <Box
148
- sx={{
149
- position: "absolute",
150
- top: c.top,
151
- left: 0,
152
- right: 0,
153
- transition: "top 0.2s ease",
154
- }}
155
- >
156
- <CommentCard
157
- comment={c}
158
- active={activeId === c.id}
159
- onResolve={() => handleResolve(c.id)}
160
- onDelete={() => handleDelete(c.id)}
161
- onClick={() => setActiveId(activeId === c.id ? null : c.id)}
162
- />
163
- </Box>
164
- </Fade>
165
  ))}
166
- </Box>
167
  );
168
  }
169
 
@@ -188,53 +170,43 @@ function CommentCard({
188
  });
189
 
190
  return (
191
- <Paper
192
- variant="outlined"
 
193
  onClick={onClick}
194
- sx={{
195
- p: 1.25,
196
- cursor: "pointer",
197
- borderLeft: 3,
198
- borderLeftColor: comment.authorColor,
199
- borderColor: active ? "primary.main" : "divider",
200
- bgcolor: active ? "rgba(149,141,241,0.05)" : "background.paper",
201
- transition: "all 0.15s",
202
- "&:hover": { borderColor: "primary.main" },
203
- }}
204
  >
205
- <Box sx={{ display: "flex", alignItems: "center", gap: 0.5, mb: 0.5 }}>
206
- <Chip
207
- label={comment.author}
208
- size="small"
209
- sx={{
210
- bgcolor: comment.authorColor,
211
- color: "#000",
212
- fontWeight: 600,
213
- fontSize: "0.65rem",
214
- height: 18,
215
- }}
216
- />
217
- <Typography variant="caption" sx={{ color: "text.disabled", ml: "auto", fontSize: "0.65rem" }}>
218
- {time}
219
- </Typography>
220
- </Box>
221
-
222
- <Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.primary", mb: 0.75, lineHeight: 1.5 }}>
223
- {comment.text}
224
- </Typography>
225
-
226
- <Box sx={{ display: "flex", gap: 0.25 }}>
227
- <Tooltip title="Resolve" arrow>
228
- <IconButton size="small" onClick={(e) => { e.stopPropagation(); onResolve(); }} sx={{ color: "success.main", p: 0.5 }}>
229
- <CheckCircleOutlinedIcon sx={{ fontSize: 16 }} />
230
- </IconButton>
231
  </Tooltip>
232
- <Tooltip title="Delete" arrow>
233
- <IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(); }} sx={{ color: "error.main", p: 0.5 }}>
234
- <DeleteOutlinedIcon sx={{ fontSize: 16 }} />
235
- </IconButton>
 
 
 
 
236
  </Tooltip>
237
- </Box>
238
- </Paper>
239
  );
240
  }
 
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { Tooltip } from "./Tooltip";
3
+ import { CheckCircle, Trash2 } from "lucide-react";
 
 
 
 
 
 
 
 
 
4
  import type { Editor } from "@tiptap/core";
5
  import type { CommentStore, CommentData } from "../editor/comments";
6
 
 
32
  return commentStore.observe(refresh);
33
  }, [commentStore, refresh]);
34
 
 
35
  const updatePositions = useCallback(() => {
36
  if (!editor || !editorContainer) return;
37
 
 
64
  }
65
  }
66
 
 
67
  result.sort((a, b) => a.top - b.top);
68
  const MIN_GAP = 80;
69
  for (let i = 1; i < result.length; i++) {
 
107
  commentStore.resolve(id, user.name);
108
  };
109
 
 
 
 
 
 
110
  const handleDelete = (id: string) => {
111
  if (!commentStore || !editor) return;
112
  commentStore.remove(id);
 
124
  };
125
 
126
  return (
127
+ <div ref={sidebarRef} style={{ position: "relative", width: "100%", minHeight: "100%" }}>
 
128
  {positioned.map((c) => (
129
+ <div
130
+ key={c.id}
131
+ style={{
132
+ position: "absolute",
133
+ top: c.top,
134
+ left: 0,
135
+ right: 0,
136
+ transition: "top 0.2s ease",
137
+ }}
138
+ >
139
+ <CommentCard
140
+ comment={c}
141
+ active={activeId === c.id}
142
+ onResolve={() => handleResolve(c.id)}
143
+ onDelete={() => handleDelete(c.id)}
144
+ onClick={() => setActiveId(activeId === c.id ? null : c.id)}
145
+ />
146
+ </div>
 
147
  ))}
148
+ </div>
149
  );
150
  }
151
 
 
170
  });
171
 
172
  return (
173
+ <div
174
+ className={`comment-card surface ${active ? "comment-card--active" : ""}`}
175
+ style={{ borderLeftColor: comment.authorColor }}
176
  onClick={onClick}
 
 
 
 
 
 
 
 
 
 
177
  >
178
+ <div className="comment-card__header">
179
+ <span
180
+ className="chip"
181
+ style={{ backgroundColor: comment.authorColor, color: "#000" }}
182
+ >
183
+ {comment.author}
184
+ </span>
185
+ <span className="comment-card__time">{time}</span>
186
+ </div>
187
+
188
+ <div className="comment-card__text">{comment.text}</div>
189
+
190
+ <div className="comment-card__actions">
191
+ <Tooltip title="Resolve">
192
+ <button
193
+ className="icon-btn icon-btn--success"
194
+ onClick={(e) => { e.stopPropagation(); onResolve(); }}
195
+ aria-label="Resolve"
196
+ >
197
+ <CheckCircle size={16} />
198
+ </button>
 
 
 
 
 
199
  </Tooltip>
200
+ <Tooltip title="Delete">
201
+ <button
202
+ className="icon-btn icon-btn--danger"
203
+ onClick={(e) => { e.stopPropagation(); onDelete(); }}
204
+ aria-label="Delete"
205
+ >
206
+ <Trash2 size={16} />
207
+ </button>
208
  </Tooltip>
209
+ </div>
210
+ </div>
211
  );
212
  }
frontend/src/components/Tooltip.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
2
+ import { computePosition, offset, flip, shift, type Placement } from "@floating-ui/dom";
3
+
4
+ interface TooltipProps {
5
+ title: string;
6
+ placement?: Placement;
7
+ children: ReactNode;
8
+ }
9
+
10
+ export function Tooltip({ title, placement = "top", children }: TooltipProps) {
11
+ const triggerRef = useRef<HTMLSpanElement>(null);
12
+ const tooltipRef = useRef<HTMLDivElement>(null);
13
+ const [open, setOpen] = useState(false);
14
+
15
+ const reposition = useCallback(() => {
16
+ const trigger = triggerRef.current;
17
+ const tip = tooltipRef.current;
18
+ if (!trigger || !tip) return;
19
+
20
+ computePosition(trigger, tip, {
21
+ placement,
22
+ strategy: "fixed",
23
+ middleware: [offset(6), flip(), shift({ padding: 8 })],
24
+ }).then(({ x, y }) => {
25
+ tip.style.left = `${x}px`;
26
+ tip.style.top = `${y}px`;
27
+ });
28
+ }, [placement]);
29
+
30
+ useEffect(() => {
31
+ if (open) reposition();
32
+ }, [open, reposition]);
33
+
34
+ if (!title) return <>{children}</>;
35
+
36
+ return (
37
+ <>
38
+ <span
39
+ ref={triggerRef}
40
+ onMouseEnter={() => setOpen(true)}
41
+ onMouseLeave={() => setOpen(false)}
42
+ onFocus={() => setOpen(true)}
43
+ onBlur={() => setOpen(false)}
44
+ style={{ display: "inline-flex" }}
45
+ >
46
+ {children}
47
+ </span>
48
+ {open && (
49
+ <div ref={tooltipRef} className="ed-tooltip" role="tooltip">
50
+ {title}
51
+ </div>
52
+ )}
53
+ </>
54
+ );
55
+ }
frontend/src/editor/BubbleToolbar.tsx CHANGED
@@ -1,15 +1,17 @@
1
  import { BubbleMenu } from "@tiptap/react/menus";
2
  import type { Editor } from "@tiptap/core";
3
- import { Box, IconButton, Divider, Tooltip } from "@mui/material";
4
- import FormatBoldIcon from "@mui/icons-material/FormatBold";
5
- import FormatItalicIcon from "@mui/icons-material/FormatItalic";
6
- import StrikethroughSIcon from "@mui/icons-material/StrikethroughS";
7
- import CodeIcon from "@mui/icons-material/Code";
8
- import FormatQuoteIcon from "@mui/icons-material/FormatQuote";
9
- import LinkIcon from "@mui/icons-material/Link";
10
- import TitleIcon from "@mui/icons-material/Title";
11
- import ChatBubbleOutlinedIcon from "@mui/icons-material/ChatBubbleOutlined";
12
- import FunctionsIcon from "@mui/icons-material/Functions";
 
 
13
 
14
  interface BubbleToolbarProps {
15
  editor: Editor;
@@ -28,22 +30,17 @@ function Btn({
28
  children: React.ReactNode;
29
  }) {
30
  return (
31
- <Tooltip title={tooltip} arrow placement="top">
32
- <IconButton
 
33
  onMouseDown={(e) => {
34
  e.preventDefault();
35
  onClick();
36
  }}
37
- size="small"
38
- sx={{
39
- color: active ? "#fff" : "rgba(255,255,255,0.6)",
40
- "&:hover": { color: "#fff" },
41
- borderRadius: 1,
42
- p: 0.75,
43
- }}
44
  >
45
  {children}
46
- </IconButton>
47
  </Tooltip>
48
  );
49
  }
@@ -61,91 +58,76 @@ export function BubbleToolbar({ editor, onAddComment }: BubbleToolbarProps) {
61
  editor={editor}
62
  options={{ placement: "top", offset: 6 }}
63
  >
64
- <Box
65
- sx={{
66
- display: "flex",
67
- alignItems: "center",
68
- gap: 0.25,
69
- bgcolor: "#1a1a1a",
70
- border: "1px solid #333",
71
- borderRadius: 2,
72
- px: 0.5,
73
- py: 0.25,
74
- boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
75
- }}
76
- >
77
  <Btn
78
  onClick={() => editor.chain().focus().toggleBold().run()}
79
  active={editor.isActive("bold")}
80
  tooltip="Bold"
81
  >
82
- <FormatBoldIcon sx={{ fontSize: 18 }} />
83
  </Btn>
84
  <Btn
85
  onClick={() => editor.chain().focus().toggleItalic().run()}
86
  active={editor.isActive("italic")}
87
  tooltip="Italic"
88
  >
89
- <FormatItalicIcon sx={{ fontSize: 18 }} />
90
  </Btn>
91
  <Btn
92
  onClick={() => editor.chain().focus().toggleStrike().run()}
93
  active={editor.isActive("strike")}
94
  tooltip="Strikethrough"
95
  >
96
- <StrikethroughSIcon sx={{ fontSize: 18 }} />
97
  </Btn>
98
  <Btn
99
  onClick={() => editor.chain().focus().toggleCode().run()}
100
  active={editor.isActive("code")}
101
  tooltip="Code"
102
  >
103
- <CodeIcon sx={{ fontSize: 18 }} />
104
  </Btn>
105
  <Btn
106
  onClick={() => editor.chain().focus().insertInlineMath({ latex: "x^2" }).run()}
107
  active={editor.isActive("inlineMath")}
108
  tooltip="Inline math"
109
  >
110
- <FunctionsIcon sx={{ fontSize: 18 }} />
111
  </Btn>
112
 
113
- <Divider orientation="vertical" flexItem sx={{ mx: 0.25, borderColor: "#444" }} />
114
 
115
  <Btn
116
  onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
117
  active={editor.isActive("heading", { level: 2 })}
118
  tooltip="Heading"
119
  >
120
- <TitleIcon sx={{ fontSize: 18 }} />
121
  </Btn>
122
  <Btn
123
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
124
  active={editor.isActive("blockquote")}
125
  tooltip="Quote"
126
  >
127
- <FormatQuoteIcon sx={{ fontSize: 18 }} />
128
  </Btn>
129
  <Btn
130
  onClick={setLink}
131
  active={editor.isActive("link")}
132
  tooltip="Link"
133
  >
134
- <LinkIcon sx={{ fontSize: 18 }} />
135
  </Btn>
136
 
137
  {onAddComment && (
138
  <>
139
- <Divider orientation="vertical" flexItem sx={{ mx: 0.25, borderColor: "#444" }} />
140
- <Btn
141
- onClick={onAddComment}
142
- tooltip="Comment"
143
- >
144
- <ChatBubbleOutlinedIcon sx={{ fontSize: 18 }} />
145
  </Btn>
146
  </>
147
  )}
148
- </Box>
149
  </BubbleMenu>
150
  );
151
  }
 
1
  import { BubbleMenu } from "@tiptap/react/menus";
2
  import type { Editor } from "@tiptap/core";
3
+ import { Tooltip } from "../components/Tooltip";
4
+ import {
5
+ Bold,
6
+ Italic,
7
+ Strikethrough,
8
+ Code,
9
+ Quote,
10
+ Link,
11
+ Heading2,
12
+ MessageCircle,
13
+ Sigma,
14
+ } from "lucide-react";
15
 
16
  interface BubbleToolbarProps {
17
  editor: Editor;
 
30
  children: React.ReactNode;
31
  }) {
32
  return (
33
+ <Tooltip title={tooltip} placement="top">
34
+ <button
35
+ className={`icon-btn ${active ? "icon-btn--active" : ""}`}
36
  onMouseDown={(e) => {
37
  e.preventDefault();
38
  onClick();
39
  }}
40
+ aria-label={tooltip}
 
 
 
 
 
 
41
  >
42
  {children}
43
+ </button>
44
  </Tooltip>
45
  );
46
  }
 
58
  editor={editor}
59
  options={{ placement: "top", offset: 6 }}
60
  >
61
+ <div className="bubble-toolbar">
 
 
 
 
 
 
 
 
 
 
 
 
62
  <Btn
63
  onClick={() => editor.chain().focus().toggleBold().run()}
64
  active={editor.isActive("bold")}
65
  tooltip="Bold"
66
  >
67
+ <Bold size={18} />
68
  </Btn>
69
  <Btn
70
  onClick={() => editor.chain().focus().toggleItalic().run()}
71
  active={editor.isActive("italic")}
72
  tooltip="Italic"
73
  >
74
+ <Italic size={18} />
75
  </Btn>
76
  <Btn
77
  onClick={() => editor.chain().focus().toggleStrike().run()}
78
  active={editor.isActive("strike")}
79
  tooltip="Strikethrough"
80
  >
81
+ <Strikethrough size={18} />
82
  </Btn>
83
  <Btn
84
  onClick={() => editor.chain().focus().toggleCode().run()}
85
  active={editor.isActive("code")}
86
  tooltip="Code"
87
  >
88
+ <Code size={18} />
89
  </Btn>
90
  <Btn
91
  onClick={() => editor.chain().focus().insertInlineMath({ latex: "x^2" }).run()}
92
  active={editor.isActive("inlineMath")}
93
  tooltip="Inline math"
94
  >
95
+ <Sigma size={18} />
96
  </Btn>
97
 
98
+ <span className="divider-v" />
99
 
100
  <Btn
101
  onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
102
  active={editor.isActive("heading", { level: 2 })}
103
  tooltip="Heading"
104
  >
105
+ <Heading2 size={18} />
106
  </Btn>
107
  <Btn
108
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
109
  active={editor.isActive("blockquote")}
110
  tooltip="Quote"
111
  >
112
+ <Quote size={18} />
113
  </Btn>
114
  <Btn
115
  onClick={setLink}
116
  active={editor.isActive("link")}
117
  tooltip="Link"
118
  >
119
+ <Link size={18} />
120
  </Btn>
121
 
122
  {onAddComment && (
123
  <>
124
+ <span className="divider-v" />
125
+ <Btn onClick={onAddComment} tooltip="Comment">
126
+ <MessageCircle size={18} />
 
 
 
127
  </Btn>
128
  </>
129
  )}
130
+ </div>
131
  </BubbleMenu>
132
  );
133
  }
frontend/src/editor/FloatingActions.tsx CHANGED
@@ -1,15 +1,18 @@
1
  import { FloatingMenu } from "@tiptap/react";
2
  import type { Editor } from "@tiptap/core";
3
  import { useState } from "react";
4
- import { Box, IconButton, Tooltip, Fade, Paper } from "@mui/material";
5
- import AddIcon from "@mui/icons-material/Add";
6
- import TitleIcon from "@mui/icons-material/Title";
7
- import FormatQuoteIcon from "@mui/icons-material/FormatQuote";
8
- import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
9
- import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
10
- import DataObjectIcon from "@mui/icons-material/DataObject";
11
- import HorizontalRuleIcon from "@mui/icons-material/HorizontalRule";
12
- import FunctionsIcon from "@mui/icons-material/Functions";
 
 
 
13
 
14
  interface FloatingActionsProps {
15
  editor: Editor;
@@ -32,112 +35,94 @@ export function FloatingActions({ editor }: FloatingActionsProps) {
32
  offset: [-4, 0],
33
  }}
34
  >
35
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 0.5 }}>
36
- <Tooltip title="Insert block" arrow placement="left">
37
- <IconButton
38
- size="small"
39
  onClick={() => setOpen(!open)}
40
- sx={{
41
- color: open ? "text.primary" : "text.disabled",
42
- border: "1px solid",
43
- borderColor: open ? "divider" : "transparent",
44
- transition: "all 0.15s",
45
- "&:hover": { color: "text.primary", borderColor: "divider" },
46
- transform: open ? "rotate(45deg)" : "none",
47
- p: 0.5,
48
- }}
49
  >
50
- <AddIcon sx={{ fontSize: 20 }} />
51
- </IconButton>
52
  </Tooltip>
53
 
54
- <Fade in={open} timeout={150}>
55
- <Paper
56
- variant="outlined"
57
- sx={{
58
- display: open ? "flex" : "none",
59
- gap: 0.25,
60
- px: 0.5,
61
- py: 0.25,
62
- bgcolor: "background.paper",
63
- boxShadow: "0 4px 20px rgba(0,0,0,0.3)",
64
- }}
65
- >
66
- <Tooltip title="Heading 2" arrow>
67
- <IconButton
68
- size="small"
69
  onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 2 }).run())}
70
- sx={{ color: "text.secondary", p: 0.75 }}
71
  >
72
- <TitleIcon sx={{ fontSize: 18 }} />
73
- </IconButton>
74
  </Tooltip>
75
- <Tooltip title="Heading 3" arrow>
76
- <IconButton
77
- size="small"
78
  onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 3 }).run())}
79
- sx={{ color: "text.secondary", fontSize: "0.75rem", fontWeight: 700, p: 0.75 }}
80
  >
81
- H3
82
- </IconButton>
83
  </Tooltip>
84
- <Tooltip title="Quote" arrow>
85
- <IconButton
86
- size="small"
87
  onClick={() => insert(() => editor.chain().focus().toggleBlockquote().run())}
88
- sx={{ color: "text.secondary", p: 0.75 }}
89
  >
90
- <FormatQuoteIcon sx={{ fontSize: 18 }} />
91
- </IconButton>
92
  </Tooltip>
93
- <Tooltip title="Bullet list" arrow>
94
- <IconButton
95
- size="small"
96
  onClick={() => insert(() => editor.chain().focus().toggleBulletList().run())}
97
- sx={{ color: "text.secondary", p: 0.75 }}
98
  >
99
- <FormatListBulletedIcon sx={{ fontSize: 18 }} />
100
- </IconButton>
101
  </Tooltip>
102
- <Tooltip title="Numbered list" arrow>
103
- <IconButton
104
- size="small"
105
  onClick={() => insert(() => editor.chain().focus().toggleOrderedList().run())}
106
- sx={{ color: "text.secondary", p: 0.75 }}
107
  >
108
- <FormatListNumberedIcon sx={{ fontSize: 18 }} />
109
- </IconButton>
110
  </Tooltip>
111
- <Tooltip title="Code block" arrow>
112
- <IconButton
113
- size="small"
114
  onClick={() => insert(() => editor.chain().focus().toggleCodeBlock().run())}
115
- sx={{ color: "text.secondary", p: 0.75 }}
116
  >
117
- <DataObjectIcon sx={{ fontSize: 18 }} />
118
- </IconButton>
119
  </Tooltip>
120
- <Tooltip title="Equation" arrow>
121
- <IconButton
122
- size="small"
123
  onClick={() => insert(() => editor.chain().focus().insertBlockMath({ latex: "E = mc^2" }).run())}
124
- sx={{ color: "text.secondary", p: 0.75 }}
125
  >
126
- <FunctionsIcon sx={{ fontSize: 18 }} />
127
- </IconButton>
128
  </Tooltip>
129
- <Tooltip title="Divider" arrow>
130
- <IconButton
131
- size="small"
132
  onClick={() => insert(() => editor.chain().focus().setHorizontalRule().run())}
133
- sx={{ color: "text.secondary", p: 0.75 }}
134
  >
135
- <HorizontalRuleIcon sx={{ fontSize: 18 }} />
136
- </IconButton>
137
  </Tooltip>
138
- </Paper>
139
- </Fade>
140
- </Box>
141
  </FloatingMenu>
142
  );
143
  }
 
1
  import { FloatingMenu } from "@tiptap/react";
2
  import type { Editor } from "@tiptap/core";
3
  import { useState } from "react";
4
+ import { Tooltip } from "../components/Tooltip";
5
+ import {
6
+ Plus,
7
+ Heading2,
8
+ Heading3,
9
+ Quote,
10
+ List,
11
+ ListOrdered,
12
+ Braces,
13
+ Minus,
14
+ Sigma,
15
+ } from "lucide-react";
16
 
17
  interface FloatingActionsProps {
18
  editor: Editor;
 
35
  offset: [-4, 0],
36
  }}
37
  >
38
+ <div className="floating-actions">
39
+ <Tooltip title="Insert block" placement="left">
40
+ <button
41
+ className={`floating-actions__toggle ${open ? "floating-actions__toggle--open" : ""}`}
42
  onClick={() => setOpen(!open)}
43
+ aria-label="Insert block"
 
 
 
 
 
 
 
 
44
  >
45
+ <Plus />
46
+ </button>
47
  </Tooltip>
48
 
49
+ {open && (
50
+ <div className="floating-actions__panel surface">
51
+ <Tooltip title="Heading 2">
52
+ <button
53
+ className="icon-btn"
 
 
 
 
 
 
 
 
 
 
54
  onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 2 }).run())}
55
+ aria-label="Heading 2"
56
  >
57
+ <Heading2 size={18} />
58
+ </button>
59
  </Tooltip>
60
+ <Tooltip title="Heading 3">
61
+ <button
62
+ className="icon-btn"
63
  onClick={() => insert(() => editor.chain().focus().toggleHeading({ level: 3 }).run())}
64
+ aria-label="Heading 3"
65
  >
66
+ <Heading3 size={18} />
67
+ </button>
68
  </Tooltip>
69
+ <Tooltip title="Quote">
70
+ <button
71
+ className="icon-btn"
72
  onClick={() => insert(() => editor.chain().focus().toggleBlockquote().run())}
73
+ aria-label="Quote"
74
  >
75
+ <Quote size={18} />
76
+ </button>
77
  </Tooltip>
78
+ <Tooltip title="Bullet list">
79
+ <button
80
+ className="icon-btn"
81
  onClick={() => insert(() => editor.chain().focus().toggleBulletList().run())}
82
+ aria-label="Bullet list"
83
  >
84
+ <List size={18} />
85
+ </button>
86
  </Tooltip>
87
+ <Tooltip title="Numbered list">
88
+ <button
89
+ className="icon-btn"
90
  onClick={() => insert(() => editor.chain().focus().toggleOrderedList().run())}
91
+ aria-label="Numbered list"
92
  >
93
+ <ListOrdered size={18} />
94
+ </button>
95
  </Tooltip>
96
+ <Tooltip title="Code block">
97
+ <button
98
+ className="icon-btn"
99
  onClick={() => insert(() => editor.chain().focus().toggleCodeBlock().run())}
100
+ aria-label="Code block"
101
  >
102
+ <Braces size={18} />
103
+ </button>
104
  </Tooltip>
105
+ <Tooltip title="Equation">
106
+ <button
107
+ className="icon-btn"
108
  onClick={() => insert(() => editor.chain().focus().insertBlockMath({ latex: "E = mc^2" }).run())}
109
+ aria-label="Equation"
110
  >
111
+ <Sigma size={18} />
112
+ </button>
113
  </Tooltip>
114
+ <Tooltip title="Divider">
115
+ <button
116
+ className="icon-btn"
117
  onClick={() => insert(() => editor.chain().focus().setHorizontalRule().run())}
118
+ aria-label="Divider"
119
  >
120
+ <Minus size={18} />
121
+ </button>
122
  </Tooltip>
123
+ </div>
124
+ )}
125
+ </div>
126
  </FloatingMenu>
127
  );
128
  }
frontend/src/editor/components/registry.ts CHANGED
@@ -4,8 +4,13 @@
4
  // Each entry describes a custom MDX component that can be used inside the
5
  // editor. Factories (factory.ts) consume these definitions to auto-generate
6
  // TipTap extensions, NodeViews, slash-menu items and MDX serializers.
 
 
 
7
  // ---------------------------------------------------------------------------
8
 
 
 
9
  export interface ComponentField {
10
  name: string;
11
  type: "string" | "boolean" | "select";
@@ -34,141 +39,87 @@ export interface ComponentDef {
34
  content?: string;
35
  }
36
 
37
- // ---------------------------------------------------------------------------
38
- // Registry — add new components here
39
- // ---------------------------------------------------------------------------
 
 
 
 
 
40
 
41
- export const COMPONENTS: ComponentDef[] = [
42
- {
43
- name: "accordion",
44
- tag: "Accordion",
45
- icon: "▸",
46
- label: "Accordion",
47
- description: "Collapsible content section",
48
- kind: "wrapper",
49
- fields: [
50
- { name: "title", type: "string", label: "Title", default: "Details", placeholder: "Section title…" },
51
- { name: "open", type: "boolean", label: "Open by default", default: false },
52
- ],
53
  },
54
- {
55
- name: "note",
56
- tag: "Note",
57
- icon: "ℹ",
58
- label: "Note / Callout",
59
- description: "Highlighted callout box",
60
- kind: "wrapper",
61
- fields: [
62
- { name: "title", type: "string", label: "Title", default: "", placeholder: "Optional title…" },
63
- { name: "emoji", type: "string", label: "Emoji", default: "", placeholder: "e.g. 💡" },
64
- {
65
- name: "variant",
66
- type: "select",
67
- label: "Variant",
68
- default: "neutral",
69
- options: ["neutral", "info", "success", "danger"],
70
- },
71
- ],
72
  },
73
- {
74
- name: "quoteBlock",
75
- tag: "Quote",
76
- icon: "❝",
77
- label: "Quote block",
78
- description: "Quote with author attribution",
79
- kind: "wrapper",
80
- fields: [
81
- { name: "author", type: "string", label: "Author", default: "", placeholder: "Author name…" },
82
- { name: "source", type: "string", label: "Source", default: "", placeholder: "Book, URL…" },
83
- ],
84
  },
85
- {
86
- name: "wide",
87
- tag: "Wide",
88
- icon: "↔",
89
- label: "Wide",
90
- description: "Wider-than-column container",
91
- kind: "wrapper",
92
- fields: [],
93
  },
94
- {
95
- name: "fullWidth",
96
- tag: "FullWidth",
97
- icon: "⟷",
98
- label: "Full width",
99
- description: "Full-width container",
100
- kind: "wrapper",
101
- fields: [],
102
  },
103
- {
104
- name: "sidenote",
105
- tag: "Sidenote",
106
- icon: "¶",
107
- label: "Sidenote",
108
- description: "Margin note alongside content",
109
- kind: "wrapper",
110
- fields: [],
111
  },
112
- {
113
- name: "reference",
114
- tag: "Reference",
115
- icon: "🏷",
116
- label: "Reference / Figure",
117
- description: "Captioned figure with anchor ID",
118
- kind: "wrapper",
119
- fields: [
120
- { name: "id", type: "string", label: "ID", default: "", placeholder: "fig-1" },
121
- { name: "caption", type: "string", label: "Caption", default: "", placeholder: "Figure caption…" },
122
- ],
123
  },
124
- {
125
- name: "htmlEmbed",
126
- tag: "HtmlEmbed",
127
- icon: "📊",
128
- label: "HTML Embed",
129
- description: "Embed an external HTML visualization",
130
- kind: "atomic",
131
- fields: [
132
- { name: "src", type: "string", label: "Source file", default: "", placeholder: "d3-chart.html" },
133
- { name: "title", type: "string", label: "Title", default: "", placeholder: "Chart title…" },
134
- { name: "desc", type: "string", label: "Description", default: "", placeholder: "Chart description…" },
135
- { name: "wide", type: "boolean", label: "Wide", default: false },
136
- { name: "downloadable", type: "boolean", label: "Downloadable", default: false },
137
- ],
138
  },
139
- {
140
- name: "hfUser",
141
- tag: "HfUser",
142
- icon: "👤",
143
- label: "HF User card",
144
- description: "Hugging Face user profile card",
145
- kind: "atomic",
146
- fields: [
147
- { name: "username", type: "string", label: "Username", default: "", placeholder: "username" },
148
- { name: "name", type: "string", label: "Display name", default: "", placeholder: "Full Name" },
149
- { name: "url", type: "string", label: "URL", default: "", placeholder: "https://huggingface.co/username" },
150
- ],
151
  },
152
- {
153
- name: "rawHtml",
154
- tag: "RawHtml",
155
- icon: "</>",
156
- label: "Raw HTML",
157
- description: "Inject raw HTML content",
158
- kind: "atomic",
159
- fields: [
160
- { name: "html", type: "string", label: "HTML", default: "", placeholder: "<div>…</div>" },
161
- ],
162
  },
163
- {
164
- name: "mermaid",
165
- tag: "Mermaid",
166
- icon: "◈",
167
- label: "Mermaid diagram",
168
- description: "Flowchart, sequence, Gantt, etc.",
169
- kind: "atomic",
170
- fields: [
171
- { name: "code", type: "string", label: "Code", default: "graph TD\n A[Start] --> B{Decision}\n B -->|Yes| C[OK]\n B -->|No| D[End]", placeholder: "graph TD\\n A --> B" },
172
- ],
173
  },
174
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  // Each entry describes a custom MDX component that can be used inside the
5
  // editor. Factories (factory.ts) consume these definitions to auto-generate
6
  // TipTap extensions, NodeViews, slash-menu items and MDX serializers.
7
+ //
8
+ // Structural data (name, kind, fields, content) comes from the shared module.
9
+ // This file adds UI metadata (tag, icon, label, description, placeholders).
10
  // ---------------------------------------------------------------------------
11
 
12
+ import { SHARED_COMPONENT_DEFS, type SharedComponentDef } from "#shared/component-defs";
13
+
14
  export interface ComponentField {
15
  name: string;
16
  type: "string" | "boolean" | "select";
 
39
  content?: string;
40
  }
41
 
42
+ // UI metadata per component (tag, icon, label, description, field labels/placeholders)
43
+ interface UIMeta {
44
+ tag: string;
45
+ icon: string;
46
+ label: string;
47
+ description: string;
48
+ fieldMeta: Record<string, { label: string; placeholder?: string }>;
49
+ }
50
 
51
+ const UI_META: Record<string, UIMeta> = {
52
+ accordion: {
53
+ tag: "Accordion", icon: "▸", label: "Accordion", description: "Collapsible content section",
54
+ fieldMeta: { title: { label: "Title", placeholder: "Section title…" }, open: { label: "Open by default" } },
 
 
 
 
 
 
 
 
55
  },
56
+ note: {
57
+ tag: "Note", icon: "ℹ", label: "Note / Callout", description: "Highlighted callout box",
58
+ fieldMeta: { title: { label: "Title", placeholder: "Optional title…" }, emoji: { label: "Emoji", placeholder: "e.g. 💡" }, variant: { label: "Variant" } },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  },
60
+ quoteBlock: {
61
+ tag: "Quote", icon: "❝", label: "Quote block", description: "Quote with author attribution",
62
+ fieldMeta: { author: { label: "Author", placeholder: "Author name…" }, source: { label: "Source", placeholder: "Book, URL…" } },
 
 
 
 
 
 
 
 
63
  },
64
+ wide: {
65
+ tag: "Wide", icon: "↔", label: "Wide", description: "Wider-than-column container",
66
+ fieldMeta: {},
 
 
 
 
 
67
  },
68
+ fullWidth: {
69
+ tag: "FullWidth", icon: "⟷", label: "Full width", description: "Full-width container",
70
+ fieldMeta: {},
 
 
 
 
 
71
  },
72
+ sidenote: {
73
+ tag: "Sidenote", icon: "¶", label: "Sidenote", description: "Margin note alongside content",
74
+ fieldMeta: {},
 
 
 
 
 
75
  },
76
+ reference: {
77
+ tag: "Reference", icon: "🏷", label: "Reference / Figure", description: "Captioned figure with anchor ID",
78
+ fieldMeta: { id: { label: "ID", placeholder: "fig-1" }, caption: { label: "Caption", placeholder: "Figure caption…" } },
 
 
 
 
 
 
 
 
79
  },
80
+ htmlEmbed: {
81
+ tag: "HtmlEmbed", icon: "📊", label: "HTML Embed", description: "Embed an external HTML visualization",
82
+ fieldMeta: { src: { label: "Source file", placeholder: "d3-chart.html" }, title: { label: "Title", placeholder: "Chart title…" }, desc: { label: "Description", placeholder: "Chart description…" }, wide: { label: "Wide" }, downloadable: { label: "Downloadable" } },
 
 
 
 
 
 
 
 
 
 
 
83
  },
84
+ hfUser: {
85
+ tag: "HfUser", icon: "👤", label: "HF User card", description: "Hugging Face user profile card",
86
+ fieldMeta: { username: { label: "Username", placeholder: "username" }, name: { label: "Display name", placeholder: "Full Name" }, url: { label: "URL", placeholder: "https://huggingface.co/username" } },
 
 
 
 
 
 
 
 
 
87
  },
88
+ rawHtml: {
89
+ tag: "RawHtml", icon: "</>", label: "Raw HTML", description: "Inject raw HTML content",
90
+ fieldMeta: { html: { label: "HTML", placeholder: "<div>…</div>" } },
 
 
 
 
 
 
 
91
  },
92
+ mermaid: {
93
+ tag: "Mermaid", icon: "◈", label: "Mermaid diagram", description: "Flowchart, sequence, Gantt, etc.",
94
+ fieldMeta: { code: { label: "Code", placeholder: "graph TD\\n A --> B" } },
 
 
 
 
 
 
 
95
  },
96
+ };
97
+
98
+ function buildComponentDef(shared: SharedComponentDef): ComponentDef {
99
+ const ui = UI_META[shared.name];
100
+ if (!ui) throw new Error(`Missing UI_META for component "${shared.name}"`);
101
+
102
+ return {
103
+ name: shared.name,
104
+ tag: ui.tag,
105
+ icon: ui.icon,
106
+ label: ui.label,
107
+ description: ui.description,
108
+ kind: shared.kind,
109
+ content: shared.content,
110
+ fields: shared.fields.map((f) => ({
111
+ name: f.name,
112
+ type: f.type,
113
+ default: f.default,
114
+ options: f.options,
115
+ label: ui.fieldMeta[f.name]?.label ?? f.name,
116
+ placeholder: ui.fieldMeta[f.name]?.placeholder,
117
+ })),
118
+ };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Registry - built from shared definitions + UI metadata
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export const COMPONENTS: ComponentDef[] = SHARED_COMPONENT_DEFS.map(buildComponentDef);
frontend/src/editor/frontmatter/FrontmatterHero.tsx CHANGED
@@ -1,14 +1,6 @@
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
- import {
3
- Box,
4
- Chip,
5
- IconButton,
6
- TextField,
7
- Tooltip,
8
- Typography,
9
- } from "@mui/material";
10
- import AddIcon from "@mui/icons-material/Add";
11
- import CloseIcon from "@mui/icons-material/Close";
12
  import { useFrontmatter } from "./useFrontmatter";
13
  import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
14
 
@@ -32,7 +24,6 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
32
 
33
  return (
34
  <div>
35
- {/* Hero section - uses .hero from _hero.css (same as HeroArticle.astro) */}
36
  <section className="hero">
37
  <EditableText
38
  value={data.title}
@@ -50,11 +41,9 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
50
  />
51
  </section>
52
 
53
- {/* Meta bar - uses .meta from _hero.css (same as HeroArticle.astro) */}
54
  {hasMeta && (
55
  <header className="meta" aria-label="Article meta information">
56
  <div className="meta-container">
57
- {/* Authors cell */}
58
  <div className="meta-container-cell">
59
  <h3>Authors</h3>
60
  <ul className="authors">
@@ -79,20 +68,20 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
79
  </li>
80
  ))}
81
  <li className="author-add-btn">
82
- <Tooltip title="Add author" arrow>
83
- <IconButton
84
- size="small"
85
  onClick={() => setShowAuthorForm(true)}
86
- sx={{ color: "var(--muted-color)", p: 0.25 }}
 
87
  >
88
- <AddIcon sx={{ fontSize: 14 }} />
89
- </IconButton>
90
  </Tooltip>
91
  </li>
92
  </ul>
93
  </div>
94
 
95
- {/* Affiliations cell */}
96
  {hasAffiliations && (
97
  <div className="meta-container-cell">
98
  <h3>Affiliations</h3>
@@ -124,7 +113,6 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
124
  </div>
125
  )}
126
 
127
- {/* Published cell */}
128
  <div className="meta-container-cell">
129
  <h3>Published</h3>
130
  <EditableText
@@ -135,7 +123,6 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
135
  />
136
  </div>
137
 
138
- {/* DOI cell */}
139
  <div className="meta-container-cell">
140
  <h3>DOI</h3>
141
  <EditableText
@@ -145,12 +132,10 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
145
  inline
146
  />
147
  </div>
148
-
149
  </div>
150
  </header>
151
  )}
152
 
153
- {/* Author forms (editor-only, MUI is fine here) */}
154
  <div style={{ maxWidth: 680, margin: "0 auto", padding: "0 16px" }}>
155
  {showAuthorForm && (
156
  <AuthorInlineForm
@@ -187,8 +172,6 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
187
  );
188
  }
189
 
190
- // ---- Editable text field (click-to-edit, editor-only behavior) ----
191
-
192
  interface EditableTextProps {
193
  value: string;
194
  placeholder: string;
@@ -237,23 +220,28 @@ function EditableText({ value, placeholder, onChange, className, multiline, inli
237
  const isEmpty = !value;
238
 
239
  if (editing) {
 
240
  return (
241
  <span className={className} style={{ display: inline ? "inline-flex" : "flex" }}>
242
- <TextField
243
- inputRef={inputRef}
 
244
  value={draft}
245
  onChange={(e) => setDraft(e.target.value)}
246
  onBlur={commit}
247
  onKeyDown={handleKeyDown}
248
- multiline={multiline}
249
- fullWidth={!inline}
250
- variant="standard"
251
  placeholder={placeholder}
252
- slotProps={{
253
- input: {
254
- disableUnderline: true,
255
- sx: { p: 0, m: 0, font: "inherit", color: "inherit", textAlign: "inherit" },
256
- },
 
 
 
 
 
 
257
  }}
258
  />
259
  </span>
@@ -275,8 +263,6 @@ function EditableText({ value, placeholder, onChange, className, multiline, inli
275
  );
276
  }
277
 
278
- // ---- Author inline form (editor-only, MUI is fine) ----
279
-
280
  function AuthorInlineForm({
281
  initial,
282
  affiliations,
@@ -314,78 +300,62 @@ function AuthorInlineForm({
314
  };
315
 
316
  return (
317
- <Box
318
- sx={{
319
- mt: 2,
320
- p: 2,
321
- border: "1px solid",
322
- borderColor: "divider",
323
- borderRadius: 2,
324
- bgcolor: "background.paper",
325
- display: "flex",
326
- flexDirection: "column",
327
- gap: 1.5,
328
- }}
329
- >
330
- <Typography variant="caption" sx={{ fontWeight: 600, color: "text.secondary", textTransform: "uppercase", letterSpacing: "0.05em" }}>
331
  {initial ? "Edit author" : "Add author"}
332
- </Typography>
333
 
334
- <Box sx={{ display: "flex", gap: 1 }}>
335
- <TextField
336
- inputRef={nameRef}
337
- size="small"
338
- label="Name"
339
  value={name}
340
  onChange={(e) => setName(e.target.value)}
341
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
342
- fullWidth
343
  />
344
- <TextField
345
- size="small"
346
- label="URL (optional)"
347
  value={url}
348
  onChange={(e) => setUrl(e.target.value)}
349
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
350
- fullWidth
351
  />
352
- </Box>
353
 
354
  {affiliations.length > 0 && (
355
- <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
356
  {affiliations.map((aff, i) => (
357
- <Chip
358
  key={`aff-select-${i}`}
359
- label={`${i + 1}. ${aff.name}`}
360
- size="small"
361
- variant={affIndices.includes(i + 1) ? "filled" : "outlined"}
362
  onClick={() => toggleAff(i + 1)}
363
- sx={{ fontSize: "0.7rem", height: 22, cursor: "pointer" }}
364
- />
 
365
  ))}
366
- </Box>
367
  )}
368
 
369
- <TextField
370
- size="small"
371
- label="New affiliation (optional)"
372
- placeholder="e.g. Hugging Face"
373
  value={newAffName}
374
  onChange={(e) => setNewAffName(e.target.value)}
375
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
376
  />
377
 
378
- <Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end" }}>
379
- <Chip label="Cancel" size="small" onClick={onCancel} sx={{ cursor: "pointer" }} />
380
- <Chip
381
- label={initial ? "Save" : "Add"}
382
- size="small"
383
- color="primary"
384
  onClick={handleSubmit}
385
  disabled={!name.trim()}
386
- sx={{ cursor: "pointer" }}
387
- />
388
- </Box>
389
- </Box>
 
390
  );
391
  }
 
1
  import { useState, useRef, useEffect, type KeyboardEvent } from "react";
2
+ import { Tooltip } from "../../components/Tooltip";
3
+ import { Plus, X } from "lucide-react";
 
 
 
 
 
 
 
 
4
  import { useFrontmatter } from "./useFrontmatter";
5
  import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
6
 
 
24
 
25
  return (
26
  <div>
 
27
  <section className="hero">
28
  <EditableText
29
  value={data.title}
 
41
  />
42
  </section>
43
 
 
44
  {hasMeta && (
45
  <header className="meta" aria-label="Article meta information">
46
  <div className="meta-container">
 
47
  <div className="meta-container-cell">
48
  <h3>Authors</h3>
49
  <ul className="authors">
 
68
  </li>
69
  ))}
70
  <li className="author-add-btn">
71
+ <Tooltip title="Add author">
72
+ <button
73
+ className="icon-btn"
74
  onClick={() => setShowAuthorForm(true)}
75
+ aria-label="Add author"
76
+ style={{ padding: 2 }}
77
  >
78
+ <Plus size={14} />
79
+ </button>
80
  </Tooltip>
81
  </li>
82
  </ul>
83
  </div>
84
 
 
85
  {hasAffiliations && (
86
  <div className="meta-container-cell">
87
  <h3>Affiliations</h3>
 
113
  </div>
114
  )}
115
 
 
116
  <div className="meta-container-cell">
117
  <h3>Published</h3>
118
  <EditableText
 
123
  />
124
  </div>
125
 
 
126
  <div className="meta-container-cell">
127
  <h3>DOI</h3>
128
  <EditableText
 
132
  inline
133
  />
134
  </div>
 
135
  </div>
136
  </header>
137
  )}
138
 
 
139
  <div style={{ maxWidth: 680, margin: "0 auto", padding: "0 16px" }}>
140
  {showAuthorForm && (
141
  <AuthorInlineForm
 
172
  );
173
  }
174
 
 
 
175
  interface EditableTextProps {
176
  value: string;
177
  placeholder: string;
 
220
  const isEmpty = !value;
221
 
222
  if (editing) {
223
+ const Tag = multiline ? "textarea" : "input";
224
  return (
225
  <span className={className} style={{ display: inline ? "inline-flex" : "flex" }}>
226
+ <Tag
227
+ ref={inputRef as any}
228
+ className="form-input"
229
  value={draft}
230
  onChange={(e) => setDraft(e.target.value)}
231
  onBlur={commit}
232
  onKeyDown={handleKeyDown}
 
 
 
233
  placeholder={placeholder}
234
+ style={{
235
+ font: "inherit",
236
+ color: "inherit",
237
+ textAlign: "inherit",
238
+ background: "transparent",
239
+ border: "none",
240
+ padding: 0,
241
+ margin: 0,
242
+ width: "100%",
243
+ outline: "none",
244
+ resize: multiline ? "vertical" : "none",
245
  }}
246
  />
247
  </span>
 
263
  );
264
  }
265
 
 
 
266
  function AuthorInlineForm({
267
  initial,
268
  affiliations,
 
300
  };
301
 
302
  return (
303
+ <div className="author-form">
304
+ <span className="author-form__title">
 
 
 
 
 
 
 
 
 
 
 
 
305
  {initial ? "Edit author" : "Add author"}
306
+ </span>
307
 
308
+ <div className="author-form__row">
309
+ <input
310
+ ref={nameRef}
311
+ className="form-input"
312
+ placeholder="Name"
313
  value={name}
314
  onChange={(e) => setName(e.target.value)}
315
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
 
316
  />
317
+ <input
318
+ className="form-input"
319
+ placeholder="URL (optional)"
320
  value={url}
321
  onChange={(e) => setUrl(e.target.value)}
322
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
 
323
  />
324
+ </div>
325
 
326
  {affiliations.length > 0 && (
327
+ <div className="author-form__chips">
328
  {affiliations.map((aff, i) => (
329
+ <span
330
  key={`aff-select-${i}`}
331
+ className={`chip chip--clickable ${affIndices.includes(i + 1) ? "" : "chip--outlined"}`}
332
+ style={affIndices.includes(i + 1) ? { background: "var(--primary-color)", color: "#000" } : {}}
 
333
  onClick={() => toggleAff(i + 1)}
334
+ >
335
+ {i + 1}. {aff.name}
336
+ </span>
337
  ))}
338
+ </div>
339
  )}
340
 
341
+ <input
342
+ className="form-input"
343
+ placeholder="New affiliation (optional), e.g. Hugging Face"
 
344
  value={newAffName}
345
  onChange={(e) => setNewAffName(e.target.value)}
346
  onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
347
  />
348
 
349
+ <div className="author-form__actions">
350
+ <button className="btn" onClick={onCancel}>Cancel</button>
351
+ <button
352
+ className="btn btn--primary"
 
 
353
  onClick={handleSubmit}
354
  disabled={!name.trim()}
355
+ >
356
+ {initial ? "Save" : "Add"}
357
+ </button>
358
+ </div>
359
+ </div>
360
  );
361
  }
frontend/src/editor/frontmatter/HueSlider.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { useRef, useState, useEffect, useCallback } from "react";
2
- import { Box, Typography } from "@mui/material";
3
 
4
  const L = 0.75;
5
  const C = 0.12;
@@ -54,7 +53,6 @@ function getColorName(hue: number): string {
54
  return best;
55
  }
56
 
57
- // OKLCH -> sRGB -> hex
58
  function oklchToHex(l: number, c: number, h: number): string {
59
  const hRad = (h * Math.PI) / 180;
60
  const a = c * Math.cos(hRad);
@@ -115,7 +113,6 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
115
  setDragging(false);
116
  }, []);
117
 
118
- // Keyboard support
119
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
120
  const step = e.shiftKey ? 10 : 2;
121
  if (e.key === "ArrowLeft") { e.preventDefault(); onChange(((hue - step) % 360 + 360) % 360); }
@@ -123,32 +120,29 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
123
  }, [hue, onChange]);
124
 
125
  return (
126
- <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
127
- {/* Swatch + info */}
128
- <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
129
- <Box
130
- sx={{
131
  width: 44,
132
  height: 44,
133
- borderRadius: "8px",
134
- bgcolor: hexColor,
135
- border: "1px solid",
136
- borderColor: "divider",
137
  flexShrink: 0,
138
  }}
139
  />
140
- <Box sx={{ minWidth: 0 }}>
141
- <Typography variant="body2" sx={{ fontWeight: 700, fontSize: "0.8rem", lineHeight: 1.3 }}>
142
  {colorName}
143
- </Typography>
144
- <Typography variant="caption" sx={{ color: "text.secondary", fontSize: "0.7rem", fontFamily: "monospace" }}>
145
- {hexColor.toUpperCase()} - {Math.round(hue)}°
146
- </Typography>
147
- </Box>
148
- </Box>
149
-
150
- {/* Hue slider */}
151
- <Box
152
  ref={sliderRef}
153
  role="slider"
154
  tabIndex={0}
@@ -160,21 +154,18 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
160
  onPointerMove={handlePointerMove}
161
  onPointerUp={handlePointerUp}
162
  onKeyDown={handleKeyDown}
163
- sx={{
164
  position: "relative",
165
  height: 16,
166
- borderRadius: "10px",
167
- border: "1px solid",
168
- borderColor: "divider",
169
  background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
170
  cursor: "ew-resize",
171
  touchAction: "none",
172
- "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 2 },
173
  }}
174
  >
175
- {/* Knob */}
176
- <Box
177
- sx={{
178
  position: "absolute",
179
  top: "50%",
180
  left: `${(hue / 360) * 100}%`,
@@ -183,13 +174,13 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
183
  borderRadius: "50%",
184
  border: "2px solid #fff",
185
  transform: "translate(-50%, -50%)",
186
- bgcolor: hexColor,
187
  boxShadow: "0 0 0 1px rgba(0,0,0,0.15), 0 1px 3px rgba(0,0,0,0.3)",
188
  pointerEvents: "none",
189
  }}
190
  />
191
- </Box>
192
- </Box>
193
  );
194
  }
195
 
 
1
+ import { useRef, useState, useCallback } from "react";
 
2
 
3
  const L = 0.75;
4
  const C = 0.12;
 
53
  return best;
54
  }
55
 
 
56
  function oklchToHex(l: number, c: number, h: number): string {
57
  const hRad = (h * Math.PI) / 180;
58
  const a = c * Math.cos(hRad);
 
113
  setDragging(false);
114
  }, []);
115
 
 
116
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
117
  const step = e.shiftKey ? 10 : 2;
118
  if (e.key === "ArrowLeft") { e.preventDefault(); onChange(((hue - step) % 360 + 360) % 360); }
 
120
  }, [hue, onChange]);
121
 
122
  return (
123
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
124
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
125
+ <div
126
+ style={{
 
127
  width: 44,
128
  height: 44,
129
+ borderRadius: 8,
130
+ backgroundColor: hexColor,
131
+ border: "1px solid var(--ed-border)",
 
132
  flexShrink: 0,
133
  }}
134
  />
135
+ <div style={{ minWidth: 0 }}>
136
+ <div style={{ fontWeight: 700, fontSize: "0.8rem", lineHeight: 1.3, color: "var(--ed-text)" }}>
137
  {colorName}
138
+ </div>
139
+ <div style={{ color: "var(--ed-text-secondary)", fontSize: "0.7rem", fontFamily: "monospace" }}>
140
+ {hexColor.toUpperCase()} - {Math.round(hue)}&deg;
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <div
 
146
  ref={sliderRef}
147
  role="slider"
148
  tabIndex={0}
 
154
  onPointerMove={handlePointerMove}
155
  onPointerUp={handlePointerUp}
156
  onKeyDown={handleKeyDown}
157
+ style={{
158
  position: "relative",
159
  height: 16,
160
+ borderRadius: 10,
161
+ border: "1px solid var(--ed-border)",
 
162
  background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
163
  cursor: "ew-resize",
164
  touchAction: "none",
 
165
  }}
166
  >
167
+ <div
168
+ style={{
 
169
  position: "absolute",
170
  top: "50%",
171
  left: `${(hue / 360) * 100}%`,
 
174
  borderRadius: "50%",
175
  border: "2px solid #fff",
176
  transform: "translate(-50%, -50%)",
177
+ backgroundColor: hexColor,
178
  boxShadow: "0 0 0 1px rgba(0,0,0,0.15), 0 1px 3px rgba(0,0,0,0.3)",
179
  pointerEvents: "none",
180
  }}
181
  />
182
+ </div>
183
+ </div>
184
  );
185
  }
186
 
frontend/src/editor/frontmatter/SettingsDrawer.tsx CHANGED
@@ -1,21 +1,9 @@
1
- import {
2
- Drawer,
3
- Box,
4
- Typography,
5
- TextField,
6
- Select,
7
- MenuItem,
8
- FormControlLabel,
9
- Switch,
10
- Divider,
11
- IconButton,
12
- } from "@mui/material";
13
- import CloseIcon from "@mui/icons-material/Close";
14
- import { useState, useEffect } from "react";
15
  import { useFrontmatter } from "./useFrontmatter";
16
  import type { FrontmatterStore, FrontmatterData } from "./frontmatter-store";
17
  import type * as Y from "yjs";
18
- import { HueSlider, oklchToHex, OKLCH_L, OKLCH_C } from "./HueSlider";
19
 
20
  interface SettingsDrawerProps {
21
  open: boolean;
@@ -42,183 +30,160 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
42
  return () => settingsMap.unobserve(sync);
43
  }, [settingsMap]);
44
 
 
 
 
 
 
 
 
 
 
 
 
45
  if (!data) return null;
46
 
47
  return (
48
- <Drawer
49
- anchor="right"
50
- open={open}
51
- onClose={onClose}
52
- slotProps={{
53
- paper: {
54
- sx: {
55
- width: 380,
56
- bgcolor: "background.paper",
57
- backgroundImage: "none",
58
- borderLeft: "1px solid",
59
- borderColor: "divider",
60
- },
61
- },
62
- }}
63
- >
64
- <Box sx={{ px: 2.5, py: 2, display: "flex", flexDirection: "column", height: "100%" }}>
65
- {/* Header */}
66
- <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
67
- <Typography variant="subtitle2" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", color: "text.secondary", fontSize: "0.75rem" }}>
68
- Article settings
69
- </Typography>
70
- <IconButton size="small" onClick={onClose} sx={{ color: "text.disabled" }}>
71
- <CloseIcon sx={{ fontSize: 16 }} />
72
- </IconButton>
73
- </Box>
74
-
75
- <Box sx={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2.5 }}>
76
- {/* Template */}
77
- <FieldGroup label="Template">
78
- <Select
79
- size="small"
80
- fullWidth
81
- value={data.template}
82
- onChange={(e) => update("template", e.target.value as FrontmatterData["template"])}
83
- >
84
- <MenuItem value="article">Article (full layout)</MenuItem>
85
- <MenuItem value="paper">Paper (single column)</MenuItem>
86
- </Select>
87
- </FieldGroup>
88
-
89
- {/* Description (SEO) */}
90
- <FieldGroup label="Description (SEO)">
91
- <TextField
92
- size="small"
93
- fullWidth
94
- multiline
95
- minRows={2}
96
- maxRows={4}
97
- placeholder="Short description for meta tags..."
98
- value={data.description}
99
- onChange={(e) => update("description", e.target.value)}
100
- />
101
- </FieldGroup>
102
-
103
- {/* Banner */}
104
- <FieldGroup label="Banner embed">
105
- <TextField
106
- size="small"
107
- fullWidth
108
- placeholder="banner.html"
109
- value={data.banner}
110
- onChange={(e) => update("banner", e.target.value)}
111
- />
112
- </FieldGroup>
113
-
114
- {/* Citation style (collaborative via Y.Map) */}
115
- <FieldGroup label="Citation style">
116
- <Select
117
- size="small"
118
- fullWidth
119
- value={citationStyle}
120
- onChange={(e) => {
121
- const val = e.target.value;
122
- setCitationStyle(val);
123
- settingsMap?.set("citationStyle", val);
124
- }}
125
- >
126
- <MenuItem value="apa">APA (7th edition)</MenuItem>
127
- <MenuItem value="ieee">IEEE</MenuItem>
128
- <MenuItem value="vancouver">Vancouver</MenuItem>
129
- <MenuItem value="chicago-author-date">Chicago (author-date)</MenuItem>
130
- <MenuItem value="harvard1">Harvard</MenuItem>
131
- </Select>
132
- </FieldGroup>
133
-
134
- {/* Primary color (hue slider like the template) */}
135
- <FieldGroup label="Primary color">
136
- <HueSlider
137
- hue={hue}
138
- onChange={(h) => {
139
- setHue(h);
140
- settingsMap?.set("primaryHue", h);
141
- }}
142
- />
143
- </FieldGroup>
144
-
145
- <Divider />
146
-
147
- {/* Toggles */}
148
- <FormControlLabel
149
- sx={{ ml: 0 }}
150
- control={
151
- <Switch
152
- size="small"
153
- checked={data.showPdf}
154
- onChange={(_, v) => update("showPdf", v)}
155
  />
156
- }
157
- label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>Show PDF download</Typography>}
158
- />
159
-
160
- <FormControlLabel
161
- sx={{ ml: 0 }}
162
- control={
163
- <Switch
164
- size="small"
165
- checked={data.tableOfContentsAutoCollapse}
166
- onChange={(_, v) => update("tableOfContentsAutoCollapse", v)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  />
168
- }
169
- label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>Auto-collapse TOC</Typography>}
170
- />
171
-
172
- <Divider />
173
-
174
- {/* Licence / SEO / PDF Pro */}
175
- <FieldGroup label="Licence">
176
- <TextField
177
- size="small"
178
- fullWidth
179
- multiline
180
- minRows={2}
181
- placeholder="e.g. CC BY 4.0 (HTML allowed)"
182
- value={data.licence}
183
- onChange={(e) => update("licence", e.target.value)}
184
  />
185
- </FieldGroup>
186
-
187
- <FieldGroup label="SEO thumbnail image">
188
- <TextField
189
- size="small"
190
- fullWidth
191
- placeholder="https://..."
192
- value={data.seoThumbImage}
193
- onChange={(e) => update("seoThumbImage", e.target.value)}
194
  />
195
- </FieldGroup>
196
-
197
- <FormControlLabel
198
- sx={{ ml: 0 }}
199
- control={
200
- <Switch
201
- size="small"
202
- checked={data.pdfProOnly}
203
- onChange={(_, v) => update("pdfProOnly", v)}
 
204
  />
205
- }
206
- label={<Typography variant="body2" sx={{ fontSize: "0.85rem" }}>PDF Pro only</Typography>}
207
- />
208
- </Box>
209
- </Box>
210
- </Drawer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  );
212
  }
213
 
214
  function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
215
  return (
216
- <Box>
217
- <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 500, mb: 0.5, display: "block", fontSize: "0.75rem" }}>
218
- {label}
219
- </Typography>
220
  {children}
221
- </Box>
222
  );
223
  }
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { X } from "lucide-react";
2
+ import { useState, useEffect, useCallback } from "react";
 
 
 
 
 
 
 
 
 
 
 
 
3
  import { useFrontmatter } from "./useFrontmatter";
4
  import type { FrontmatterStore, FrontmatterData } from "./frontmatter-store";
5
  import type * as Y from "yjs";
6
+ import { HueSlider } from "./HueSlider";
7
 
8
  interface SettingsDrawerProps {
9
  open: boolean;
 
30
  return () => settingsMap.unobserve(sync);
31
  }, [settingsMap]);
32
 
33
+ const handleEscape = useCallback((e: KeyboardEvent) => {
34
+ if (e.key === "Escape") onClose();
35
+ }, [onClose]);
36
+
37
+ useEffect(() => {
38
+ if (open) {
39
+ document.addEventListener("keydown", handleEscape);
40
+ return () => document.removeEventListener("keydown", handleEscape);
41
+ }
42
+ }, [open, handleEscape]);
43
+
44
  if (!data) return null;
45
 
46
  return (
47
+ <>
48
+ <div className={`drawer-backdrop ${open ? "open" : ""}`} onClick={onClose} />
49
+ <aside className={`drawer-panel ${open ? "open" : ""}`}>
50
+ <div className="settings-drawer">
51
+ <div className="settings-drawer__header">
52
+ <span className="settings-drawer__title">Article settings</span>
53
+ <button className="icon-btn" onClick={onClose} aria-label="Close">
54
+ <X size={16} />
55
+ </button>
56
+ </div>
57
+
58
+ <div className="settings-drawer__body">
59
+ <FieldGroup label="Template">
60
+ <select
61
+ className="form-select"
62
+ value={data.template}
63
+ onChange={(e) => update("template", e.target.value as FrontmatterData["template"])}
64
+ >
65
+ <option value="article">Article (full layout)</option>
66
+ <option value="paper">Paper (single column)</option>
67
+ </select>
68
+ </FieldGroup>
69
+
70
+ <FieldGroup label="Description (SEO)">
71
+ <textarea
72
+ className="form-input"
73
+ rows={3}
74
+ placeholder="Short description for meta tags..."
75
+ value={data.description}
76
+ onChange={(e) => update("description", e.target.value)}
77
+ />
78
+ </FieldGroup>
79
+
80
+ <FieldGroup label="Banner embed">
81
+ <input
82
+ className="form-input"
83
+ placeholder="banner.html"
84
+ value={data.banner}
85
+ onChange={(e) => update("banner", e.target.value)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  />
87
+ </FieldGroup>
88
+
89
+ <FieldGroup label="Citation style">
90
+ <select
91
+ className="form-select"
92
+ value={citationStyle}
93
+ onChange={(e) => {
94
+ const val = e.target.value;
95
+ setCitationStyle(val);
96
+ settingsMap?.set("citationStyle", val);
97
+ }}
98
+ >
99
+ <option value="apa">APA (7th edition)</option>
100
+ <option value="ieee">IEEE</option>
101
+ <option value="vancouver">Vancouver</option>
102
+ <option value="chicago-author-date">Chicago (author-date)</option>
103
+ <option value="harvard1">Harvard</option>
104
+ </select>
105
+ </FieldGroup>
106
+
107
+ <FieldGroup label="Primary color">
108
+ <HueSlider
109
+ hue={hue}
110
+ onChange={(h) => {
111
+ setHue(h);
112
+ settingsMap?.set("primaryHue", h);
113
+ }}
114
  />
115
+ </FieldGroup>
116
+
117
+ <hr className="divider-h" />
118
+
119
+ <SwitchField
120
+ label="Show PDF download"
121
+ checked={data.showPdf}
122
+ onChange={(v) => update("showPdf", v)}
 
 
 
 
 
 
 
 
123
  />
124
+
125
+ <SwitchField
126
+ label="Auto-collapse TOC"
127
+ checked={data.tableOfContentsAutoCollapse}
128
+ onChange={(v) => update("tableOfContentsAutoCollapse", v)}
 
 
 
 
129
  />
130
+
131
+ <hr className="divider-h" />
132
+
133
+ <FieldGroup label="Licence">
134
+ <textarea
135
+ className="form-input"
136
+ rows={2}
137
+ placeholder="e.g. CC BY 4.0 (HTML allowed)"
138
+ value={data.licence}
139
+ onChange={(e) => update("licence", e.target.value)}
140
  />
141
+ </FieldGroup>
142
+
143
+ <FieldGroup label="SEO thumbnail image">
144
+ <input
145
+ className="form-input"
146
+ placeholder="https://..."
147
+ value={data.seoThumbImage}
148
+ onChange={(e) => update("seoThumbImage", e.target.value)}
149
+ />
150
+ </FieldGroup>
151
+
152
+ <SwitchField
153
+ label="PDF Pro only"
154
+ checked={data.pdfProOnly}
155
+ onChange={(v) => update("pdfProOnly", v)}
156
+ />
157
+ </div>
158
+ </div>
159
+ </aside>
160
+ </>
161
  );
162
  }
163
 
164
  function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
165
  return (
166
+ <div>
167
+ <label className="field-label">{label}</label>
 
 
168
  {children}
169
+ </div>
170
  );
171
  }
172
 
173
+ function SwitchField({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
174
+ return (
175
+ <label className="form-switch-label">
176
+ <span className="form-switch">
177
+ <input
178
+ type="checkbox"
179
+ role="switch"
180
+ checked={checked}
181
+ onChange={(e) => onChange(e.target.checked)}
182
+ />
183
+ <span className="form-switch__track" />
184
+ <span className="form-switch__thumb" />
185
+ </span>
186
+ {label}
187
+ </label>
188
+ );
189
+ }
frontend/src/main.tsx CHANGED
@@ -1,6 +1,5 @@
1
  import React from "react";
2
  import ReactDOM from "react-dom/client";
3
- import { CssBaseline } from "@mui/material";
4
  import App from "./App";
5
 
6
  // Template foundation (source of truth - same as research-article-template)
@@ -16,9 +15,11 @@ import "./styles/components/_card.css";
16
  import "./styles/components/_mermaid.css";
17
  import "./styles/components/_hero.css";
18
  import "./styles/components/_toc.css";
19
- // _button.css and _form.css are NOT imported here: they style bare
20
- // <button>, <input>, <select>, <label> globally and would override MUI.
21
- // The publisher injects them in the static HTML where there is no MUI.
 
 
22
 
23
  // Editor extensions
24
  import "./styles/_editor-tokens.css";
@@ -28,7 +29,6 @@ import "./styles/editing.css";
28
 
29
  ReactDOM.createRoot(document.getElementById("root")!).render(
30
  <React.StrictMode>
31
- <CssBaseline />
32
  <App />
33
  </React.StrictMode>
34
  );
 
1
  import React from "react";
2
  import ReactDOM from "react-dom/client";
 
3
  import App from "./App";
4
 
5
  // Template foundation (source of truth - same as research-article-template)
 
15
  import "./styles/components/_mermaid.css";
16
  import "./styles/components/_hero.css";
17
  import "./styles/components/_toc.css";
18
+ import "./styles/components/_button.css";
19
+ import "./styles/components/_form.css";
20
+
21
+ // Editor chrome UI
22
+ import "./styles/_ui.css";
23
 
24
  // Editor extensions
25
  import "./styles/_editor-tokens.css";
 
29
 
30
  ReactDOM.createRoot(document.getElementById("root")!).render(
31
  <React.StrictMode>
 
32
  <App />
33
  </React.StrictMode>
34
  );
frontend/src/styles/_base.css CHANGED
@@ -1,14 +1,6 @@
1
  @import "https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,200..900;1,200..900&display=swap";
2
 
3
- /*
4
- * Template originally sets html { font-size; line-height; background-color;
5
- * overflow-x }. In the editor we scope font-size/line-height to the article
6
- * area so MUI portals (Dialog, Menu, Tooltip) keep their own defaults.
7
- */
8
-
9
- .content-grid,
10
- .hero,
11
- .meta {
12
  font-size: 16px;
13
  line-height: 1.6;
14
  }
 
1
  @import "https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,200..900;1,200..900&display=swap";
2
 
3
+ html {
 
 
 
 
 
 
 
 
4
  font-size: 16px;
5
  line-height: 1.6;
6
  }
frontend/src/styles/_publisher.css ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================ */
2
+ /* Publisher-only CSS overrides */
3
+ /* Injected into the published static HTML. Not used in the editor. */
4
+ /* ============================================================================ */
5
+
6
+ /* Published page global reset */
7
+ body { font-family: var(--default-font-family); color: var(--text-color); }
8
+ html { font-size: 16px; line-height: 1.6; background-color: var(--page-bg); overflow-x: hidden; scroll-behavior: smooth; scroll-padding-top: 80px; }
9
+
10
+ /* Theme toggle icon wrapper (from ThemeToggle.astro) */
11
+ #theme-toggle .icon-wrapper {
12
+ display: grid;
13
+ place-items: center;
14
+ width: 20px;
15
+ height: 20px;
16
+ }
17
+ #theme-toggle .icon-wrapper .icon {
18
+ grid-area: 1 / 1;
19
+ filter: none !important;
20
+ }
21
+ #theme-toggle .icon-wrapper.animated .icon {
22
+ transition: opacity 0.35s ease;
23
+ }
24
+ #theme-toggle .icon-wrapper.spin-cw { animation: spin-cw 0.5s cubic-bezier(0.4,0,0.2,1); }
25
+ #theme-toggle .icon-wrapper.spin-ccw { animation: spin-ccw 0.5s cubic-bezier(0.4,0,0.2,1); }
26
+ @keyframes spin-cw { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
27
+ @keyframes spin-ccw { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
28
+
29
+ /* Note component - aligned with template Note.astro */
30
+ div[data-component="note"] {
31
+ background: var(--surface-bg);
32
+ border-left: 2px solid var(--border-color);
33
+ border-top-right-radius: 8px;
34
+ border-bottom-right-radius: 8px;
35
+ padding: 20px 18px;
36
+ margin: var(--block-spacing-y, 1em) 0;
37
+ }
38
+ div[data-component="note"][variant="info"] {
39
+ border-left-color: #f39c12;
40
+ background: color-mix(in oklab, #f39c12 10%, var(--surface-bg));
41
+ }
42
+ div[data-component="note"][variant="success"] {
43
+ border-left-color: #2ecc71;
44
+ background: color-mix(in oklab, #2ecc71 8%, var(--surface-bg));
45
+ }
46
+ div[data-component="note"][variant="danger"] {
47
+ border-left-color: #e74c3c;
48
+ background: color-mix(in oklab, #e74c3c 8%, var(--surface-bg));
49
+ }
50
+ div[data-component="note"] .note__title {
51
+ font-size: 16px;
52
+ font-weight: 600;
53
+ color: var(--text-color);
54
+ margin-bottom: 6px;
55
+ }
56
+ div[data-component="note"] > *:first-child { margin-top: 0; }
57
+ div[data-component="note"] > *:last-child { margin-bottom: 0; }
58
+
59
+ /* Quote component - aligned with template Quote.astro */
60
+ div[data-component="quoteBlock"] {
61
+ position: relative;
62
+ margin: 32px 0;
63
+ max-width: 600px;
64
+ padding: 0;
65
+ border: none;
66
+ background: none;
67
+ }
68
+ div[data-component="quoteBlock"]::before {
69
+ content: '\201C';
70
+ position: absolute;
71
+ top: -24px;
72
+ left: -30px;
73
+ font-size: 8rem;
74
+ font-weight: 400;
75
+ color: var(--text-color);
76
+ opacity: 0.05;
77
+ z-index: -1;
78
+ line-height: 1;
79
+ pointer-events: none;
80
+ }
81
+ div[data-component="quoteBlock"] .quote-text {
82
+ font-size: 1.5rem;
83
+ line-height: 1.4;
84
+ font-weight: 400;
85
+ letter-spacing: -0.01em;
86
+ color: var(--text-color);
87
+ }
88
+ div[data-component="quoteBlock"] .quote-footer {
89
+ font-size: 0.875rem;
90
+ color: var(--muted-color);
91
+ margin-top: 12px;
92
+ }
93
+ div[data-component="quoteBlock"] .quote-author::before {
94
+ content: '\2014\00A0';
95
+ font-style: normal;
96
+ }
97
+ div[data-component="quoteBlock"] .quote-author {
98
+ font-weight: 500;
99
+ font-style: italic;
100
+ color: var(--text-color);
101
+ opacity: 0.85;
102
+ }
103
+ div[data-component="quoteBlock"] .quote-source {
104
+ font-weight: 500;
105
+ font-style: italic;
106
+ color: var(--text-color);
107
+ opacity: 0.85;
108
+ }
109
+ div[data-component="quoteBlock"] > *:first-child { margin-top: 0; }
110
+ div[data-component="quoteBlock"] > *:last-child { margin-bottom: 0; }
111
+
112
+ /* Sidenote component - aligned with template Sidenote.astro */
113
+ div[data-component="sidenote"] {
114
+ font-size: 0.9rem;
115
+ color: var(--muted-color);
116
+ padding: 0 30px;
117
+ margin: 1em 0;
118
+ }
119
+ div[data-component="sidenote"] > *:first-child { margin-top: 0; }
120
+ div[data-component="sidenote"] > *:last-child { margin-bottom: 0; }
121
+
122
+ /* Reference wrapper - aligned with template Reference.astro */
123
+ div[data-component="reference"] {
124
+ margin: 0 0 var(--spacing-4, 1rem);
125
+ }
126
+ div[data-component="reference"] .reference__caption {
127
+ text-align: left;
128
+ font-size: 0.9rem;
129
+ color: var(--muted-color);
130
+ margin-top: 6px;
131
+ }
132
+ div[data-component="reference"] > *:first-child { margin-top: 0; }
133
+ div[data-component="reference"] > *:last-child { margin-bottom: 0; }
134
+
135
+ /* Stack / multi-column layout - aligned with template Stack.astro */
136
+ div[data-component="stack"] {
137
+ display: grid;
138
+ gap: 1rem;
139
+ margin: var(--block-spacing-y, 1.5em) 0;
140
+ width: 100%;
141
+ max-width: 100%;
142
+ box-sizing: border-box;
143
+ }
144
+ div[data-component="stack"][data-layout="2-column"] { grid-template-columns: repeat(2, 1fr); }
145
+ div[data-component="stack"][data-layout="3-column"] { grid-template-columns: repeat(3, 1fr); }
146
+ div[data-component="stack"][data-layout="4-column"] { grid-template-columns: repeat(4, 1fr); }
147
+ div[data-component="stack"][data-layout="auto"] { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
148
+ div[data-component="stack"]:not([data-layout]) { grid-template-columns: repeat(2, 1fr); }
149
+ div[data-component="stack"][data-gap="small"] { gap: 0.5rem; }
150
+ div[data-component="stack"][data-gap="large"] { gap: 2rem; }
151
+ div[data-type="stack-column"] { min-width: 0; overflow: hidden; word-wrap: break-word; overflow-wrap: break-word; }
152
+ div[data-type="stack-column"] > *:first-child { margin-top: 0; }
153
+ div[data-type="stack-column"] > *:last-child { margin-bottom: 0; }
154
+ @media (max-width: 768px) {
155
+ div[data-component="stack"][data-layout="2-column"],
156
+ div[data-component="stack"][data-layout="3-column"],
157
+ div[data-component="stack"][data-layout="4-column"],
158
+ div[data-component="stack"][data-layout="auto"],
159
+ div[data-component="stack"]:not([data-layout]) { grid-template-columns: 1fr !important; }
160
+ }
161
+ @media (min-width: 769px) and (max-width: 1100px) {
162
+ div[data-component="stack"][data-layout="3-column"],
163
+ div[data-component="stack"][data-layout="4-column"],
164
+ div[data-component="stack"][data-layout="auto"] { grid-template-columns: repeat(2, 1fr) !important; }
165
+ }
166
+
167
+ /* Wide / full-width helpers */
168
+ div[data-component="wide"] {
169
+ width: min(1100px, 100vw - 64px);
170
+ margin-left: 50%;
171
+ transform: translateX(-50%);
172
+ }
173
+ div[data-component="fullWidth"] {
174
+ width: 100vw;
175
+ margin-left: calc(50% - 50vw);
176
+ }
177
+
178
+ /* Accordion - aligned with template Accordion.astro */
179
+ details[data-component="accordion"] {
180
+ border: 1px solid var(--border-color);
181
+ border-radius: var(--table-border-radius, 8px);
182
+ background: var(--surface-bg);
183
+ padding: 0;
184
+ margin: 0 0 var(--spacing-4, 1em);
185
+ transition: box-shadow 180ms ease, border-color 180ms ease;
186
+ }
187
+ details[data-component="accordion"][open] {
188
+ border-color: color-mix(in oklab, var(--border-color), var(--primary-color) 20%);
189
+ }
190
+ details[data-component="accordion"] > summary {
191
+ display: flex;
192
+ align-items: center;
193
+ justify-content: space-between;
194
+ gap: 4px;
195
+ padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
196
+ cursor: pointer;
197
+ font-weight: 600;
198
+ color: var(--text-color);
199
+ list-style: none;
200
+ user-select: none;
201
+ }
202
+ details[data-component="accordion"][open] > summary {
203
+ border-bottom: 1px solid var(--border-color);
204
+ }
205
+ details[data-component="accordion"] > summary::-webkit-details-marker { display: none; }
206
+ details[data-component="accordion"] > summary::marker { content: ""; }
207
+ details[data-component="accordion"] > summary::after {
208
+ content: '';
209
+ display: inline-block;
210
+ width: 0.5em;
211
+ height: 0.5em;
212
+ border-right: 2px solid currentColor;
213
+ border-bottom: 2px solid currentColor;
214
+ transform: rotate(-45deg);
215
+ transition: transform 220ms ease;
216
+ opacity: 0.6;
217
+ flex-shrink: 0;
218
+ }
219
+ details[data-component="accordion"][open] > summary::after {
220
+ transform: rotate(45deg);
221
+ }
222
+ details[data-component="accordion"] > .accordion-content {
223
+ padding: 8px;
224
+ }
225
+ details[data-component="accordion"] > .accordion-content > *:first-child { margin-top: 0; }
226
+ details[data-component="accordion"] > .accordion-content > *:last-child { margin-bottom: 0; }
227
+
228
+ /* Footer */
229
+ .footer {
230
+ contain: layout style;
231
+ font-size: 0.8em;
232
+ line-height: 1.7em;
233
+ margin-top: 60px;
234
+ margin-bottom: 0;
235
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
236
+ color: rgba(0, 0, 0, 0.5);
237
+ }
238
+
239
+ .footer-inner {
240
+ max-width: 1280px;
241
+ margin: 0 auto;
242
+ padding: 60px 16px 48px;
243
+ display: grid;
244
+ grid-template-columns: 220px minmax(0, 680px) 260px;
245
+ gap: 32px;
246
+ align-items: start;
247
+ }
248
+
249
+ .citation-block,
250
+ .references-block,
251
+ .reuse-block,
252
+ .doi-block {
253
+ display: contents;
254
+ }
255
+
256
+ .citation-block > .footer-heading,
257
+ .references-block > .footer-heading,
258
+ .reuse-block > .footer-heading,
259
+ .doi-block > .footer-heading {
260
+ grid-column: 1;
261
+ font-size: 15px;
262
+ font-weight: 600;
263
+ margin: 0;
264
+ text-align: right;
265
+ padding-right: 30px;
266
+ }
267
+
268
+ .citation-block > :not(.footer-heading),
269
+ .references-block > :not(.footer-heading),
270
+ .reuse-block > :not(.footer-heading),
271
+ .doi-block > :not(.footer-heading) {
272
+ grid-column: 2;
273
+ }
274
+
275
+ .citation-block .footer-heading { margin: 0 0 8px; }
276
+
277
+ .citation-block p,
278
+ .reuse-block p,
279
+ .doi-block p,
280
+ .footnotes ol,
281
+ .footnotes ol p,
282
+ .references { margin-top: 0; }
283
+
284
+ .footnote-ref a {
285
+ color: var(--primary-color, #958DF1);
286
+ text-decoration: none;
287
+ font-size: 0.8em;
288
+ }
289
+ .footnote-ref a:hover { text-decoration: underline; }
290
+ .footnotes ol { padding-left: 1.5em; }
291
+ .footnotes li { margin-bottom: 0.5em; font-size: 0.9rem; color: var(--muted-color, #888); }
292
+ .footnotes li p { margin: 0; }
293
+ .footnote-backref { text-decoration: none; margin-left: 4px; }
294
+
295
+ .citation {
296
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
297
+ font-size: 11px;
298
+ line-height: 15px;
299
+ border: 1px solid rgba(0, 0, 0, 0.1);
300
+ background: rgba(0, 0, 0, 0.02);
301
+ padding: 10px 18px;
302
+ border-radius: 3px;
303
+ color: rgba(150, 150, 150, 1);
304
+ overflow: hidden;
305
+ margin-top: -12px;
306
+ white-space: pre-wrap;
307
+ word-wrap: break-word;
308
+ }
309
+
310
+ .citation a { color: rgba(0, 0, 0, 0.6); text-decoration: underline; }
311
+ .citation.short { margin-top: -4px; }
312
+
313
+ .citation-inline {
314
+ color: var(--primary-color, #958df1);
315
+ text-decoration: none;
316
+ text-decoration-line: underline;
317
+ text-decoration-color: transparent;
318
+ text-underline-offset: 2px;
319
+ cursor: pointer;
320
+ transition: text-decoration-color 0.15s;
321
+ }
322
+ .citation-inline:hover {
323
+ text-decoration-color: currentColor;
324
+ }
325
+
326
+ .bibliography-content .csl-entry {
327
+ margin-bottom: 0.75em;
328
+ padding-left: 1.5em;
329
+ text-indent: -1.5em;
330
+ font-size: 0.9em;
331
+ line-height: 1.5;
332
+ }
333
+ .bibliography-content .csl-entry:target {
334
+ background: rgba(149, 141, 241, 0.1);
335
+ border-radius: 4px;
336
+ padding: 4px 4px 4px 1.5em;
337
+ }
338
+
339
+ .references-block .footer-heading { margin: 0; }
340
+ .references-block ol { padding: 0 0 0 15px; }
341
+ .references-block li { margin-bottom: 1em; }
342
+ .references-block a { color: var(--text-color); }
343
+
344
+ .footer a {
345
+ color: var(--primary-color);
346
+ border-bottom: 1px solid var(--link-underline);
347
+ text-decoration: none;
348
+ }
349
+ .footer a:hover {
350
+ color: var(--primary-color-hover);
351
+ border-bottom-color: var(--link-underline-hover);
352
+ }
353
+
354
+ .template-credit { display: contents; }
355
+ .template-credit p {
356
+ grid-column: 2;
357
+ margin: 24px 0 0 0;
358
+ font-size: 0.85em;
359
+ color: rgba(0, 0, 0, 0.5);
360
+ }
361
+ .template-credit a { color: rgba(0, 0, 0, 0.6); border-bottom: 1px solid rgba(0, 0, 0, 0.15); }
362
+ .template-credit a:hover { color: rgba(0, 0, 0, 0.8); border-bottom-color: rgba(0, 0, 0, 0.3); }
363
+
364
+ [data-theme="dark"] .footer { border-top-color: rgba(255, 255, 255, 0.15); color: rgba(200, 200, 200, 0.8); }
365
+ [data-theme="dark"] .citation { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.15); color: rgba(200, 200, 200, 1); }
366
+ [data-theme="dark"] .citation a { color: rgba(255, 255, 255, 0.75); }
367
+ [data-theme="dark"] .bibliography-content .csl-entry:target { background: rgba(149, 141, 241, 0.15); }
368
+ [data-theme="dark"] .footer a { color: var(--primary-color); }
369
+ [data-theme="dark"] .template-credit p { color: rgba(200, 200, 200, 0.6); }
370
+ [data-theme="dark"] .template-credit a { color: rgba(200, 200, 200, 0.7); border-bottom-color: rgba(255, 255, 255, 0.2); }
371
+ [data-theme="dark"] .template-credit a:hover { color: rgba(200, 200, 200, 0.9); border-bottom-color: rgba(255, 255, 255, 0.35); }
372
+
373
+ @media (max-width: 1100px) {
374
+ .footer-inner { grid-template-columns: 1fr; gap: 16px; display: block; padding: 40px 16px; }
375
+ .footer-inner > .footer-heading { grid-column: auto; margin-top: 16px; }
376
+ }
377
+
378
+ @media (min-width: 768px) {
379
+ .references-block ol { padding: 0 0 0 30px; margin-left: -30px; }
380
+ }
381
+
382
+ /* PDF download link */
383
+ a.pdf-link {
384
+ display: inline-flex;
385
+ align-items: center;
386
+ gap: 0.4em;
387
+ color: var(--accent-color, #4493f8);
388
+ text-decoration: none;
389
+ font-weight: 500;
390
+ }
391
+ a.pdf-link:hover { text-decoration: underline; }
392
+ a.pdf-link::before { content: "\1F4C4"; }
393
+
394
+ /* Image lightbox dialog */
395
+ dialog.lightbox { border: none; background: transparent; padding: 0; max-width: 95vw; max-height: 95vh; }
396
+ dialog.lightbox::backdrop { background: rgba(0, 0, 0, 0.85); }
397
+ dialog.lightbox img { max-width: 95vw; max-height: 90vh; object-fit: contain; border-radius: 4px; }
frontend/src/styles/_reset.css CHANGED
@@ -1,6 +1,10 @@
1
  html { box-sizing: border-box; }
2
  *, *::before, *::after { box-sizing: inherit; }
3
- body { margin: 0; }
 
 
 
 
4
  audio { display: block; width: 100%; }
5
 
6
  img,
@@ -11,15 +15,3 @@ picture {
11
  position: relative;
12
  z-index: var(--z-elevated);
13
  }
14
-
15
- /*
16
- * Article-specific font and color are scoped to the article area
17
- * to avoid overriding MUI theme on portals (Dialog, Menu, Tooltip).
18
- * The template's original `body { font-family; color }` is moved here.
19
- */
20
- .content-grid,
21
- .hero,
22
- .meta {
23
- font-family: var(--default-font-family);
24
- color: var(--text-color);
25
- }
 
1
  html { box-sizing: border-box; }
2
  *, *::before, *::after { box-sizing: inherit; }
3
+ body {
4
+ margin: 0;
5
+ font-family: var(--default-font-family);
6
+ color: var(--text-color);
7
+ }
8
  audio { display: block; width: 100%; }
9
 
10
  img,
 
15
  position: relative;
16
  z-index: var(--z-elevated);
17
  }
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/styles/_ui.css ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================================ */
2
+ /* Editor Chrome UI Components */
3
+ /* Replaces MUI: always-dark editor shell using CSS custom properties. */
4
+ /* ============================================================================ */
5
+
6
+ :root {
7
+ --ed-bg: #0f0f0f;
8
+ --ed-surface: #1a1a1a;
9
+ --ed-surface-hover: #222;
10
+ --ed-text: #e0e0e0;
11
+ --ed-text-secondary: #888;
12
+ --ed-text-disabled: #555;
13
+ --ed-border: #2a2a2a;
14
+ --ed-radius: 8px;
15
+ --ed-radius-sm: 6px;
16
+ --ed-success: #66bb6a;
17
+ --ed-error: #f44336;
18
+ }
19
+
20
+ /* ---- Icon button ---- */
21
+ .icon-btn {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ border: none;
26
+ background: none;
27
+ cursor: pointer;
28
+ padding: 6px;
29
+ border-radius: var(--ed-radius-sm);
30
+ color: var(--ed-text-disabled);
31
+ transition: color 0.15s, background-color 0.15s;
32
+ line-height: 0;
33
+ }
34
+ .icon-btn:hover {
35
+ color: var(--ed-text);
36
+ background: rgba(255, 255, 255, 0.08);
37
+ }
38
+ .icon-btn:focus-visible {
39
+ outline: 2px solid var(--primary-color);
40
+ outline-offset: 2px;
41
+ }
42
+ .icon-btn svg { width: 18px; height: 18px; }
43
+ .icon-btn--active { color: var(--primary-color); }
44
+ .icon-btn--primary { color: var(--primary-color); }
45
+
46
+ /* ---- Button ---- */
47
+ .btn {
48
+ display: inline-flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ gap: 6px;
52
+ border: none;
53
+ background: none;
54
+ cursor: pointer;
55
+ padding: 6px 14px;
56
+ border-radius: var(--ed-radius-sm);
57
+ font-size: 0.85rem;
58
+ font-weight: 500;
59
+ font-family: inherit;
60
+ color: var(--ed-text-secondary);
61
+ transition: all 0.15s;
62
+ }
63
+ .btn:hover { color: var(--ed-text); background: rgba(255, 255, 255, 0.06); }
64
+ .btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
65
+ .btn--primary { background: var(--primary-color); color: #000; font-weight: 600; }
66
+ .btn--primary:hover { background: var(--primary-color-hover); color: #000; }
67
+ .btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
68
+
69
+ /* ---- Chip ---- */
70
+ .chip {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ gap: 4px;
74
+ padding: 2px 8px;
75
+ border-radius: 999px;
76
+ font-size: 0.65rem;
77
+ font-weight: 600;
78
+ line-height: 1;
79
+ height: 22px;
80
+ white-space: nowrap;
81
+ }
82
+ .chip--sm { height: 18px; padding: 1px 6px; }
83
+ .chip--outlined { border: 1px solid var(--ed-border); background: transparent; }
84
+ .chip--clickable { cursor: pointer; }
85
+ .chip--clickable:hover { filter: brightness(1.1); }
86
+ .chip img { width: 18px; height: 18px; border-radius: 50%; object-fit: cover; }
87
+
88
+ /* ---- Surface (replaces MUI Paper) ---- */
89
+ .surface {
90
+ background: var(--ed-surface);
91
+ border: 1px solid var(--ed-border);
92
+ border-radius: var(--ed-radius);
93
+ }
94
+
95
+ /* ---- Dividers ---- */
96
+ .divider-v { width: 1px; height: 16px; background: var(--ed-border); margin: 0 4px; flex-shrink: 0; }
97
+ .divider-h { border: none; height: 1px; background: var(--ed-border); margin: 0; }
98
+
99
+ /* ---- Spinner ---- */
100
+ .spinner {
101
+ display: inline-block;
102
+ width: 24px;
103
+ height: 24px;
104
+ border: 2.5px solid rgba(255, 255, 255, 0.15);
105
+ border-top-color: var(--primary-color);
106
+ border-radius: 50%;
107
+ animation: ed-spin 0.8s linear infinite;
108
+ }
109
+ @keyframes ed-spin { to { transform: rotate(360deg); } }
110
+
111
+ /* ---- Badge dot ---- */
112
+ .badge-dot { position: relative; }
113
+ .badge-dot::after {
114
+ content: '';
115
+ position: absolute;
116
+ top: 2px;
117
+ right: 2px;
118
+ width: 8px;
119
+ height: 8px;
120
+ background: var(--ed-error);
121
+ border-radius: 50%;
122
+ pointer-events: none;
123
+ }
124
+ .badge-dot--hidden::after { display: none; }
125
+
126
+ /* ---- Native dialog ---- */
127
+ dialog.ed-dialog {
128
+ border: 1px solid var(--ed-border);
129
+ border-radius: var(--ed-radius);
130
+ background: var(--ed-surface);
131
+ color: var(--ed-text);
132
+ padding: 0;
133
+ max-width: 400px;
134
+ width: 90vw;
135
+ }
136
+ dialog.ed-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
137
+ .ed-dialog__title { padding: 16px 20px 8px; font-size: 1.1rem; font-weight: 600; margin: 0; }
138
+ .ed-dialog__body { padding: 8px 20px 16px; font-size: 0.9rem; color: var(--ed-text-secondary); line-height: 1.6; }
139
+ .ed-dialog__body p { margin: 0; }
140
+ .ed-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; padding: 8px 20px 16px; }
141
+
142
+ /* ---- Drawer ---- */
143
+ .drawer-backdrop {
144
+ position: fixed;
145
+ inset: 0;
146
+ background: rgba(0, 0, 0, 0.5);
147
+ z-index: var(--z-overlay, 1000);
148
+ opacity: 0;
149
+ pointer-events: none;
150
+ transition: opacity 0.25s;
151
+ }
152
+ .drawer-backdrop.open { opacity: 1; pointer-events: auto; }
153
+
154
+ .drawer-panel {
155
+ position: fixed;
156
+ top: 0;
157
+ right: 0;
158
+ bottom: 0;
159
+ width: 380px;
160
+ background: var(--ed-surface);
161
+ border-left: 1px solid var(--ed-border);
162
+ z-index: var(--z-modal, 1100);
163
+ transform: translateX(100%);
164
+ transition: transform 0.25s ease;
165
+ overflow-y: auto;
166
+ }
167
+ .drawer-panel.open { transform: translateX(0); }
168
+
169
+ /* ---- Form input / textarea ---- */
170
+ .form-input {
171
+ display: block;
172
+ width: 100%;
173
+ padding: 8px 12px;
174
+ background: var(--ed-bg);
175
+ border: 1px solid var(--ed-border);
176
+ border-radius: var(--ed-radius-sm);
177
+ color: var(--ed-text);
178
+ font-size: 0.875rem;
179
+ font-family: inherit;
180
+ outline: none;
181
+ transition: border-color 0.15s;
182
+ }
183
+ .form-input:focus { border-color: var(--primary-color); }
184
+ .form-input::placeholder { color: var(--ed-text-disabled); }
185
+ textarea.form-input { resize: vertical; min-height: 60px; }
186
+
187
+ /* ---- Form select ---- */
188
+ .form-select {
189
+ display: block;
190
+ width: 100%;
191
+ padding: 8px 12px;
192
+ background: var(--ed-bg);
193
+ border: 1px solid var(--ed-border);
194
+ border-radius: var(--ed-radius-sm);
195
+ color: var(--ed-text);
196
+ font-size: 0.875rem;
197
+ font-family: inherit;
198
+ outline: none;
199
+ cursor: pointer;
200
+ transition: border-color 0.15s;
201
+ }
202
+ .form-select:focus { border-color: var(--primary-color); }
203
+
204
+ /* ---- Switch ---- */
205
+ .form-switch-label {
206
+ display: flex;
207
+ align-items: center;
208
+ gap: 8px;
209
+ cursor: pointer;
210
+ font-size: 0.85rem;
211
+ color: var(--ed-text);
212
+ }
213
+ .form-switch {
214
+ position: relative;
215
+ display: inline-block;
216
+ width: 34px;
217
+ height: 20px;
218
+ flex-shrink: 0;
219
+ }
220
+ .form-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
221
+ .form-switch__track {
222
+ position: absolute;
223
+ inset: 0;
224
+ background: var(--ed-border);
225
+ border-radius: 10px;
226
+ transition: background 0.2s;
227
+ }
228
+ .form-switch input:checked + .form-switch__track { background: var(--primary-color); }
229
+ .form-switch__thumb {
230
+ position: absolute;
231
+ top: 2px;
232
+ left: 2px;
233
+ width: 16px;
234
+ height: 16px;
235
+ background: white;
236
+ border-radius: 50%;
237
+ transition: transform 0.2s;
238
+ pointer-events: none;
239
+ }
240
+ .form-switch input:checked ~ .form-switch__thumb { transform: translateX(14px); }
241
+ .form-switch input:focus-visible + .form-switch__track {
242
+ outline: 2px solid var(--primary-color);
243
+ outline-offset: 2px;
244
+ }
245
+
246
+ /* ---- Field group label ---- */
247
+ .field-label {
248
+ display: block;
249
+ font-size: 0.75rem;
250
+ font-weight: 500;
251
+ color: var(--ed-text-secondary);
252
+ margin-bottom: 4px;
253
+ }
254
+
255
+ /* ---- Tooltip ---- */
256
+ .ed-tooltip {
257
+ position: fixed;
258
+ z-index: var(--z-tooltip, 1200);
259
+ pointer-events: none;
260
+ background: #333;
261
+ color: #fff;
262
+ font-size: 0.7rem;
263
+ padding: 4px 8px;
264
+ border-radius: 4px;
265
+ white-space: nowrap;
266
+ line-height: 1.4;
267
+ }
268
+
269
+ /* ---- Bubble toolbar ---- */
270
+ .bubble-toolbar {
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 2px;
274
+ background: #1a1a1a;
275
+ border: 1px solid #333;
276
+ border-radius: 8px;
277
+ padding: 2px 4px;
278
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
279
+ }
280
+ .bubble-toolbar .icon-btn { color: rgba(255, 255, 255, 0.6); padding: 6px; }
281
+ .bubble-toolbar .icon-btn:hover { color: #fff; background: none; }
282
+ .bubble-toolbar .icon-btn--active { color: #fff; }
283
+ .bubble-toolbar .divider-v { height: 16px; background: #444; margin: 0 2px; }
284
+
285
+ /* ---- Floating actions ---- */
286
+ .floating-actions { display: flex; align-items: flex-start; gap: 4px; }
287
+ .floating-actions__toggle {
288
+ display: inline-flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ border: 1px solid transparent;
292
+ background: none;
293
+ cursor: pointer;
294
+ padding: 4px;
295
+ border-radius: var(--ed-radius-sm);
296
+ color: var(--ed-text-disabled);
297
+ transition: all 0.15s;
298
+ line-height: 0;
299
+ }
300
+ .floating-actions__toggle:hover { color: var(--ed-text); border-color: var(--ed-border); }
301
+ .floating-actions__toggle--open {
302
+ color: var(--ed-text);
303
+ border-color: var(--ed-border);
304
+ transform: rotate(45deg);
305
+ }
306
+ .floating-actions__toggle svg { width: 20px; height: 20px; }
307
+ .floating-actions__panel {
308
+ display: flex;
309
+ gap: 2px;
310
+ padding: 2px 4px;
311
+ animation: fadeIn 0.15s ease;
312
+ }
313
+ .floating-actions__panel .icon-btn { color: var(--ed-text-secondary); padding: 6px; }
314
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: translateY(0); } }
315
+
316
+ /* ---- Comment card ---- */
317
+ .comment-card {
318
+ padding: 10px;
319
+ cursor: pointer;
320
+ border-left: 3px solid;
321
+ border-radius: var(--ed-radius);
322
+ transition: all 0.15s;
323
+ }
324
+ .comment-card:hover { border-color: var(--primary-color); }
325
+ .comment-card--active {
326
+ border-color: var(--primary-color);
327
+ background: rgba(149, 141, 241, 0.05);
328
+ }
329
+ .comment-card__header { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
330
+ .comment-card__time { font-size: 0.65rem; color: var(--ed-text-disabled); margin-left: auto; }
331
+ .comment-card__text { font-size: 0.8rem; color: var(--ed-text); line-height: 1.5; margin-bottom: 6px; }
332
+ .comment-card__actions { display: flex; gap: 2px; }
333
+ .comment-card__actions .icon-btn { padding: 4px; }
334
+ .comment-card__actions .icon-btn--success { color: var(--ed-success); }
335
+ .comment-card__actions .icon-btn--danger { color: var(--ed-error); }
336
+
337
+ /* ---- Top bar ---- */
338
+ .top-bar {
339
+ flex-shrink: 0;
340
+ display: flex;
341
+ align-items: center;
342
+ justify-content: flex-end;
343
+ padding: 6px 16px;
344
+ gap: 4px;
345
+ }
346
+
347
+ /* ---- Chat panel (floating) ---- */
348
+ .chat-floating {
349
+ position: fixed;
350
+ bottom: 16px;
351
+ left: 16px;
352
+ width: 360px;
353
+ height: 520px;
354
+ max-height: calc(100vh - 80px);
355
+ border-radius: 12px;
356
+ overflow: hidden;
357
+ display: flex;
358
+ flex-direction: column;
359
+ background: var(--ed-surface);
360
+ border: 1px solid var(--ed-border);
361
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
362
+ z-index: var(--z-overlay, 1300);
363
+ }
364
+ .chat-floating__header {
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: space-between;
368
+ padding: 6px 12px;
369
+ border-bottom: 1px solid var(--ed-border);
370
+ flex-shrink: 0;
371
+ }
372
+ .chat-floating__title {
373
+ font-weight: 600;
374
+ color: var(--ed-text-secondary);
375
+ font-size: 0.7rem;
376
+ }
377
+ .chat-floating__body { flex: 1; overflow: hidden; display: flex; }
378
+
379
+ /* ---- Chat FAB ---- */
380
+ .chat-fab {
381
+ position: fixed;
382
+ bottom: 20px;
383
+ left: 20px;
384
+ width: 48px;
385
+ height: 48px;
386
+ border-radius: 50%;
387
+ border: none;
388
+ background: var(--primary-color);
389
+ color: #000;
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ cursor: pointer;
394
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
395
+ z-index: var(--z-overlay, 1300);
396
+ transition: background 0.15s;
397
+ line-height: 0;
398
+ }
399
+ .chat-fab:hover { background: var(--primary-color-hover); }
400
+ .chat-fab svg { width: 22px; height: 22px; }
401
+
402
+ /* ---- Author form ---- */
403
+ .author-form {
404
+ margin-top: 16px;
405
+ padding: 16px;
406
+ border: 1px solid var(--ed-border);
407
+ border-radius: var(--ed-radius);
408
+ background: var(--ed-surface);
409
+ display: flex;
410
+ flex-direction: column;
411
+ gap: 12px;
412
+ }
413
+ .author-form__title {
414
+ font-weight: 600;
415
+ color: var(--ed-text-secondary);
416
+ text-transform: uppercase;
417
+ letter-spacing: 0.05em;
418
+ font-size: 0.7rem;
419
+ }
420
+ .author-form__row { display: flex; gap: 8px; }
421
+ .author-form__chips { display: flex; flex-wrap: wrap; gap: 4px; }
422
+ .author-form__actions { display: flex; gap: 8px; justify-content: flex-end; }
423
+
424
+ /* ---- Settings drawer ---- */
425
+ .settings-drawer { padding: 20px; display: flex; flex-direction: column; height: 100%; }
426
+ .settings-drawer__header {
427
+ display: flex;
428
+ align-items: center;
429
+ justify-content: space-between;
430
+ margin-bottom: 16px;
431
+ }
432
+ .settings-drawer__title {
433
+ font-weight: 600;
434
+ text-transform: uppercase;
435
+ letter-spacing: 0.05em;
436
+ color: var(--ed-text-secondary);
437
+ font-size: 0.75rem;
438
+ }
439
+ .settings-drawer__body { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
440
+
441
+ /* ---- Chat panel ---- */
442
+ .chat-panel {
443
+ display: flex;
444
+ flex-direction: column;
445
+ height: 100%;
446
+ width: 100%;
447
+ }
448
+ .chat-panel__header {
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: space-between;
452
+ padding: 8px 12px;
453
+ border-bottom: 1px solid var(--ed-border);
454
+ flex-shrink: 0;
455
+ }
456
+ .chat-panel__label {
457
+ font-weight: 600;
458
+ color: var(--ed-text-secondary);
459
+ letter-spacing: 0.05em;
460
+ text-transform: uppercase;
461
+ font-size: 0.7rem;
462
+ }
463
+ .chat-panel__messages {
464
+ flex: 1;
465
+ overflow: auto;
466
+ padding: 8px 12px;
467
+ display: flex;
468
+ flex-direction: column;
469
+ gap: 12px;
470
+ }
471
+ .chat-panel__empty {
472
+ display: flex;
473
+ align-items: center;
474
+ justify-content: center;
475
+ flex: 1;
476
+ opacity: 0.4;
477
+ text-align: center;
478
+ font-size: 0.75rem;
479
+ max-width: 200px;
480
+ margin: 0 auto;
481
+ }
482
+ .chat-panel__thinking {
483
+ display: flex;
484
+ align-items: center;
485
+ gap: 8px;
486
+ padding: 0 8px;
487
+ font-size: 0.75rem;
488
+ color: var(--ed-text-disabled);
489
+ }
490
+ .chat-panel__error {
491
+ padding: 4px 8px;
492
+ background: rgba(244, 67, 54, 0.15);
493
+ border-radius: 4px;
494
+ font-size: 0.75rem;
495
+ color: var(--ed-error);
496
+ }
497
+ .chat-panel__actions {
498
+ display: flex;
499
+ flex-wrap: wrap;
500
+ gap: 4px;
501
+ padding: 6px 12px;
502
+ border-top: 1px solid var(--ed-border);
503
+ flex-shrink: 0;
504
+ }
505
+ .chat-panel__input {
506
+ padding: 8px 12px;
507
+ border-top: 1px solid var(--ed-border);
508
+ flex-shrink: 0;
509
+ }
510
+ .chat-panel__input-row {
511
+ display: flex;
512
+ gap: 4px;
513
+ align-items: flex-end;
514
+ }
515
+ .chat-panel__textarea {
516
+ flex: 1;
517
+ background: transparent;
518
+ border: none;
519
+ color: var(--ed-text);
520
+ font-size: 0.85rem;
521
+ font-family: inherit;
522
+ line-height: 1.5;
523
+ padding: 4px 0;
524
+ resize: none;
525
+ outline: none;
526
+ }
527
+ .chat-panel__textarea::placeholder { color: var(--ed-text-disabled); }
528
+
529
+ /* ---- Chat bubbles ---- */
530
+ .chat-bubble { display: flex; flex-direction: column; gap: 4px; }
531
+ .chat-bubble__text {
532
+ max-width: 95%;
533
+ padding: 6px 12px;
534
+ border-radius: 8px;
535
+ font-size: 0.8rem;
536
+ line-height: 1.6;
537
+ color: var(--ed-text-secondary);
538
+ white-space: pre-wrap;
539
+ word-break: break-word;
540
+ }
541
+ .chat-bubble__text--user {
542
+ align-self: flex-end;
543
+ background: rgba(149, 141, 241, 0.15);
544
+ color: var(--ed-text);
545
+ }
546
+ .chat-bubble__tool {
547
+ display: flex;
548
+ align-items: center;
549
+ gap: 4px;
550
+ padding: 0 12px;
551
+ opacity: 0.6;
552
+ font-size: 0.65rem;
553
+ color: var(--ed-text-secondary);
554
+ }
frontend/src/theme.ts DELETED
@@ -1,47 +0,0 @@
1
- import { createTheme } from "@mui/material/styles";
2
-
3
- /**
4
- * Build a MUI theme whose primary color matches the article's
5
- * --primary-color CSS variable (stored in Yjs settings).
6
- */
7
- export function buildTheme(primaryColor: string) {
8
- return createTheme({
9
- palette: {
10
- mode: "dark",
11
- background: {
12
- default: "#0f0f0f",
13
- paper: "#1a1a1a",
14
- },
15
- primary: {
16
- main: primaryColor,
17
- },
18
- text: {
19
- primary: "#e0e0e0",
20
- secondary: "#888",
21
- },
22
- divider: "#2a2a2a",
23
- },
24
- typography: {
25
- fontFamily:
26
- '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
27
- },
28
- shape: {
29
- borderRadius: 8,
30
- },
31
- components: {
32
- MuiIconButton: {
33
- styleOverrides: {
34
- root: {
35
- borderRadius: 6,
36
- padding: 6,
37
- },
38
- },
39
- },
40
- },
41
- });
42
- }
43
-
44
- export const DEFAULT_HUE = 47;
45
- export const DEFAULT_PRIMARY = "#c87533";
46
-
47
- export const theme = buildTheme(DEFAULT_PRIMARY);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/vite.config.ts CHANGED
@@ -1,8 +1,14 @@
1
  import { defineConfig } from "vite";
2
  import react from "@vitejs/plugin-react";
 
3
 
4
  export default defineConfig({
5
  plugins: [react()],
 
 
 
 
 
6
  server: {
7
  port: 5678,
8
  proxy: {
 
1
  import { defineConfig } from "vite";
2
  import react from "@vitejs/plugin-react";
3
+ import { resolve } from "path";
4
 
5
  export default defineConfig({
6
  plugins: [react()],
7
+ resolve: {
8
+ alias: {
9
+ "#shared": resolve(__dirname, "../backend/src/shared"),
10
+ },
11
+ },
12
  server: {
13
  port: 5678,
14
  proxy: {