living-ai-documentation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/LICENSE +661 -0
  2. package/README.fr.md +344 -0
  3. package/README.md +344 -0
  4. package/dist/bin/cli.d.ts +3 -0
  5. package/dist/bin/cli.d.ts.map +1 -0
  6. package/dist/bin/cli.js +262 -0
  7. package/dist/bin/cli.js.map +1 -0
  8. package/dist/src/frontend/accuracy-gauge.js +70 -0
  9. package/dist/src/frontend/admin.html +1532 -0
  10. package/dist/src/frontend/annotations.js +585 -0
  11. package/dist/src/frontend/boot.js +101 -0
  12. package/dist/src/frontend/config.js +29 -0
  13. package/dist/src/frontend/confirm-modal.js +82 -0
  14. package/dist/src/frontend/context.html +1252 -0
  15. package/dist/src/frontend/dark-mode.js +20 -0
  16. package/dist/src/frontend/diagram/alignment.js +161 -0
  17. package/dist/src/frontend/diagram/clipboard.js +187 -0
  18. package/dist/src/frontend/diagram/constants.js +109 -0
  19. package/dist/src/frontend/diagram/custom-shapes.js +104 -0
  20. package/dist/src/frontend/diagram/debug.js +43 -0
  21. package/dist/src/frontend/diagram/drawio-export.js +649 -0
  22. package/dist/src/frontend/diagram/edge-panel.js +293 -0
  23. package/dist/src/frontend/diagram/edge-rendering.js +12 -0
  24. package/dist/src/frontend/diagram/evidence.js +146 -0
  25. package/dist/src/frontend/diagram/grid.js +78 -0
  26. package/dist/src/frontend/diagram/groups.js +102 -0
  27. package/dist/src/frontend/diagram/history.js +157 -0
  28. package/dist/src/frontend/diagram/image-name-modal.js +48 -0
  29. package/dist/src/frontend/diagram/image-upload.js +36 -0
  30. package/dist/src/frontend/diagram/label-editor.js +115 -0
  31. package/dist/src/frontend/diagram/link-panel.js +144 -0
  32. package/dist/src/frontend/diagram/main.js +364 -0
  33. package/dist/src/frontend/diagram/network.js +2214 -0
  34. package/dist/src/frontend/diagram/node-panel.js +389 -0
  35. package/dist/src/frontend/diagram/node-rendering.js +964 -0
  36. package/dist/src/frontend/diagram/persistence.js +168 -0
  37. package/dist/src/frontend/diagram/ports.js +421 -0
  38. package/dist/src/frontend/diagram/selection-overlay.js +387 -0
  39. package/dist/src/frontend/diagram/state.js +43 -0
  40. package/dist/src/frontend/diagram/t.js +3 -0
  41. package/dist/src/frontend/diagram/toast.js +21 -0
  42. package/dist/src/frontend/diagram/unlock-hold.js +206 -0
  43. package/dist/src/frontend/diagram/zoom.js +20 -0
  44. package/dist/src/frontend/diagram-link-modal.js +137 -0
  45. package/dist/src/frontend/diagram.html +1494 -0
  46. package/dist/src/frontend/documents.js +479 -0
  47. package/dist/src/frontend/export.js +338 -0
  48. package/dist/src/frontend/file-attach.js +178 -0
  49. package/dist/src/frontend/files-modal.js +243 -0
  50. package/dist/src/frontend/i18n/en.json +624 -0
  51. package/dist/src/frontend/i18n/fr.json +624 -0
  52. package/dist/src/frontend/i18n.js +32 -0
  53. package/dist/src/frontend/image-paste.js +126 -0
  54. package/dist/src/frontend/index.html +2806 -0
  55. package/dist/src/frontend/local-search.js +476 -0
  56. package/dist/src/frontend/metadata.js +318 -0
  57. package/dist/src/frontend/misc.js +92 -0
  58. package/dist/src/frontend/new-doc-modal.js +285 -0
  59. package/dist/src/frontend/new-folder-modal.js +169 -0
  60. package/dist/src/frontend/search.js +194 -0
  61. package/dist/src/frontend/shape-editor.html +685 -0
  62. package/dist/src/frontend/sidebar-helpers.js +96 -0
  63. package/dist/src/frontend/sidebar-resize.js +98 -0
  64. package/dist/src/frontend/sidebar.js +351 -0
  65. package/dist/src/frontend/snippet-detect.js +25 -0
  66. package/dist/src/frontend/snippet-table.js +85 -0
  67. package/dist/src/frontend/snippet-tree.js +94 -0
  68. package/dist/src/frontend/snippets.js +1146 -0
  69. package/dist/src/frontend/state.js +46 -0
  70. package/dist/src/frontend/utils.js +21 -0
  71. package/dist/src/frontend/validate.js +107 -0
  72. package/dist/src/frontend/vendor/wordcloud2.js +1187 -0
  73. package/dist/src/frontend/wordcloud.js +693 -0
  74. package/dist/src/lib/config.d.ts +26 -0
  75. package/dist/src/lib/config.d.ts.map +1 -0
  76. package/dist/src/lib/config.js +195 -0
  77. package/dist/src/lib/config.js.map +1 -0
  78. package/dist/src/lib/hash.d.ts +2 -0
  79. package/dist/src/lib/hash.d.ts.map +1 -0
  80. package/dist/src/lib/hash.js +18 -0
  81. package/dist/src/lib/hash.js.map +1 -0
  82. package/dist/src/lib/metadata.d.ts +31 -0
  83. package/dist/src/lib/metadata.d.ts.map +1 -0
  84. package/dist/src/lib/metadata.js +128 -0
  85. package/dist/src/lib/metadata.js.map +1 -0
  86. package/dist/src/lib/parser.d.ts +11 -0
  87. package/dist/src/lib/parser.d.ts.map +1 -0
  88. package/dist/src/lib/parser.js +111 -0
  89. package/dist/src/lib/parser.js.map +1 -0
  90. package/dist/src/lib/status.d.ts +9 -0
  91. package/dist/src/lib/status.d.ts.map +1 -0
  92. package/dist/src/lib/status.js +72 -0
  93. package/dist/src/lib/status.js.map +1 -0
  94. package/dist/src/mcp/server.d.ts +3 -0
  95. package/dist/src/mcp/server.d.ts.map +1 -0
  96. package/dist/src/mcp/server.js +2046 -0
  97. package/dist/src/mcp/server.js.map +1 -0
  98. package/dist/src/mcp/tools/diagrams.d.ts +82 -0
  99. package/dist/src/mcp/tools/diagrams.d.ts.map +1 -0
  100. package/dist/src/mcp/tools/diagrams.js +594 -0
  101. package/dist/src/mcp/tools/diagrams.js.map +1 -0
  102. package/dist/src/mcp/tools/documents.d.ts +44 -0
  103. package/dist/src/mcp/tools/documents.d.ts.map +1 -0
  104. package/dist/src/mcp/tools/documents.js +186 -0
  105. package/dist/src/mcp/tools/documents.js.map +1 -0
  106. package/dist/src/mcp/tools/git.d.ts +10 -0
  107. package/dist/src/mcp/tools/git.d.ts.map +1 -0
  108. package/dist/src/mcp/tools/git.js +217 -0
  109. package/dist/src/mcp/tools/git.js.map +1 -0
  110. package/dist/src/mcp/tools/metadata.d.ts +57 -0
  111. package/dist/src/mcp/tools/metadata.d.ts.map +1 -0
  112. package/dist/src/mcp/tools/metadata.js +222 -0
  113. package/dist/src/mcp/tools/metadata.js.map +1 -0
  114. package/dist/src/mcp/tools/source.d.ts +29 -0
  115. package/dist/src/mcp/tools/source.d.ts.map +1 -0
  116. package/dist/src/mcp/tools/source.js +196 -0
  117. package/dist/src/mcp/tools/source.js.map +1 -0
  118. package/dist/src/routes/annotations.d.ts +3 -0
  119. package/dist/src/routes/annotations.d.ts.map +1 -0
  120. package/dist/src/routes/annotations.js +83 -0
  121. package/dist/src/routes/annotations.js.map +1 -0
  122. package/dist/src/routes/browse-source.d.ts +3 -0
  123. package/dist/src/routes/browse-source.d.ts.map +1 -0
  124. package/dist/src/routes/browse-source.js +79 -0
  125. package/dist/src/routes/browse-source.js.map +1 -0
  126. package/dist/src/routes/browse.d.ts +3 -0
  127. package/dist/src/routes/browse.d.ts.map +1 -0
  128. package/dist/src/routes/browse.js +91 -0
  129. package/dist/src/routes/browse.js.map +1 -0
  130. package/dist/src/routes/config.d.ts +3 -0
  131. package/dist/src/routes/config.d.ts.map +1 -0
  132. package/dist/src/routes/config.js +145 -0
  133. package/dist/src/routes/config.js.map +1 -0
  134. package/dist/src/routes/context.d.ts +3 -0
  135. package/dist/src/routes/context.d.ts.map +1 -0
  136. package/dist/src/routes/context.js +287 -0
  137. package/dist/src/routes/context.js.map +1 -0
  138. package/dist/src/routes/diagrams.d.ts +3 -0
  139. package/dist/src/routes/diagrams.d.ts.map +1 -0
  140. package/dist/src/routes/diagrams.js +69 -0
  141. package/dist/src/routes/diagrams.js.map +1 -0
  142. package/dist/src/routes/documents.d.ts +11 -0
  143. package/dist/src/routes/documents.d.ts.map +1 -0
  144. package/dist/src/routes/documents.js +450 -0
  145. package/dist/src/routes/documents.js.map +1 -0
  146. package/dist/src/routes/export.d.ts +3 -0
  147. package/dist/src/routes/export.d.ts.map +1 -0
  148. package/dist/src/routes/export.js +280 -0
  149. package/dist/src/routes/export.js.map +1 -0
  150. package/dist/src/routes/files.d.ts +3 -0
  151. package/dist/src/routes/files.d.ts.map +1 -0
  152. package/dist/src/routes/files.js +180 -0
  153. package/dist/src/routes/files.js.map +1 -0
  154. package/dist/src/routes/images.d.ts +3 -0
  155. package/dist/src/routes/images.d.ts.map +1 -0
  156. package/dist/src/routes/images.js +49 -0
  157. package/dist/src/routes/images.js.map +1 -0
  158. package/dist/src/routes/metadata.d.ts +3 -0
  159. package/dist/src/routes/metadata.d.ts.map +1 -0
  160. package/dist/src/routes/metadata.js +131 -0
  161. package/dist/src/routes/metadata.js.map +1 -0
  162. package/dist/src/routes/shape-libraries.d.ts +3 -0
  163. package/dist/src/routes/shape-libraries.d.ts.map +1 -0
  164. package/dist/src/routes/shape-libraries.js +118 -0
  165. package/dist/src/routes/shape-libraries.js.map +1 -0
  166. package/dist/src/routes/wordcloud.d.ts +3 -0
  167. package/dist/src/routes/wordcloud.d.ts.map +1 -0
  168. package/dist/src/routes/wordcloud.js +95 -0
  169. package/dist/src/routes/wordcloud.js.map +1 -0
  170. package/dist/src/server.d.ts +7 -0
  171. package/dist/src/server.d.ts.map +1 -0
  172. package/dist/src/server.js +93 -0
  173. package/dist/src/server.js.map +1 -0
  174. package/dist/starter-doc/.living-doc.json +52 -0
  175. package/dist/starter-doc/ADRS/2026_01_01_[ADR]_example_architecture_decision.md +59 -0
  176. package/dist/starter-doc/AI/2026_01_01_how_to.md +112 -0
  177. package/dist/starter-doc/AI/PROJECT-INSTRUCTIONS.md +172 -0
  178. package/dist/starter-doc/AI/PROJECT-STACK.md +77 -0
  179. package/dist/starter-doc/AI/PROJECT-USEFUL-COMMANDS.md +80 -0
  180. package/dist/starter-doc/AI/default/AGENTS.md +31 -0
  181. package/dist/starter-doc/AI/default/CLAUDE.md +31 -0
  182. package/dist/starter-doc/AI/default/MEMORY.md +24 -0
  183. package/dist/starter-doc/AI/rules/no-magic-numbers.md +18 -0
  184. package/dist/starter-doc/AI/rules/track-current-work.md +23 -0
  185. package/dist/starter-doc/WORKLOG/current-task.md +57 -0
  186. package/dist/starter-doc-fr/.living-doc.json +52 -0
  187. package/dist/starter-doc-fr/ADRS/2026_01_01_[ADR]_example_architecture_decision.md +59 -0
  188. package/dist/starter-doc-fr/AI/2026_01_01_how_to.md +100 -0
  189. package/dist/starter-doc-fr/AI/PROJECT-INSTRUCTIONS.md +172 -0
  190. package/dist/starter-doc-fr/AI/PROJECT-STACK.md +77 -0
  191. package/dist/starter-doc-fr/AI/PROJECT-USEFUL-COMMANDS.md +80 -0
  192. package/dist/starter-doc-fr/AI/default/AGENTS.md +31 -0
  193. package/dist/starter-doc-fr/AI/default/CLAUDE.md +31 -0
  194. package/dist/starter-doc-fr/AI/default/MEMORY.md +24 -0
  195. package/dist/starter-doc-fr/AI/rules/no-magic-numbers.md +18 -0
  196. package/dist/starter-doc-fr/AI/rules/track-current-work.md +23 -0
  197. package/dist/starter-doc-fr/WORKLOG/current-task.md +57 -0
  198. package/images/living_documentation.jpg +0 -0
  199. package/images/readme-extra-files.png +0 -0
  200. package/images/readme-filename-pattern.png +0 -0
  201. package/images/readme-intelligent-search-demo.jpg +0 -0
  202. package/images/readme-sidebar.png +0 -0
  203. package/package.json +72 -0
@@ -0,0 +1,1532 @@
1
+ <!doctype html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Admin — Living Documentation</title>
7
+
8
+ <script src="/i18n.js"></script>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+
11
+ <script>
12
+ tailwind.config = { darkMode: "class", theme: { extend: {} } };
13
+ </script>
14
+ </head>
15
+
16
+ <body
17
+ class="h-full bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100"
18
+ >
19
+ <!-- ── Header ── -->
20
+ <header
21
+ class="sticky top-0 z-40 flex items-center justify-between px-4 h-14 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm"
22
+ >
23
+ <div class="flex items-center gap-3">
24
+ <span
25
+ class="text-blue-600 dark:text-blue-400 text-xl select-none"
26
+ aria-hidden="true"
27
+ >&#128218;</span
28
+ >
29
+ <h1
30
+ class="text-base font-semibold tracking-tight"
31
+ data-i18n="admin.title"
32
+ >
33
+ Admin Panel
34
+ </h1>
35
+ </div>
36
+
37
+ <div class="flex items-center gap-3">
38
+ <button
39
+ id="dark-toggle"
40
+ data-i18n-title="admin.toggle_dark"
41
+ class="p-2 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
42
+ >
43
+ <span id="dark-icon" class="text-lg leading-none">☾</span>
44
+ </button>
45
+ <span class="text-gray-300 dark:text-gray-700">|</span>
46
+ <a
47
+ href="/"
48
+ class="text-blue-600 dark:text-blue-400 hover:underline text-sm"
49
+ >
50
+ <span data-i18n="admin.back_to_docs">Back to docs →</span>
51
+ </a>
52
+ </div>
53
+ </header>
54
+
55
+ <!-- ── Page ── -->
56
+ <main class="pb-10">
57
+ <form id="config-form" novalidate>
58
+ <!-- Sticky sub-header: Configuration title + Save button -->
59
+ <div
60
+ class="sticky top-14 z-30 border-b border-gray-200 dark:border-gray-800 bg-gray-50/90 dark:bg-gray-950/90 backdrop-blur"
61
+ >
62
+ <div
63
+ class="max-w-3xl mx-auto px-6 py-4 flex items-center justify-between gap-4"
64
+ >
65
+ <div class="min-w-0">
66
+ <h2
67
+ class="text-lg font-bold text-gray-900 dark:text-gray-50 truncate"
68
+ data-i18n="admin.config.title"
69
+ >
70
+ Configuration
71
+ </h2>
72
+ <p
73
+ class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 truncate"
74
+ data-i18n="admin.config.description"
75
+ >
76
+ Settings are saved to .living-doc.json in your docs folder.
77
+ </p>
78
+ </div>
79
+ <div class="flex items-center gap-4 shrink-0">
80
+ <div id="save-msg" class="text-sm"></div>
81
+ <button
82
+ type="submit"
83
+ class="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-950"
84
+ >
85
+ <span data-i18n="admin.config.save_btn">Save changes</span>
86
+ </button>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="max-w-3xl mx-auto px-6 pt-8 space-y-8">
92
+ <!-- ── 🌐 General ── -->
93
+ <section
94
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
95
+ >
96
+ <header
97
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
98
+ >
99
+ <h3
100
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
101
+ >
102
+ <span class="text-xl" aria-hidden="true">🌐</span>
103
+ <span data-i18n="admin.section.general.title">General</span>
104
+ </h3>
105
+ <p
106
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
107
+ data-i18n="admin.section.general.desc"
108
+ >
109
+ Site identity, theme, and interface language.
110
+ </p>
111
+ </header>
112
+ <div class="divide-y divide-gray-100 dark:divide-gray-800">
113
+ <div class="px-6 py-4 space-y-1.5">
114
+ <label
115
+ class="block text-sm font-medium text-gray-700 dark:text-gray-300"
116
+ for="field-title"
117
+ data-i18n="admin.appearance.site_title_label"
118
+ >Site Title</label
119
+ >
120
+ <input
121
+ id="field-title"
122
+ name="title"
123
+ type="text"
124
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
125
+ placeholder="Living Documentation"
126
+ />
127
+ <p
128
+ class="text-xs text-gray-400 dark:text-gray-500"
129
+ data-i18n="admin.appearance.site_title_hint"
130
+ >
131
+ Displayed in the browser tab and sidebar header.
132
+ </p>
133
+ </div>
134
+ <div class="px-6 py-4 space-y-1.5">
135
+ <label
136
+ class="block text-sm font-medium text-gray-700 dark:text-gray-300"
137
+ for="field-theme"
138
+ data-i18n="admin.appearance.theme_label"
139
+ >Default Theme</label
140
+ >
141
+ <select
142
+ id="field-theme"
143
+ name="theme"
144
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
145
+ >
146
+ <option
147
+ value="system"
148
+ data-i18n="admin.appearance.theme_system"
149
+ >
150
+ System (follow OS preference)
151
+ </option>
152
+ <option
153
+ value="light"
154
+ data-i18n="admin.appearance.theme_light"
155
+ >
156
+ Light
157
+ </option>
158
+ <option value="dark" data-i18n="admin.appearance.theme_dark">
159
+ Dark
160
+ </option>
161
+ </select>
162
+ <p
163
+ class="text-xs text-gray-400 dark:text-gray-500"
164
+ data-i18n="admin.appearance.theme_hint"
165
+ >
166
+ Users can always override with the toggle button.
167
+ </p>
168
+ </div>
169
+ <div class="px-6 py-4 space-y-1.5">
170
+ <label
171
+ class="block text-sm font-medium text-gray-700 dark:text-gray-300"
172
+ for="field-language"
173
+ data-i18n="admin.appearance.language_label"
174
+ >Language</label
175
+ >
176
+ <select
177
+ id="field-language"
178
+ name="language"
179
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
180
+ >
181
+ <option value="en" data-i18n="admin.appearance.language_en">
182
+ English
183
+ </option>
184
+ <option value="fr" data-i18n="admin.appearance.language_fr">
185
+ Français
186
+ </option>
187
+ </select>
188
+ <p
189
+ class="text-xs text-gray-400 dark:text-gray-500"
190
+ data-i18n="admin.appearance.language_hint"
191
+ >
192
+ Interface language for all pages.
193
+ </p>
194
+ </div>
195
+ </div>
196
+ </section>
197
+
198
+ <!-- ── 📝 Filename convention ── -->
199
+ <section
200
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
201
+ >
202
+ <header
203
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
204
+ >
205
+ <h3
206
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
207
+ >
208
+ <span class="text-xl" aria-hidden="true">📝</span>
209
+ <span data-i18n="admin.section.filename.title"
210
+ >Filename convention</span
211
+ >
212
+ </h3>
213
+ <p
214
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
215
+ data-i18n="admin.section.filename.desc"
216
+ >
217
+ How new documents are named, and how the viewer parses them.
218
+ </p>
219
+ </header>
220
+ <div class="divide-y divide-gray-100 dark:divide-gray-800">
221
+ <div class="px-6 py-4 grid grid-cols-[1fr_auto] gap-x-6 gap-y-2">
222
+ <div>
223
+ <dt
224
+ class="text-xs text-gray-400 uppercase tracking-wide mb-0.5"
225
+ >
226
+ <span data-i18n="admin.server_info.docs_folder"
227
+ >Docs Folder</span
228
+ >
229
+ </dt>
230
+ <dd
231
+ id="info-folder"
232
+ class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all"
233
+ >
234
+
235
+ </dd>
236
+ </div>
237
+ <div>
238
+ <dt
239
+ class="text-xs text-gray-400 uppercase tracking-wide mb-0.5"
240
+ >
241
+ <span data-i18n="admin.server_info.port">Port</span>
242
+ </dt>
243
+ <dd
244
+ id="info-port"
245
+ class="font-mono text-xs text-gray-700 dark:text-gray-300"
246
+ >
247
+
248
+ </dd>
249
+ </div>
250
+ </div>
251
+ <div class="px-6 py-4 space-y-1.5">
252
+ <label
253
+ class="block text-sm font-medium text-gray-700 dark:text-gray-300"
254
+ for="field-pattern"
255
+ data-i18n="admin.pattern.label"
256
+ >Filename Pattern</label
257
+ >
258
+ <input
259
+ id="field-pattern"
260
+ name="filenamePattern"
261
+ type="text"
262
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
263
+ placeholder="YYYY_MM_DD_HH_mm_[Category]_title"
264
+ data-i18n-placeholder="admin.pattern.placeholder"
265
+ />
266
+ <p class="text-xs text-gray-400 dark:text-gray-500">
267
+ <span data-i18n="admin.pattern.hint"
268
+ >Parsed for date, category, and title.</span
269
+ >
270
+ <span
271
+ id="pattern-desc-example"
272
+ class="font-mono bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-1 rounded"
273
+ >YYYY_MM_DD_HH_mm_[Category]_title.md</span
274
+ >
275
+ </p>
276
+ </div>
277
+ <div id="pattern-preview" class="px-6 py-4">
278
+ <h4
279
+ class="text-xs font-semibold text-blue-700 dark:text-blue-400 mb-2 uppercase tracking-wide"
280
+ >
281
+ <span data-i18n="admin.pattern.preview_title"
282
+ >Pattern Preview</span
283
+ >
284
+ </h4>
285
+ <p
286
+ class="text-xs text-gray-500 dark:text-gray-400 mb-3"
287
+ data-i18n="admin.pattern.preview_desc"
288
+ >
289
+ How your pattern parses example filenames:
290
+ </p>
291
+ <div id="preview-rows" class="space-y-2 text-sm"></div>
292
+ </div>
293
+ </div>
294
+ </section>
295
+
296
+ <!-- ── 📂 Source & extra files ── -->
297
+ <section
298
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
299
+ >
300
+ <header
301
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
302
+ >
303
+ <h3
304
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
305
+ >
306
+ <span class="text-xl" aria-hidden="true">📂</span>
307
+ <span data-i18n="admin.section.source.title"
308
+ >Source &amp; extra files</span
309
+ >
310
+ </h3>
311
+ <p
312
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
313
+ data-i18n="admin.section.source.desc"
314
+ >
315
+ Code source root (for MCP tools) and Markdown files pulled from
316
+ outside the docs folder.
317
+ </p>
318
+ </header>
319
+ <div class="divide-y divide-gray-100 dark:divide-gray-800">
320
+ <div class="px-6 py-4 space-y-1.5">
321
+ <label
322
+ class="block text-sm font-medium text-gray-700 dark:text-gray-300"
323
+ for="field-source-root"
324
+ data-i18n="admin.server_info.source_root"
325
+ >Source Root</label
326
+ >
327
+ <input
328
+ id="field-source-root"
329
+ name="sourceRoot"
330
+ type="text"
331
+ class="w-full px-3 py-2 font-mono text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 break-all"
332
+ placeholder=".."
333
+ />
334
+ <p
335
+ class="text-xs text-gray-400 dark:text-gray-500"
336
+ data-i18n="admin.server_info.source_root_hint"
337
+ >
338
+ Absolute path used by MCP source tools. Defaults to the parent
339
+ of Docs Folder when empty.
340
+ </p>
341
+ </div>
342
+ <div class="px-6 py-4 space-y-4">
343
+ <div>
344
+ <p
345
+ class="text-sm font-medium text-gray-700 dark:text-gray-300"
346
+ data-i18n="admin.extra_files.title"
347
+ >
348
+ General — Extra Files
349
+ </p>
350
+ <p
351
+ class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"
352
+ data-i18n="admin.extra_files.description"
353
+ >
354
+ Add Markdown files from outside the docs folder to the
355
+ General section.
356
+ </p>
357
+ </div>
358
+ <div
359
+ class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
360
+ >
361
+ <div
362
+ class="flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
363
+ >
364
+ <button
365
+ id="browse-up"
366
+ onclick="browseUp()"
367
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-30 disabled:pointer-events-none shrink-0"
368
+ >
369
+ <span data-i18n="common.up">↑ Up</span>
370
+ </button>
371
+ <span
372
+ id="browse-path"
373
+ class="font-mono text-xs text-gray-400 dark:text-gray-500 truncate flex-1 text-right"
374
+ ></span>
375
+ </div>
376
+ <div
377
+ id="browse-list"
378
+ class="divide-y divide-gray-100 dark:divide-gray-800 max-h-52 overflow-y-auto"
379
+ ></div>
380
+ </div>
381
+ <div>
382
+ <p
383
+ class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2"
384
+ data-i18n="admin.section.added_files_label"
385
+ >
386
+ Added files
387
+ </p>
388
+ <div id="extra-files-list" class="space-y-1"></div>
389
+ <p
390
+ id="extra-files-empty"
391
+ class="text-xs text-gray-400 italic"
392
+ >
393
+ <span data-i18n="admin.extra_files.empty"
394
+ >No extra files added yet.</span
395
+ >
396
+ </p>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ </section>
401
+
402
+ <!-- ── 🌳 Sidebar behaviour ── -->
403
+ <section
404
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
405
+ >
406
+ <header
407
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
408
+ >
409
+ <h3
410
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
411
+ >
412
+ <span class="text-xl" aria-hidden="true">🗂️</span>
413
+ <span data-i18n="admin.section.sidebar.title"
414
+ >Sidebar behaviour</span
415
+ >
416
+ </h3>
417
+ <p
418
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
419
+ data-i18n="admin.section.sidebar.desc"
420
+ >
421
+ How folders and categories expand in the left drawer.
422
+ </p>
423
+ </header>
424
+ <div class="divide-y divide-gray-100 dark:divide-gray-800">
425
+ <div class="px-6 py-4">
426
+ <label class="flex items-center gap-3 cursor-pointer">
427
+ <input
428
+ id="field-exclusive-folder"
429
+ name="exclusiveFolderExpansion"
430
+ type="checkbox"
431
+ class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
432
+ />
433
+ <span
434
+ class="text-sm font-medium text-gray-700 dark:text-gray-300"
435
+ data-i18n="admin.appearance.exclusive_folder_label"
436
+ >Exclusive folder expansion in sidebar</span
437
+ >
438
+ </label>
439
+ <p
440
+ class="mt-1 text-xs text-gray-400 dark:text-gray-500 ml-7"
441
+ data-i18n="admin.appearance.exclusive_folder_hint"
442
+ >
443
+ When enabled, opening a folder in the sidebar automatically
444
+ closes any other open folder at the same level (and its
445
+ children).
446
+ </p>
447
+ </div>
448
+ <div class="px-6 py-4">
449
+ <label class="flex items-center gap-3 cursor-pointer">
450
+ <input
451
+ id="field-exclusive-category"
452
+ name="exclusiveCategoryExpansion"
453
+ type="checkbox"
454
+ class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
455
+ />
456
+ <span
457
+ class="text-sm font-medium text-gray-700 dark:text-gray-300"
458
+ data-i18n="admin.appearance.exclusive_category_label"
459
+ >Exclusive category expansion in sidebar</span
460
+ >
461
+ </label>
462
+ <p
463
+ class="mt-1 text-xs text-gray-400 dark:text-gray-500 ml-7"
464
+ data-i18n="admin.appearance.exclusive_category_hint"
465
+ >
466
+ When enabled, opening a category automatically closes any
467
+ other open category under the same parent folder.
468
+ </p>
469
+ </div>
470
+ </div>
471
+ </section>
472
+
473
+ <!-- ── ✍️ Markdown rendering ── -->
474
+ <section
475
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
476
+ >
477
+ <header
478
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
479
+ >
480
+ <h3
481
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
482
+ >
483
+ <span class="text-xl" aria-hidden="true">✍️</span>
484
+ <span data-i18n="admin.section.markdown.title"
485
+ >Markdown rendering</span
486
+ >
487
+ </h3>
488
+ <p
489
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
490
+ data-i18n="admin.section.markdown.desc"
491
+ >
492
+ How the viewer renders newlines and long code blocks.
493
+ </p>
494
+ </header>
495
+ <div class="divide-y divide-gray-100 dark:divide-gray-800">
496
+ <div class="px-6 py-4 space-y-1.5">
497
+ <label
498
+ class="block text-sm font-medium text-gray-700 dark:text-gray-300"
499
+ for="field-code-max-height"
500
+ data-i18n="admin.appearance.code_max_height_label"
501
+ >Code block max height (px)</label
502
+ >
503
+ <input
504
+ id="field-code-max-height"
505
+ name="codeBlockMaxHeight"
506
+ type="number"
507
+ min="0"
508
+ max="5000"
509
+ step="10"
510
+ class="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
511
+ placeholder="400"
512
+ />
513
+ <p
514
+ class="text-xs text-gray-400 dark:text-gray-500"
515
+ data-i18n="admin.appearance.code_max_height_hint"
516
+ >
517
+ Longer code blocks get a collapse/expand button. Set to 0 to
518
+ disable.
519
+ </p>
520
+ </div>
521
+ <div class="px-6 py-4">
522
+ <label class="flex items-center gap-3 cursor-pointer">
523
+ <input
524
+ id="field-soft-breaks"
525
+ name="markdownSoftBreaks"
526
+ type="checkbox"
527
+ class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
528
+ />
529
+ <span
530
+ class="text-sm font-medium text-gray-700 dark:text-gray-300"
531
+ data-i18n="admin.appearance.soft_breaks_label"
532
+ >Treat every newline as a line break</span
533
+ >
534
+ </label>
535
+ <p
536
+ class="mt-1 text-xs text-gray-400 dark:text-gray-500 ml-7"
537
+ data-i18n-html="admin.appearance.soft_breaks_hint"
538
+ >
539
+ <strong>Unchecked (CommonMark, default):</strong> a single
540
+ newline in the source is rendered as a space — two adjacent
541
+ lines merge into one paragraph. To force a line break without
542
+ leaving a blank line, end the previous line with
543
+ <code>two spaces</code> then newline, or use
544
+ <code>&lt;br/&gt;</code>.<br />
545
+ <strong>Checked (GitHub-flavoured):</strong> every single
546
+ newline becomes a real line break in the rendered output.
547
+ Convenient for chat-style writing, but you can no longer
548
+ hard-wrap long paragraphs in the source file without visible
549
+ breaks.
550
+ </p>
551
+ </div>
552
+ </div>
553
+ </section>
554
+
555
+ <!-- ── 📎 File attachments ── -->
556
+ <section
557
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
558
+ >
559
+ <header
560
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
561
+ >
562
+ <h3
563
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
564
+ >
565
+ <span class="text-xl" aria-hidden="true">📎</span>
566
+ <span data-i18n="admin.section.files.title"
567
+ >File attachments</span
568
+ >
569
+ </h3>
570
+ <p
571
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
572
+ data-i18n-html="admin.files.description"
573
+ >
574
+ Extensions that are rejected by the
575
+ <code>/api/files/upload</code> endpoint. One extension per line
576
+ (or comma/space separated), without the leading dot.
577
+ </p>
578
+ </header>
579
+ <div class="px-6 py-4 space-y-1.5">
580
+ <label
581
+ class="block text-xs font-medium text-gray-500 dark:text-gray-400"
582
+ for="field-blocked-extensions"
583
+ data-i18n="admin.files.blocked_label"
584
+ >Blocked extensions</label
585
+ >
586
+ <textarea
587
+ id="field-blocked-extensions"
588
+ name="blockedFileExtensions"
589
+ rows="3"
590
+ class="w-full px-3 py-2 font-mono text-xs rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
591
+ placeholder="exe sh bat cmd com scr ps1 msi"
592
+ ></textarea>
593
+ <p
594
+ class="text-xs text-gray-400 dark:text-gray-500"
595
+ data-i18n="admin.files.hint"
596
+ >
597
+ Defaults to common executable formats. Max upload size is 19 MB
598
+ per file.
599
+ </p>
600
+ </div>
601
+ </section>
602
+
603
+ <!-- ── 🎨 Diagram palettes ── -->
604
+ <section
605
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
606
+ >
607
+ <header
608
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
609
+ >
610
+ <h3
611
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
612
+ >
613
+ <span class="text-xl" aria-hidden="true">🎨</span>
614
+ <span data-i18n="admin.section.diagram.title"
615
+ >Diagram palettes</span
616
+ >
617
+ </h3>
618
+ <p
619
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
620
+ data-i18n="admin.section.diagram.desc"
621
+ >
622
+ Customize the colors available in the diagram editor.
623
+ </p>
624
+ </header>
625
+ <div class="divide-y divide-gray-100 dark:divide-gray-800">
626
+ <div class="px-6 py-4 space-y-3">
627
+ <div class="flex items-center justify-between">
628
+ <h4
629
+ class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
630
+ data-i18n="admin.palette.shapes_title"
631
+ >
632
+ Shape colors
633
+ </h4>
634
+ <button
635
+ type="button"
636
+ onclick="resetNodePalette()"
637
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
638
+ data-i18n="common.reset"
639
+ >
640
+ Reset
641
+ </button>
642
+ </div>
643
+ <p
644
+ class="text-xs text-gray-400 dark:text-gray-500"
645
+ data-i18n="admin.palette.shapes_hint"
646
+ >
647
+ Click a color to show or hide it in the node panel. Dimmed =
648
+ hidden.
649
+ </p>
650
+ <div id="nodeSwatches" class="flex flex-wrap gap-2"></div>
651
+ </div>
652
+ <div class="px-6 py-4 space-y-3">
653
+ <div class="flex items-center justify-between">
654
+ <h4
655
+ class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
656
+ data-i18n="admin.palette.arrows_title"
657
+ >
658
+ Arrow colors
659
+ </h4>
660
+ <button
661
+ type="button"
662
+ onclick="resetEdgePalette()"
663
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
664
+ data-i18n="common.reset"
665
+ >
666
+ Reset
667
+ </button>
668
+ </div>
669
+ <p
670
+ class="text-xs text-gray-400 dark:text-gray-500"
671
+ data-i18n="admin.palette.arrows_hint"
672
+ >
673
+ Add or remove colors available for arrows. Click ✕ to remove.
674
+ </p>
675
+ <div
676
+ id="edgeSwatches"
677
+ class="flex flex-wrap gap-3 items-end"
678
+ ></div>
679
+ <div class="flex items-center gap-2 pt-1">
680
+ <input
681
+ type="color"
682
+ id="newEdgeColor"
683
+ value="#3b82f6"
684
+ class="w-8 h-8 rounded cursor-pointer border border-gray-300 dark:border-gray-600 bg-transparent"
685
+ />
686
+ <button
687
+ type="button"
688
+ onclick="addEdgeColor()"
689
+ class="px-3 py-1.5 text-xs rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
690
+ data-i18n="admin.palette.add_color_btn"
691
+ >
692
+ Add color
693
+ </button>
694
+ </div>
695
+ </div>
696
+ </div>
697
+ </section>
698
+
699
+ <!-- ── 🛠️ Developer ── -->
700
+ <section
701
+ class="rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
702
+ >
703
+ <header
704
+ class="px-6 py-4 border-b border-gray-100 dark:border-gray-800"
705
+ >
706
+ <h3
707
+ class="text-base font-semibold text-gray-800 dark:text-gray-100 flex items-center gap-2"
708
+ >
709
+ <span class="text-xl" aria-hidden="true">🛠️</span>
710
+ <span data-i18n="admin.section.developer.title">Developer</span>
711
+ </h3>
712
+ <p
713
+ class="mt-1 text-xs text-gray-500 dark:text-gray-400 ml-8"
714
+ data-i18n="admin.section.developer.desc"
715
+ >
716
+ Advanced options for power users.
717
+ </p>
718
+ </header>
719
+ <div class="px-6 py-4">
720
+ <label class="flex items-center gap-3 cursor-pointer">
721
+ <input
722
+ id="field-debug"
723
+ name="showDiagramDebug"
724
+ type="checkbox"
725
+ class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
726
+ />
727
+ <span
728
+ class="text-sm font-medium text-gray-700 dark:text-gray-300"
729
+ data-i18n="admin.appearance.debug_label"
730
+ >Show debug button in diagram editor</span
731
+ >
732
+ </label>
733
+ <p class="mt-1 text-xs text-gray-400 dark:text-gray-500 ml-7">
734
+ Displays a Debug button that overlays raw position and size info
735
+ on each node — useful for diagnosing snap-to-grid issues.
736
+ </p>
737
+ </div>
738
+ </section>
739
+ </div>
740
+ </form>
741
+
742
+ <!-- ── AGPL-3.0 License ── -->
743
+ <div class="max-w-3xl mx-auto px-6">
744
+ <div
745
+ class="mt-12 rounded-2xl overflow-hidden border border-blue-100 dark:border-blue-900/40 shadow-sm"
746
+ >
747
+ <!-- Gradient banner -->
748
+ <div
749
+ class="bg-gradient-to-r from-blue-600 via-blue-500 to-indigo-500 px-6 py-5 flex items-center gap-4"
750
+ >
751
+ <div class="text-white text-3xl select-none" aria-hidden="true">
752
+ 📖
753
+ </div>
754
+ <div>
755
+ <h3
756
+ class="text-white font-bold text-base tracking-tight"
757
+ data-i18n="admin.license.title"
758
+ >
759
+ AGPL-3.0 License — Open Source
760
+ </h3>
761
+ <p
762
+ class="text-blue-100 text-xs mt-0.5"
763
+ data-i18n="admin.license.subtitle"
764
+ >
765
+ Free to use, fork, and build upon — derivatives and
766
+ network-hosted services must remain under AGPL (copyleft + SaaS
767
+ clause).
768
+ </p>
769
+ </div>
770
+ </div>
771
+
772
+ <!-- Body -->
773
+ <div class="bg-white dark:bg-gray-900 px-6 py-5 space-y-4">
774
+ <p
775
+ class="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"
776
+ data-i18n-html="admin.license.body"
777
+ >
778
+ This open source project is gracefully provided to you by its
779
+ conceptors
780
+ <strong>Youssef MEDAGHRI-ALAOUI</strong> &amp;
781
+ <strong>Claude Code</strong>.
782
+ </p>
783
+
784
+ <div class="flex flex-wrap gap-3 pt-1">
785
+ <a
786
+ href="https://www.linkedin.com/in/youssef-medaghri-alaoui-93b2922/"
787
+ target="_blank"
788
+ rel="noopener noreferrer"
789
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[#0A66C2] hover:bg-[#0958a8] text-white text-sm font-semibold transition-colors shadow-sm"
790
+ >
791
+ <svg
792
+ class="w-4 h-4 shrink-0"
793
+ viewBox="0 0 24 24"
794
+ fill="currentColor"
795
+ aria-hidden="true"
796
+ >
797
+ <path
798
+ d="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.85 0-2.13 1.44-2.13 2.93v5.68H9.37V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.35-1.85 3.58 0 4.24 2.36 4.24 5.43v6.31ZM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12ZM7.12 20.45H3.56V9h3.56v11.45ZM22.22 0H1.77C.79 0 0 .77 0 1.72v20.56C0 23.23.79 24 1.77 24h20.45C23.2 24 24 23.23 24 22.28V1.72C24 .77 23.2 0 22.22 0Z"
799
+ />
800
+ </svg>
801
+ <span data-i18n="admin.license.linkedin_btn"
802
+ >Youssef on LinkedIn</span
803
+ >
804
+ </a>
805
+
806
+ <a
807
+ href="https://www.npmjs.com/package/living-ai-documentation"
808
+ target="_blank"
809
+ rel="noopener noreferrer"
810
+ class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[#CB3837] hover:bg-[#a82d2d] text-white text-sm font-semibold transition-colors shadow-sm"
811
+ >
812
+ <svg
813
+ class="w-4 h-4 shrink-0"
814
+ viewBox="0 0 24 24"
815
+ fill="currentColor"
816
+ aria-hidden="true"
817
+ >
818
+ <path
819
+ d="M0 0v24h24V0H0Zm19.2 19.2H13.6V8h-3.2v11.2H4.8V4.8h14.4v14.4Z"
820
+ />
821
+ </svg>
822
+ <span data-i18n="admin.license.npm_btn"
823
+ >living-ai-documentation on npm</span
824
+ >
825
+ </a>
826
+ </div>
827
+ </div>
828
+ </div>
829
+ </div>
830
+ </main>
831
+
832
+ <script>
833
+ // ── Boot ───────────────────────────────────────────────────
834
+ document.addEventListener("DOMContentLoaded", async () => {
835
+ applyDarkMode(loadDarkPref());
836
+ setupDarkToggle();
837
+ await loadConfig();
838
+ setupPatternPreview();
839
+ document
840
+ .getElementById("config-form")
841
+ .addEventListener("submit", saveConfig);
842
+ });
843
+
844
+ // ── Dark mode ──────────────────────────────────────────────
845
+ function loadDarkPref() {
846
+ const saved = localStorage.getItem("ld-dark");
847
+ if (saved !== null) return saved === "true";
848
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
849
+ }
850
+ function applyDarkMode(dark) {
851
+ document.documentElement.classList.toggle("dark", dark);
852
+ document.getElementById("dark-icon").textContent = dark ? "☀" : "☾";
853
+ }
854
+ function setupDarkToggle() {
855
+ document.getElementById("dark-toggle").addEventListener("click", () => {
856
+ const isDark = document.documentElement.classList.toggle("dark");
857
+ localStorage.setItem("ld-dark", isDark);
858
+ document.getElementById("dark-icon").textContent = isDark ? "☀" : "☾";
859
+ });
860
+ }
861
+
862
+ // ── Config ─────────────────────────────────────────────────
863
+ async function loadConfig() {
864
+ try {
865
+ const cfg = await fetch("/api/config").then((r) => r.json());
866
+ await window.initI18n(cfg.language || "en");
867
+ window.applyI18n();
868
+ window._ldCurrentLanguage = cfg.language || "en";
869
+ document.getElementById("info-folder").textContent =
870
+ cfg.docsFolder || "—";
871
+ document.getElementById("info-port").textContent = cfg.port || "—";
872
+ document.getElementById("field-source-root").value =
873
+ cfg.docsFolder && cfg.sourceRoot
874
+ ? pathRelative(cfg.docsFolder, cfg.sourceRoot)
875
+ : "";
876
+ document.getElementById("field-title").value = cfg.title || "";
877
+ document.getElementById("field-theme").value = cfg.theme || "system";
878
+ document.getElementById("field-language").value =
879
+ cfg.language || "en";
880
+ document.getElementById("field-pattern").value =
881
+ cfg.filenamePattern || "";
882
+ document.getElementById("field-debug").checked =
883
+ !!cfg.showDiagramDebug;
884
+ document.getElementById("field-exclusive-folder").checked =
885
+ !!cfg.exclusiveFolderExpansion;
886
+ document.getElementById("field-exclusive-category").checked =
887
+ !!cfg.exclusiveCategoryExpansion;
888
+ document.getElementById("field-code-max-height").value =
889
+ typeof cfg.codeBlockMaxHeight === "number"
890
+ ? cfg.codeBlockMaxHeight
891
+ : 400;
892
+ document.getElementById("field-soft-breaks").checked =
893
+ !!cfg.markdownSoftBreaks;
894
+ document.getElementById("field-blocked-extensions").value = (
895
+ cfg.blockedFileExtensions || []
896
+ ).join(" ");
897
+ updatePreview(cfg.filenamePattern);
898
+ initExtraFiles(cfg);
899
+ initPalettes(cfg);
900
+ } catch {
901
+ showMsg(window.t("admin.msg.load_failed"), "error");
902
+ }
903
+ }
904
+
905
+ async function saveConfig(e) {
906
+ e.preventDefault();
907
+ const filenamePattern = document
908
+ .getElementById("field-pattern")
909
+ .value.trim();
910
+ if (filenamePattern) {
911
+ const catCount = (filenamePattern.match(/\[Category\]/gi) || [])
912
+ .length;
913
+ if (catCount === 0) {
914
+ showMsg(window.t("admin.msg.pattern_no_category"), "error");
915
+ return;
916
+ }
917
+ if (catCount > 1) {
918
+ showMsg(window.t("admin.msg.pattern_one_category"), "error");
919
+ return;
920
+ }
921
+ }
922
+ const sourceRootRaw = document
923
+ .getElementById("field-source-root")
924
+ .value.trim();
925
+ if (sourceRootRaw !== "" && isAbsolutePath(sourceRootRaw)) {
926
+ showMsg(window.t("admin.msg.source_root_absolute"), "error");
927
+ return;
928
+ }
929
+ const blockedExtensions = document
930
+ .getElementById("field-blocked-extensions")
931
+ .value.split(/[\s,]+/)
932
+ .map((e) => e.trim().replace(/^\.+/, "").toLowerCase())
933
+ .filter((e) => /^[a-z0-9]+$/.test(e));
934
+ const payload = {
935
+ title: document.getElementById("field-title").value.trim(),
936
+ theme: document.getElementById("field-theme").value,
937
+ language: document.getElementById("field-language").value,
938
+ filenamePattern,
939
+ showDiagramDebug: document.getElementById("field-debug").checked,
940
+ exclusiveFolderExpansion: document.getElementById(
941
+ "field-exclusive-folder",
942
+ ).checked,
943
+ exclusiveCategoryExpansion: document.getElementById(
944
+ "field-exclusive-category",
945
+ ).checked,
946
+ codeBlockMaxHeight: Math.max(
947
+ 0,
948
+ Math.min(
949
+ 5000,
950
+ parseInt(
951
+ document.getElementById("field-code-max-height").value,
952
+ 10,
953
+ ) || 0,
954
+ ),
955
+ ),
956
+ markdownSoftBreaks:
957
+ document.getElementById("field-soft-breaks").checked,
958
+ diagramNodePalette: [...nodePalette],
959
+ diagramEdgePalette: [...edgePalette],
960
+ sourceRoot: sourceRootRaw === "" ? null : sourceRootRaw,
961
+ blockedFileExtensions: blockedExtensions,
962
+ };
963
+
964
+ try {
965
+ const res = await fetch("/api/config", {
966
+ method: "PUT",
967
+ headers: { "Content-Type": "application/json" },
968
+ body: JSON.stringify(payload),
969
+ });
970
+ if (!res.ok) throw new Error(await res.text());
971
+ if (
972
+ payload.language &&
973
+ payload.language !== window._ldCurrentLanguage
974
+ ) {
975
+ await window.initI18n(payload.language);
976
+ window.applyI18n();
977
+ window._ldCurrentLanguage = payload.language;
978
+ updatePreview();
979
+ }
980
+ showMsg(window.t("admin.msg.saved"), "ok");
981
+ } catch (err) {
982
+ showMsg(window.t("admin.msg.save_failed") + err.message, "error");
983
+ }
984
+ }
985
+
986
+ function showMsg(text, type) {
987
+ const el = document.getElementById("save-msg");
988
+ el.textContent = text;
989
+ el.className =
990
+ "text-sm " +
991
+ (type === "ok"
992
+ ? "text-green-600 dark:text-green-400"
993
+ : "text-red-600 dark:text-red-400");
994
+ if (type === "ok")
995
+ setTimeout(() => {
996
+ el.textContent = "";
997
+ }, 3000);
998
+ }
999
+
1000
+ // ── Pattern preview ────────────────────────────────────────
1001
+ const EXAMPLES = [
1002
+ "2024_01_15_09_30_[DevOps]_deploy_pipeline.md",
1003
+ "2023_11_03_14_45_[Frontend]_react_hooks_guide.md",
1004
+ "2025_06_20_08_00_meeting_notes.md",
1005
+ "readme.md",
1006
+ ];
1007
+
1008
+ function dateStrToISO(s) {
1009
+ const p = s.split("_");
1010
+ return p.length === 5
1011
+ ? p[0] + "-" + p[1] + "-" + p[2] + "T" + p[3] + ":" + p[4]
1012
+ : p[0] + "-" + p[1] + "-" + p[2];
1013
+ }
1014
+
1015
+ function buildPatternsFromFormat(patternStr) {
1016
+ if (!patternStr) patternStr = "YYYY_MM_DD_HH_mm_[Category]_title";
1017
+ const hasDate = /YYYY.*MM.*DD/.test(patternStr);
1018
+ const hasTime = hasDate && /HH.*mm/.test(patternStr);
1019
+ const hasCategory = /\[Category\]/i.test(patternStr);
1020
+ const dateGroup = hasTime
1021
+ ? "(\\d{4}_\\d{2}_\\d{2}(?:_\\d{2}_\\d{2})?)"
1022
+ : "(\\d{4}_\\d{2}_\\d{2})";
1023
+ const catGroup = "\\[([^\\]]+)\\]";
1024
+ const catBeforeDate =
1025
+ hasDate &&
1026
+ hasCategory &&
1027
+ patternStr.search(/\[Category\]/i) < patternStr.search(/YYYY/i);
1028
+ let full = null,
1029
+ dateOnly = null;
1030
+ if (hasDate && hasCategory) {
1031
+ const ordered = catBeforeDate
1032
+ ? catGroup + "_" + dateGroup
1033
+ : dateGroup + "_" + catGroup;
1034
+ full = new RegExp("^" + ordered + "_(.+)\\.md$", "i");
1035
+ dateOnly = new RegExp("^" + dateGroup + "_(.+)\\.md$", "i");
1036
+ } else if (hasDate) {
1037
+ dateOnly = new RegExp("^" + dateGroup + "_(.+)\\.md$", "i");
1038
+ } else if (hasCategory) {
1039
+ full = new RegExp("^" + catGroup + "_(.+)\\.md$", "i");
1040
+ }
1041
+ return { full, dateOnly, hasDate, hasCategory, catBeforeDate };
1042
+ }
1043
+
1044
+ function parsePreview(filename, patterns) {
1045
+ if (patterns.full) {
1046
+ const m = filename.match(patterns.full);
1047
+ if (m) {
1048
+ if (patterns.hasDate && patterns.hasCategory) {
1049
+ const dateStr = patterns.catBeforeDate ? m[2] : m[1];
1050
+ const category = patterns.catBeforeDate ? m[1] : m[2];
1051
+ return {
1052
+ date: dateStrToISO(dateStr),
1053
+ category,
1054
+ title: titleCase(m[3]),
1055
+ match: true,
1056
+ };
1057
+ } else if (patterns.hasCategory) {
1058
+ return {
1059
+ date: null,
1060
+ category: m[1],
1061
+ title: titleCase(m[2]),
1062
+ match: true,
1063
+ };
1064
+ }
1065
+ }
1066
+ }
1067
+ if (patterns.dateOnly) {
1068
+ const m = filename.match(patterns.dateOnly);
1069
+ if (m) {
1070
+ return {
1071
+ date: dateStrToISO(m[1]),
1072
+ category: "General",
1073
+ title: titleCase(m[2]),
1074
+ match: true,
1075
+ };
1076
+ }
1077
+ }
1078
+ return {
1079
+ date: null,
1080
+ category: "General",
1081
+ title: filename.replace(".md", ""),
1082
+ match: false,
1083
+ };
1084
+ }
1085
+
1086
+ function titleCase(s) {
1087
+ return s
1088
+ .split("_")
1089
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
1090
+ .join(" ");
1091
+ }
1092
+
1093
+ function updatePreview() {
1094
+ const patternVal = document
1095
+ .getElementById("field-pattern")
1096
+ .value.trim();
1097
+ const descSpan = document.getElementById("pattern-desc-example");
1098
+ descSpan.textContent =
1099
+ (patternVal || "YYYY_MM_DD_HH_mm_[Category]_title") + ".md";
1100
+ const patterns = buildPatternsFromFormat(patternVal);
1101
+ const rows = document.getElementById("preview-rows");
1102
+ const tr = (k, fb) =>
1103
+ (typeof window.t === "function" && window.t(k)) || fb;
1104
+ const labelDate = tr("admin.pattern.col_date", "Date");
1105
+ const labelCat = tr("admin.pattern.col_category", "Category");
1106
+ const labelTitle = tr("admin.pattern.col_title", "Title");
1107
+ const labelNone = tr("admin.pattern.col_none", "none");
1108
+ rows.innerHTML = EXAMPLES.map((f) => {
1109
+ const p = parsePreview(f, patterns);
1110
+ return `
1111
+ <div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3">
1112
+ <p class="font-mono text-xs text-gray-500 dark:text-gray-400 mb-2 break-all">${esc(f)}</p>
1113
+ <div class="grid grid-cols-3 gap-2 text-xs">
1114
+ <div>
1115
+ <span class="text-gray-400 block mb-0.5">${labelDate}</span>
1116
+ <span class="${p.date ? "text-green-600 dark:text-green-400" : "text-gray-400"}">${p.date || labelNone}</span>
1117
+ </div>
1118
+ <div>
1119
+ <span class="text-gray-400 block mb-0.5">${labelCat}</span>
1120
+ <span class="text-blue-600 dark:text-blue-400">${esc(p.category)}</span>
1121
+ </div>
1122
+ <div>
1123
+ <span class="text-gray-400 block mb-0.5">${labelTitle}</span>
1124
+ <span class="text-gray-700 dark:text-gray-300 truncate block">${esc(p.title)}</span>
1125
+ </div>
1126
+ </div>
1127
+ </div>`;
1128
+ }).join("");
1129
+ }
1130
+
1131
+ function setupPatternPreview() {
1132
+ document
1133
+ .getElementById("field-pattern")
1134
+ .addEventListener("input", updatePreview);
1135
+ }
1136
+
1137
+ function esc(s) {
1138
+ return String(s)
1139
+ .replace(/&/g, "&amp;")
1140
+ .replace(/</g, "&lt;")
1141
+ .replace(/>/g, "&gt;")
1142
+ .replace(/"/g, "&quot;")
1143
+ .replace(/'/g, "&#39;");
1144
+ }
1145
+
1146
+ // ── Extra Files / Browser ──────────────────────────────────
1147
+ // extraFiles is stored internally as absolute paths (matching what GET /api/config returns
1148
+ // and what /api/browse emits). On save, paths are converted to relative before PUT.
1149
+ let extraFiles = [];
1150
+ let browseParent = null;
1151
+ let browseCurrent = "";
1152
+ let docsFolder = "";
1153
+
1154
+ function initExtraFiles(cfg) {
1155
+ extraFiles = cfg.extraFiles || [];
1156
+ docsFolder = cfg.docsFolder || "";
1157
+ renderExtraFiles();
1158
+ loadBrowse(cfg.docsFolder || "/");
1159
+ }
1160
+
1161
+ // Returns true if the path looks absolute (unix, Windows, or tilde-home).
1162
+ function isAbsolutePath(p) {
1163
+ if (!p) return false;
1164
+ return /^([/\\]|[a-zA-Z]:[\\/]|~)/.test(p);
1165
+ }
1166
+
1167
+ // Posix-style path.relative(from, to). Returns "." when equal, "../x" etc.
1168
+ function pathRelative(from, to) {
1169
+ if (!from) return to;
1170
+ const norm = (s) => s.replace(/\\/g, "/");
1171
+ const fromParts = norm(from).split("/").filter(Boolean);
1172
+ const toParts = norm(to).split("/").filter(Boolean);
1173
+ let common = 0;
1174
+ while (
1175
+ common < fromParts.length &&
1176
+ common < toParts.length &&
1177
+ fromParts[common] === toParts[common]
1178
+ )
1179
+ common++;
1180
+ const ups = fromParts.length - common;
1181
+ const downs = toParts.slice(common).join("/");
1182
+ let rel = "../".repeat(ups) + downs;
1183
+ if (rel.endsWith("/")) rel = rel.slice(0, -1);
1184
+ return rel === "" ? "." : rel;
1185
+ }
1186
+
1187
+ function relativeDisplay(filePath) {
1188
+ if (!docsFolder) return filePath;
1189
+ return "$DOCS_FOLDER/" + pathRelative(docsFolder, filePath);
1190
+ }
1191
+
1192
+ async function loadBrowse(dirPath) {
1193
+ const list = document.getElementById("browse-list");
1194
+ list.innerHTML =
1195
+ '<p class="px-3 py-4 text-xs text-gray-400 text-center">Loading…</p>';
1196
+ try {
1197
+ const data = await fetch(
1198
+ "/api/browse?path=" + encodeURIComponent(dirPath),
1199
+ ).then((r) => r.json());
1200
+ browseCurrent = data.current;
1201
+ browseParent = data.parent;
1202
+ document.getElementById("browse-path").textContent = relativeDisplay(
1203
+ data.current,
1204
+ );
1205
+ document.getElementById("browse-up").disabled = !data.parent;
1206
+
1207
+ const rows = [];
1208
+
1209
+ for (const dir of data.dirs) {
1210
+ rows.push(`
1211
+ <button data-path="${esc(dir.path)}" onclick="loadBrowse(this.dataset.path)"
1212
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-left
1213
+ hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
1214
+ <span class="text-gray-400 shrink-0">📁</span>
1215
+ <span class="text-gray-700 dark:text-gray-300 truncate">${esc(dir.name)}</span>
1216
+ </button>`);
1217
+ }
1218
+
1219
+ for (const file of data.files) {
1220
+ const added = extraFiles.includes(file.path);
1221
+ rows.push(`
1222
+ <div class="flex items-center justify-between gap-2 px-3 py-2 text-sm
1223
+ hover:bg-gray-50 dark:hover:bg-gray-800">
1224
+ <span class="flex items-center gap-2 text-gray-700 dark:text-gray-300 min-w-0">
1225
+ <span class="text-gray-400 shrink-0">📄</span>
1226
+ <span class="truncate">${esc(file.name)}</span>
1227
+ </span>
1228
+ ${
1229
+ added
1230
+ ? '<span class="text-xs text-green-600 dark:text-green-400 shrink-0">Added</span>'
1231
+ : `<button data-path="${esc(file.path)}" onclick="addExtraFile(this.dataset.path)"
1232
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline shrink-0">+ Add</button>`
1233
+ }
1234
+ </div>`);
1235
+ }
1236
+
1237
+ if (rows.length === 0) {
1238
+ list.innerHTML =
1239
+ '<p class="px-3 py-4 text-xs text-gray-400 text-center">Empty directory</p>';
1240
+ } else {
1241
+ list.innerHTML = rows.join("");
1242
+ }
1243
+ } catch {
1244
+ list.innerHTML =
1245
+ '<p class="px-3 py-4 text-xs text-red-400 text-center">Cannot read directory</p>';
1246
+ }
1247
+ }
1248
+
1249
+ function browseUp() {
1250
+ if (browseParent) loadBrowse(browseParent);
1251
+ }
1252
+
1253
+ async function addExtraFile(filePath) {
1254
+ if (extraFiles.includes(filePath)) return;
1255
+ extraFiles = [...extraFiles, filePath];
1256
+ await saveExtraFiles();
1257
+ renderExtraFiles();
1258
+ loadBrowse(browseCurrent);
1259
+ }
1260
+
1261
+ async function removeExtraFile(filePath) {
1262
+ extraFiles = extraFiles.filter((f) => f !== filePath);
1263
+ await saveExtraFiles();
1264
+ renderExtraFiles();
1265
+ loadBrowse(browseCurrent);
1266
+ }
1267
+
1268
+ async function saveExtraFiles() {
1269
+ try {
1270
+ const relativeEntries = extraFiles.map((abs) =>
1271
+ pathRelative(docsFolder, abs),
1272
+ );
1273
+ const res = await fetch("/api/config", {
1274
+ method: "PUT",
1275
+ headers: { "Content-Type": "application/json" },
1276
+ body: JSON.stringify({ extraFiles: relativeEntries }),
1277
+ });
1278
+ if (!res.ok) throw new Error(await res.text());
1279
+ } catch (err) {
1280
+ showMsg("Failed to save: " + err.message, "error");
1281
+ }
1282
+ }
1283
+
1284
+ function moveExtraFile(index, direction) {
1285
+ const target = index + direction;
1286
+ if (target < 0 || target >= extraFiles.length) return;
1287
+ const next = [...extraFiles];
1288
+ [next[index], next[target]] = [next[target], next[index]];
1289
+ extraFiles = next;
1290
+ saveExtraFiles();
1291
+ renderExtraFiles();
1292
+ }
1293
+
1294
+ // ── Diagram Palettes ───────────────────────────────────────
1295
+ // NODE_COLORS_ADMIN mirrors constants.js NODE_COLORS (default bg values).
1296
+ // Mirrors NODE_COLORS from constants.js (bg + original border).
1297
+ const NODE_COLORS_ADMIN = {
1298
+ "c-white": { bg: "#ffffff", border: "#dbd5d1", label: "White" },
1299
+ "c-gray": { bg: "#f5f5f4", border: "#a8a29e", label: "Gris" },
1300
+ "c-slate": { bg: "#f1f5f9", border: "#64748b", label: "Ardoise" },
1301
+ "c-blue": { bg: "#dbeafe", border: "#3b82f6", label: "Bleu" },
1302
+ "c-sky": { bg: "#e0f2fe", border: "#0ea5e9", label: "Ciel" },
1303
+ "c-cyan": { bg: "#cffafe", border: "#06b6d4", label: "Cyan" },
1304
+ "c-teal": { bg: "#ccfbf1", border: "#14b8a6", label: "Teal" },
1305
+ "c-green": { bg: "#dcfce7", border: "#22c55e", label: "Vert" },
1306
+ "c-lime": { bg: "#ecfccb", border: "#84cc16", label: "Lime" },
1307
+ "c-amber": { bg: "#fef9c3", border: "#f59e0b", label: "Ambre" },
1308
+ "c-orange": { bg: "#ffedd5", border: "#f97316", label: "Orange" },
1309
+ "c-red": { bg: "#fee2e2", border: "#ef4444", label: "Rouge" },
1310
+ "c-rose": { bg: "#ffe4e6", border: "#f43f5e", label: "Rose" },
1311
+ "c-pink": { bg: "#fce7f3", border: "#ec4899", label: "Fuschia" },
1312
+ "c-purple": { bg: "#ede9fe", border: "#8b5cf6", label: "Violet" },
1313
+ };
1314
+ const NODE_PALETTE_KEYS = Object.keys(NODE_COLORS_ADMIN); // fixed order of 15 slots
1315
+ const DEFAULT_EDGE_PALETTE_ADMIN = [
1316
+ "#ffffff",
1317
+ "#a8a29e",
1318
+ "#374151",
1319
+ "#3b82f6",
1320
+ "#14b8a6",
1321
+ "#22c55e",
1322
+ "#f97316",
1323
+ "#ef4444",
1324
+ "#a855f7",
1325
+ ];
1326
+
1327
+ // nodePalette: array of 15 bg hex strings, one per slot (positional).
1328
+ let nodePalette = NODE_PALETTE_KEYS.map((k) => NODE_COLORS_ADMIN[k].bg);
1329
+ let edgePalette = [...DEFAULT_EDGE_PALETTE_ADMIN];
1330
+
1331
+ // Per-slot lightness ratio (border_L / bg_L) — mirrors NODE_L_RATIOS from constants.js.
1332
+ const NODE_L_RATIOS_ADMIN = {
1333
+ "c-gray": 0.667,
1334
+ "c-slate": 0.488,
1335
+ "c-blue": 0.645,
1336
+ "c-sky": 0.518,
1337
+ "c-cyan": 0.473,
1338
+ "c-teal": 0.448,
1339
+ "c-green": 0.489,
1340
+ "c-lime": 0.496,
1341
+ "c-amber": 0.57,
1342
+ "c-orange": 0.578,
1343
+ "c-red": 0.64,
1344
+ "c-rose": 0.636,
1345
+ "c-pink": 0.638,
1346
+ "c-purple": 0.694,
1347
+ "c-indigo": 0.71,
1348
+ };
1349
+
1350
+ // Mirrors deriveNodeColors from constants.js (no ES module in admin.html).
1351
+ function deriveNodeColors(bgHex, lRatio = 0.6) {
1352
+ const r = parseInt(bgHex.slice(1, 3), 16) / 255;
1353
+ const g = parseInt(bgHex.slice(3, 5), 16) / 255;
1354
+ const b = parseInt(bgHex.slice(5, 7), 16) / 255;
1355
+ const max = Math.max(r, g, b),
1356
+ min = Math.min(r, g, b);
1357
+ const l = (max + min) / 2;
1358
+ const d = max - min;
1359
+ let h = 0,
1360
+ s = 0;
1361
+ if (d > 0) {
1362
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1363
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
1364
+ else if (max === g) h = ((b - r) / d + 2) / 6;
1365
+ else h = ((r - g) / d + 4) / 6;
1366
+ }
1367
+ function hslToHex(hh, ss, ll) {
1368
+ let rr, gg, bb;
1369
+ if (ss === 0) {
1370
+ rr = gg = bb = ll;
1371
+ } else {
1372
+ const hue2rgb = (p, q, t) => {
1373
+ if (t < 0) t += 1;
1374
+ if (t > 1) t -= 1;
1375
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
1376
+ if (t < 1 / 2) return q;
1377
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
1378
+ return p;
1379
+ };
1380
+ const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
1381
+ const p = 2 * ll - q;
1382
+ rr = hue2rgb(p, q, hh + 1 / 3);
1383
+ gg = hue2rgb(p, q, hh);
1384
+ bb = hue2rgb(p, q, hh - 1 / 3);
1385
+ }
1386
+ return (
1387
+ "#" +
1388
+ [rr, gg, bb]
1389
+ .map((v) =>
1390
+ Math.max(0, Math.min(255, Math.round(v * 255)))
1391
+ .toString(16)
1392
+ .padStart(2, "0"),
1393
+ )
1394
+ .join("")
1395
+ );
1396
+ }
1397
+ const border = hslToHex(h, s, l * lRatio);
1398
+ return { bg: bgHex, border };
1399
+ }
1400
+
1401
+ function initPalettes(cfg) {
1402
+ // nodePalette: positional array of 15 bg hex strings
1403
+ if (
1404
+ Array.isArray(cfg.diagramNodePalette) &&
1405
+ cfg.diagramNodePalette.length
1406
+ ) {
1407
+ nodePalette = NODE_PALETTE_KEYS.map(
1408
+ (k, i) => cfg.diagramNodePalette[i] || NODE_COLORS_ADMIN[k].bg,
1409
+ );
1410
+ } else {
1411
+ nodePalette = NODE_PALETTE_KEYS.map((k) => NODE_COLORS_ADMIN[k].bg);
1412
+ }
1413
+ edgePalette =
1414
+ Array.isArray(cfg.diagramEdgePalette) && cfg.diagramEdgePalette.length
1415
+ ? [...cfg.diagramEdgePalette]
1416
+ : [...DEFAULT_EDGE_PALETTE_ADMIN];
1417
+ renderNodePalette();
1418
+ renderEdgePalette();
1419
+ }
1420
+
1421
+ function swatchBorder(key, bg) {
1422
+ // Use original hand-crafted border if bg is unchanged; derive for custom colours.
1423
+ const def = NODE_COLORS_ADMIN[key];
1424
+ return bg === def.bg
1425
+ ? def.border
1426
+ : deriveNodeColors(bg, NODE_L_RATIOS_ADMIN[key] || 0.6).border;
1427
+ }
1428
+
1429
+ function renderNodePalette() {
1430
+ const container = document.getElementById("nodeSwatches");
1431
+ container.innerHTML = NODE_PALETTE_KEYS.map((key, i) => {
1432
+ const bg = nodePalette[i] || NODE_COLORS_ADMIN[key].bg;
1433
+ const border = swatchBorder(key, bg);
1434
+ return `<div style="position:relative;width:32px;height:32px" title="${esc(NODE_COLORS_ADMIN[key].label)}">
1435
+ <div style="width:32px;height:32px;border-radius:50%;background:${bg};border:3px solid ${border};pointer-events:none"></div>
1436
+ <input type="color" value="${bg}" data-i="${i}"
1437
+ oninput="previewNodeColor(${i},this.value)"
1438
+ onchange="commitNodeColor(${i},this.value)"
1439
+ style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;cursor:pointer;border:none;padding:0" />
1440
+ </div>`;
1441
+ }).join("");
1442
+ }
1443
+
1444
+ function previewNodeColor(i, hex) {
1445
+ nodePalette[i] = hex.toLowerCase();
1446
+ const key = NODE_PALETTE_KEYS[i];
1447
+ const border = swatchBorder(key, hex.toLowerCase());
1448
+ const wrapper = document.getElementById("nodeSwatches").children[i];
1449
+ if (wrapper) {
1450
+ const swatch = wrapper.querySelector("div");
1451
+ if (swatch) {
1452
+ swatch.style.background = hex;
1453
+ swatch.style.borderColor = border;
1454
+ }
1455
+ }
1456
+ }
1457
+
1458
+ function commitNodeColor(i, hex) {
1459
+ previewNodeColor(i, hex);
1460
+ }
1461
+
1462
+ function resetNodePalette() {
1463
+ nodePalette = NODE_PALETTE_KEYS.map((k) => NODE_COLORS_ADMIN[k].bg);
1464
+ renderNodePalette();
1465
+ }
1466
+
1467
+ function renderEdgePalette() {
1468
+ const container = document.getElementById("edgeSwatches");
1469
+ container.innerHTML = edgePalette
1470
+ .map(
1471
+ (hex, i) => `
1472
+ <div style="display:flex;flex-direction:column;align-items:center;gap:3px">
1473
+ <div style="width:26px;height:26px;border-radius:50%;background:${hex};border:2px solid rgba(0,0,0,0.15)"></div>
1474
+ <button type="button" data-i="${i}" onclick="removeEdgeColor(+this.dataset.i)"
1475
+ style="font-size:10px;color:#ef4444;cursor:pointer;line-height:1;background:none;border:none"
1476
+ title="Supprimer">✕</button>
1477
+ </div>`,
1478
+ )
1479
+ .join("");
1480
+ }
1481
+
1482
+ function addEdgeColor() {
1483
+ const hex = document.getElementById("newEdgeColor").value.toLowerCase();
1484
+ if (edgePalette.includes(hex)) return;
1485
+ edgePalette = [...edgePalette, hex];
1486
+ renderEdgePalette();
1487
+ }
1488
+
1489
+ function removeEdgeColor(index) {
1490
+ if (edgePalette.length <= 1) return;
1491
+ edgePalette = edgePalette.filter((_, i) => i !== index);
1492
+ renderEdgePalette();
1493
+ }
1494
+
1495
+ function resetEdgePalette() {
1496
+ edgePalette = [...DEFAULT_EDGE_PALETTE_ADMIN];
1497
+ renderEdgePalette();
1498
+ }
1499
+
1500
+ function renderExtraFiles() {
1501
+ const list = document.getElementById("extra-files-list");
1502
+ const empty = document.getElementById("extra-files-empty");
1503
+ if (extraFiles.length === 0) {
1504
+ list.innerHTML = "";
1505
+ empty.style.display = "";
1506
+ return;
1507
+ }
1508
+ empty.style.display = "none";
1509
+ list.innerHTML = extraFiles
1510
+ .map(
1511
+ (f, i) => `
1512
+ <div class="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-50 dark:bg-gray-800">
1513
+ <div class="flex flex-col shrink-0">
1514
+ <button data-i="${i}" data-d="-1" onclick="moveExtraFile(+this.dataset.i, +this.dataset.d)"
1515
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 leading-none disabled:opacity-20"
1516
+ ${i === 0 ? "disabled" : ""}>&#8593;</button>
1517
+ <button data-i="${i}" data-d="1" onclick="moveExtraFile(+this.dataset.i, +this.dataset.d)"
1518
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 leading-none disabled:opacity-20"
1519
+ ${i === extraFiles.length - 1 ? "disabled" : ""}>&#8595;</button>
1520
+ </div>
1521
+ <div class="overflow-x-auto flex-1 min-w-0">
1522
+ <span class="font-mono text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap" title="${esc(f)}">${esc(relativeDisplay(f))}</span>
1523
+ </div>
1524
+ <button data-path="${esc(f)}" onclick="removeExtraFile(this.dataset.path)"
1525
+ class="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 shrink-0">Remove</button>
1526
+ </div>`,
1527
+ )
1528
+ .join("");
1529
+ }
1530
+ </script>
1531
+ </body>
1532
+ </html>