mulmoclaude 0.3.0 → 0.5.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 (312) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
  3. package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
  4. package/client/assets/index-CY-WpQUm.css +2 -0
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
  6. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  7. package/client/index.html +2 -4
  8. package/package.json +17 -15
  9. package/server/agent/attachmentConverter.ts +2 -2
  10. package/server/agent/backend/claude-code.ts +170 -0
  11. package/server/agent/backend/index.ts +14 -0
  12. package/server/agent/backend/types.ts +65 -0
  13. package/server/agent/index.ts +31 -159
  14. package/server/agent/mcp-server.ts +88 -10
  15. package/server/agent/mcp-tools/index.ts +8 -7
  16. package/server/agent/mcp-tools/notify.ts +76 -0
  17. package/server/agent/mcp-tools/x.ts +12 -2
  18. package/server/agent/plugin-names.ts +10 -4
  19. package/server/agent/prompt.ts +187 -26
  20. package/server/agent/resumeFailover.ts +5 -5
  21. package/server/agent/sandboxMounts.ts +3 -3
  22. package/server/api/auth/bearerAuth.ts +3 -3
  23. package/server/api/auth/token.ts +2 -2
  24. package/server/api/routes/agent.ts +99 -4
  25. package/server/api/routes/chart.ts +13 -0
  26. package/server/api/routes/chat-index.ts +2 -1
  27. package/server/api/routes/config.ts +35 -8
  28. package/server/api/routes/files.ts +75 -24
  29. package/server/api/routes/html.ts +15 -2
  30. package/server/api/routes/image.ts +75 -20
  31. package/server/api/routes/mulmo-script.ts +33 -31
  32. package/server/api/routes/news.ts +146 -0
  33. package/server/api/routes/notifications.ts +58 -2
  34. package/server/api/routes/pdf.ts +2 -2
  35. package/server/api/routes/plugins.ts +73 -91
  36. package/server/api/routes/presentHtml.ts +9 -0
  37. package/server/api/routes/roles.ts +12 -2
  38. package/server/api/routes/scheduler.ts +20 -11
  39. package/server/api/routes/schedulerTasks.ts +58 -21
  40. package/server/api/routes/sessions.ts +15 -4
  41. package/server/api/routes/sessionsCursor.ts +4 -4
  42. package/server/api/routes/skills.ts +26 -5
  43. package/server/api/routes/sources.ts +8 -7
  44. package/server/api/routes/todos.ts +30 -0
  45. package/server/api/routes/todosColumnsHandlers.ts +13 -27
  46. package/server/api/routes/todosHandlers.ts +1 -1
  47. package/server/api/routes/todosItemsHandlers.ts +14 -14
  48. package/server/api/routes/wiki/frontmatter.ts +86 -0
  49. package/server/api/routes/wiki.ts +335 -75
  50. package/server/api/sandboxStatus.ts +1 -1
  51. package/server/events/notifications.ts +32 -8
  52. package/server/events/pub-sub/index.ts +3 -3
  53. package/server/events/relay-client.ts +26 -16
  54. package/server/events/resolveRelayBridgeOptions.ts +125 -0
  55. package/server/index.ts +72 -49
  56. package/server/system/config.ts +5 -5
  57. package/server/system/credentials.ts +7 -5
  58. package/server/system/env.ts +15 -5
  59. package/server/system/macosNotify.ts +152 -0
  60. package/server/utils/errors.ts +11 -2
  61. package/server/utils/fetch.ts +54 -0
  62. package/server/utils/files/atomic.ts +18 -17
  63. package/server/utils/files/image-store.ts +19 -13
  64. package/server/utils/files/journal-io.ts +2 -2
  65. package/server/utils/files/json.ts +5 -5
  66. package/server/utils/files/markdown-image-fill.ts +131 -0
  67. package/server/utils/files/markdown-store.ts +22 -6
  68. package/server/utils/files/naming.ts +20 -10
  69. package/server/utils/files/reference-dirs-io.ts +3 -3
  70. package/server/utils/files/roles-io.ts +4 -4
  71. package/server/utils/files/safe.ts +14 -14
  72. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  73. package/server/utils/files/spreadsheet-store.ts +15 -10
  74. package/server/utils/files/workspace-io.ts +12 -12
  75. package/server/utils/gemini.ts +30 -4
  76. package/server/utils/gitignore.ts +9 -9
  77. package/server/utils/id.ts +40 -8
  78. package/server/utils/json.ts +5 -5
  79. package/server/utils/logBackgroundError.ts +12 -3
  80. package/server/utils/logPreview.ts +24 -0
  81. package/server/utils/markdown.ts +5 -5
  82. package/server/utils/port.d.mts +6 -0
  83. package/server/utils/port.mjs +48 -0
  84. package/server/utils/promptMeta.ts +32 -0
  85. package/server/utils/request.ts +12 -6
  86. package/server/utils/slug.ts +65 -4
  87. package/server/utils/spawn.ts +1 -1
  88. package/server/utils/types.ts +2 -2
  89. package/server/workspace/chat-index/index.ts +1 -1
  90. package/server/workspace/chat-index/summarizer.ts +5 -5
  91. package/server/workspace/custom-dirs.ts +5 -5
  92. package/server/workspace/helps/gemini.md +57 -0
  93. package/server/workspace/helps/index.md +2 -1
  94. package/server/workspace/helps/sources.md +42 -0
  95. package/server/workspace/helps/wiki.md +40 -5
  96. package/server/workspace/journal/archivist-cli.ts +121 -0
  97. package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
  98. package/server/workspace/journal/dailyPass.ts +78 -38
  99. package/server/workspace/journal/diff.ts +2 -2
  100. package/server/workspace/journal/index.ts +56 -5
  101. package/server/workspace/journal/memoryExtractor.ts +1 -1
  102. package/server/workspace/journal/optimizationPass.ts +4 -5
  103. package/server/workspace/journal/paths.ts +8 -24
  104. package/server/workspace/journal/state.ts +18 -8
  105. package/server/workspace/news/reader.ts +248 -0
  106. package/server/workspace/paths.ts +4 -3
  107. package/server/workspace/reference-dirs.ts +3 -3
  108. package/server/workspace/skills/parser.ts +6 -6
  109. package/server/workspace/skills/scheduler.ts +5 -4
  110. package/server/workspace/skills/user-tasks.ts +3 -2
  111. package/server/workspace/skills/writer.ts +3 -3
  112. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  113. package/server/workspace/sources/classifier.ts +1 -1
  114. package/server/workspace/sources/fetchers/rss.ts +5 -5
  115. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  116. package/server/workspace/sources/interests.ts +3 -3
  117. package/server/workspace/sources/paths.ts +6 -6
  118. package/server/workspace/sources/pipeline/fetch.ts +59 -13
  119. package/server/workspace/sources/pipeline/index.ts +59 -7
  120. package/server/workspace/sources/pipeline/notify.ts +13 -5
  121. package/server/workspace/sources/pipeline/plan.ts +11 -9
  122. package/server/workspace/sources/pipeline/summarize.ts +1 -1
  123. package/server/workspace/sources/pipeline/write.ts +5 -5
  124. package/server/workspace/sources/rateLimiter.ts +1 -1
  125. package/server/workspace/sources/sourceState.ts +9 -4
  126. package/server/workspace/sources/types.ts +9 -0
  127. package/server/workspace/sources/urls.ts +1 -1
  128. package/server/workspace/tool-trace/classify.ts +4 -4
  129. package/server/workspace/workspace.ts +7 -7
  130. package/src/App.vue +477 -251
  131. package/src/components/CanvasViewToggle.vue +12 -10
  132. package/src/components/ChatInput.vue +112 -105
  133. package/src/components/FileContentHeader.vue +10 -7
  134. package/src/components/FileContentRenderer.vue +37 -10
  135. package/src/components/FileTree.vue +34 -4
  136. package/src/components/FileTreePane.vue +32 -27
  137. package/src/components/FilesView.vue +5 -3
  138. package/src/components/FilterChip.vue +22 -0
  139. package/src/components/LockStatusPopup.vue +19 -13
  140. package/src/components/NewsView.vue +252 -0
  141. package/src/components/NotificationBell.vue +35 -9
  142. package/src/components/NotificationToast.vue +4 -1
  143. package/src/components/PageChatComposer.vue +101 -0
  144. package/src/components/PluginLauncher.vue +36 -62
  145. package/src/components/RightSidebar.vue +13 -10
  146. package/src/components/RoleSelector.vue +3 -2
  147. package/src/components/SessionHeaderControls.vue +63 -0
  148. package/src/components/SessionHistoryExpandButton.vue +30 -0
  149. package/src/components/SessionHistoryPanel.vue +64 -93
  150. package/src/components/SessionHistoryToggleButton.vue +40 -0
  151. package/src/components/SessionRoleIcon.vue +72 -0
  152. package/src/components/SessionSidebar.vue +96 -0
  153. package/src/components/SessionTabBar.vue +44 -51
  154. package/src/components/SettingsMcpTab.vue +361 -52
  155. package/src/components/SettingsModal.vue +203 -72
  156. package/src/components/SettingsReferenceDirsTab.vue +72 -51
  157. package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
  158. package/src/components/SidebarHeader.vue +50 -16
  159. package/src/components/SourcesManager.vue +900 -0
  160. package/src/components/SourcesView.vue +45 -0
  161. package/src/components/StackView.vue +84 -48
  162. package/src/components/SuggestionsPanel.vue +25 -36
  163. package/src/components/SystemFileBanner.vue +106 -0
  164. package/src/components/ThinkingIndicator.vue +41 -0
  165. package/src/components/TodoExplorer.vue +72 -22
  166. package/src/components/todo/TodoAddDialog.vue +17 -12
  167. package/src/components/todo/TodoEditDialog.vue +7 -2
  168. package/src/components/todo/TodoEditPanel.vue +15 -10
  169. package/src/components/todo/TodoKanbanView.vue +16 -6
  170. package/src/components/todo/TodoListView.vue +14 -3
  171. package/src/components/todo/TodoTableView.vue +36 -5
  172. package/src/composables/favicon/conditions.ts +76 -0
  173. package/src/composables/favicon/resolveColor.ts +93 -0
  174. package/src/composables/favicon/types.ts +61 -0
  175. package/src/composables/useAppApi.ts +23 -0
  176. package/src/composables/useChatScroll.ts +5 -5
  177. package/src/composables/useCurrentRole.ts +32 -0
  178. package/src/composables/useDynamicFavicon.ts +174 -58
  179. package/src/composables/useEventListeners.ts +7 -12
  180. package/src/composables/useFaviconState.ts +93 -12
  181. package/src/composables/useFileSelection.ts +25 -6
  182. package/src/composables/useHealth.ts +76 -7
  183. package/src/composables/useLayoutMode.ts +27 -0
  184. package/src/composables/useNewsItems.ts +38 -0
  185. package/src/composables/useNewsReadState.ts +75 -0
  186. package/src/composables/useNotifications.ts +76 -13
  187. package/src/composables/usePendingCalls.ts +11 -1
  188. package/src/composables/useRoles.ts +6 -10
  189. package/src/composables/useRunElapsed.ts +80 -0
  190. package/src/composables/useSessionDerived.ts +21 -5
  191. package/src/composables/useSessionHistory.ts +7 -17
  192. package/src/composables/useSidePanelVisible.ts +25 -0
  193. package/src/composables/useViewLayout.ts +16 -37
  194. package/src/config/apiRoutes.ts +19 -6
  195. package/src/config/historyFilters.ts +30 -0
  196. package/src/config/mcpCatalog.ts +285 -0
  197. package/src/config/mcpTypes.ts +26 -0
  198. package/src/config/roles.ts +19 -51
  199. package/src/config/systemFileDescriptors.ts +170 -0
  200. package/src/config/toolNames.ts +6 -1
  201. package/src/config/workspacePaths.ts +1 -0
  202. package/src/index.css +14 -0
  203. package/src/lang/de.ts +706 -0
  204. package/src/lang/en.ts +726 -0
  205. package/src/lang/es.ts +712 -0
  206. package/src/lang/fr.ts +704 -0
  207. package/src/lang/ja.ts +707 -0
  208. package/src/lang/ko.ts +709 -0
  209. package/src/lang/pt-BR.ts +702 -0
  210. package/src/lang/zh.ts +705 -0
  211. package/src/lib/vue-i18n.ts +97 -0
  212. package/src/main.ts +3 -0
  213. package/src/plugins/canvas/View.vue +104 -186
  214. package/src/plugins/canvas/definition.ts +0 -8
  215. package/src/plugins/canvas/index.ts +3 -2
  216. package/src/plugins/chart/Preview.vue +1 -1
  217. package/src/plugins/chart/View.vue +9 -4
  218. package/src/plugins/chart/index.ts +3 -2
  219. package/src/plugins/editImage/index.ts +3 -2
  220. package/src/plugins/generateImage/index.ts +3 -2
  221. package/src/plugins/manageRoles/Preview.vue +4 -1
  222. package/src/plugins/manageRoles/View.vue +67 -46
  223. package/src/plugins/manageRoles/index.ts +3 -2
  224. package/src/plugins/manageSkills/Preview.vue +8 -3
  225. package/src/plugins/manageSkills/View.vue +39 -34
  226. package/src/plugins/manageSkills/index.ts +3 -2
  227. package/src/plugins/manageSource/Preview.vue +1 -1
  228. package/src/plugins/manageSource/View.vue +3 -687
  229. package/src/plugins/manageSource/index.ts +3 -2
  230. package/src/plugins/markdown/Preview.vue +1 -1
  231. package/src/plugins/markdown/View.vue +164 -73
  232. package/src/plugins/markdown/definition.ts +6 -4
  233. package/src/plugins/markdown/index.ts +3 -2
  234. package/src/plugins/presentForm/Preview.vue +99 -0
  235. package/src/plugins/presentForm/View.vue +675 -0
  236. package/src/plugins/presentForm/definition.ts +127 -0
  237. package/src/plugins/presentForm/index.ts +18 -0
  238. package/src/plugins/presentForm/plugin.ts +94 -0
  239. package/src/plugins/presentForm/types.ts +109 -0
  240. package/src/plugins/presentHtml/Preview.vue +1 -1
  241. package/src/plugins/presentHtml/View.vue +7 -4
  242. package/src/plugins/presentHtml/index.ts +3 -2
  243. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  244. package/src/plugins/presentMulmoScript/View.vue +36 -26
  245. package/src/plugins/presentMulmoScript/index.ts +3 -2
  246. package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
  247. package/src/plugins/scheduler/AutomationsView.vue +23 -0
  248. package/src/plugins/scheduler/CalendarView.vue +23 -0
  249. package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
  250. package/src/plugins/scheduler/Preview.vue +7 -4
  251. package/src/plugins/scheduler/TasksTab.vue +119 -28
  252. package/src/plugins/scheduler/View.vue +75 -32
  253. package/src/plugins/scheduler/automationsDefinition.ts +58 -0
  254. package/src/plugins/scheduler/calendarDefinition.ts +46 -0
  255. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  256. package/src/plugins/scheduler/index.ts +68 -14
  257. package/src/plugins/scheduler/legacyShape.ts +34 -0
  258. package/src/plugins/spreadsheet/Preview.vue +9 -5
  259. package/src/plugins/spreadsheet/View.vue +43 -57
  260. package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
  261. package/src/plugins/spreadsheet/index.ts +3 -2
  262. package/src/plugins/textResponse/Preview.vue +15 -58
  263. package/src/plugins/textResponse/View.vue +42 -45
  264. package/src/plugins/textResponse/utils.ts +25 -0
  265. package/src/plugins/todo/Preview.vue +11 -6
  266. package/src/plugins/todo/View.vue +27 -13
  267. package/src/plugins/todo/composables/useTodos.ts +3 -1
  268. package/src/plugins/todo/index.ts +3 -2
  269. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  270. package/src/plugins/ui-image/ImageView.vue +7 -4
  271. package/src/plugins/wiki/Preview.vue +5 -2
  272. package/src/plugins/wiki/View.vue +539 -92
  273. package/src/plugins/wiki/index.ts +5 -2
  274. package/src/plugins/wiki/route.ts +121 -0
  275. package/src/router/guards.ts +43 -24
  276. package/src/router/index.ts +53 -26
  277. package/src/router/pageRoutes.ts +23 -0
  278. package/src/tools/index.ts +12 -5
  279. package/src/tools/legacyPluginNames.ts +13 -0
  280. package/src/types/notification.ts +31 -6
  281. package/src/types/vue-i18n.d.ts +20 -0
  282. package/src/utils/agent/eventDispatch.ts +3 -6
  283. package/src/utils/agent/formatElapsed.ts +37 -0
  284. package/src/utils/agent/request.ts +22 -1
  285. package/src/utils/canvas/layoutMode.ts +26 -0
  286. package/src/utils/canvas/sidePanelVisible.ts +19 -0
  287. package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
  288. package/src/utils/errors.ts +9 -2
  289. package/src/utils/files/filename.ts +24 -0
  290. package/src/utils/filesPreview/schedulerPreview.ts +9 -3
  291. package/src/utils/id.ts +18 -0
  292. package/src/utils/image/cacheBust.ts +16 -0
  293. package/src/utils/image/resolve.ts +16 -0
  294. package/src/utils/markdown/taskList.ts +175 -0
  295. package/src/utils/mcp/interpolateSpec.ts +97 -0
  296. package/src/utils/notification/dispatch.ts +51 -15
  297. package/src/utils/path/workspaceLinkRouter.ts +99 -0
  298. package/src/utils/session/mergeSessions.ts +5 -0
  299. package/src/utils/sources/filter.ts +69 -0
  300. package/src/vite-env.d.ts +9 -0
  301. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  302. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  303. package/client/assets/index-Bm70FDU2.css +0 -1
  304. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  305. package/server/workspace/journal/linkRewrite.ts +0 -4
  306. package/src/components/ToolResultsPanel.vue +0 -77
  307. package/src/composables/useCanvasViewMode.ts +0 -121
  308. package/src/plugins/scheduler/definition.ts +0 -57
  309. package/src/utils/canvas/viewMode.ts +0 -46
  310. package/src/utils/role/plugins.ts +0 -12
  311. package/src/utils/session/seedRoleDefault.ts +0 -35
  312. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -0,0 +1,675 @@
1
+ <template>
2
+ <div class="w-full h-full overflow-y-auto p-8">
3
+ <div v-if="formData" class="max-w-3xl w-full mx-auto">
4
+ <!-- Form Title -->
5
+ <h2 v-if="formData.title" class="text-gray-900 text-3xl font-bold mb-4 text-center">
6
+ {{ formData.title }}
7
+ </h2>
8
+
9
+ <!-- Form Description -->
10
+ <p v-if="formData.description" class="text-gray-600 text-center mb-8 text-lg">
11
+ {{ formData.description }}
12
+ </p>
13
+
14
+ <!-- Error Summary -->
15
+ <div v-if="showErrorSummary && fieldErrors.size > 0" class="bg-red-50 border-2 border-red-500 rounded-lg p-4 mb-6" role="alert">
16
+ <h3 class="text-red-800 font-semibold mb-2 flex items-center gap-2">
17
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
18
+ <path
19
+ fill-rule="evenodd"
20
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
21
+ clip-rule="evenodd"
22
+ />
23
+ </svg>
24
+ {{ t("pluginPresentForm.errorSummary") }}
25
+ </h3>
26
+ <ul class="text-red-700 space-y-1">
27
+ <li v-for="[fieldId, error] in fieldErrors" :key="fieldId">
28
+ <a :href="`#${fieldId}`" @click.prevent="focusField(fieldId)" class="hover:underline cursor-pointer">
29
+ {{ error.message }}
30
+ </a>
31
+ </li>
32
+ </ul>
33
+ </div>
34
+
35
+ <!-- Form Fields -->
36
+ <form @submit.prevent="handleSubmit" class="space-y-6">
37
+ <div
38
+ v-for="field in formData.fields"
39
+ :key="field.id"
40
+ :id="field.id"
41
+ class="form-field"
42
+ :class="{ 'has-error': hasError(field.id) && touched.has(field.id) }"
43
+ >
44
+ <!-- Field Label -->
45
+ <label
46
+ :for="`input-${field.id}`"
47
+ class="block text-gray-800 font-semibold mb-2"
48
+ :class="{
49
+ 'text-red-600': hasError(field.id) && touched.has(field.id),
50
+ }"
51
+ >
52
+ {{ field.label }}
53
+ <span v-if="field.required" class="text-red-500 ml-1" aria-label="required">{{ t("pluginPresentForm.requiredMarker") }}</span>
54
+ </label>
55
+
56
+ <!-- Field Description -->
57
+ <p v-if="field.description" class="text-gray-600 text-sm mb-2">
58
+ {{ field.description }}
59
+ </p>
60
+
61
+ <!-- Text Field -->
62
+ <input
63
+ v-if="field.type === 'text'"
64
+ :id="`input-${field.id}`"
65
+ v-model="formValues[field.id]"
66
+ type="text"
67
+ :placeholder="field.placeholder"
68
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
69
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
70
+ @blur="handleBlur(field.id)"
71
+ @input="handleInput(field.id)"
72
+ class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
73
+ :class="{
74
+ 'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
75
+ 'border-gray-300': !hasError(field.id) || !touched.has(field.id),
76
+ }"
77
+ />
78
+
79
+ <!-- Textarea Field -->
80
+ <textarea
81
+ v-else-if="field.type === 'textarea'"
82
+ :id="`input-${field.id}`"
83
+ v-model="formValues[field.id]"
84
+ :placeholder="field.placeholder"
85
+ :rows="field.rows || 4"
86
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
87
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
88
+ @blur="handleBlur(field.id)"
89
+ @input="handleInput(field.id)"
90
+ class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors resize-y"
91
+ :class="{
92
+ 'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
93
+ 'border-gray-300': !hasError(field.id) || !touched.has(field.id),
94
+ }"
95
+ />
96
+
97
+ <!-- Number Field -->
98
+ <input
99
+ v-else-if="field.type === 'number'"
100
+ :id="`input-${field.id}`"
101
+ v-model.number="formValues[field.id]"
102
+ type="number"
103
+ :min="field.min"
104
+ :max="field.max"
105
+ :step="field.step"
106
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
107
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
108
+ @blur="handleBlur(field.id)"
109
+ @input="handleInput(field.id)"
110
+ class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
111
+ :class="{
112
+ 'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
113
+ 'border-gray-300': !hasError(field.id) || !touched.has(field.id),
114
+ }"
115
+ />
116
+
117
+ <!-- Date Field -->
118
+ <input
119
+ v-else-if="field.type === 'date'"
120
+ :id="`input-${field.id}`"
121
+ v-model="formValues[field.id]"
122
+ type="date"
123
+ :min="field.minDate"
124
+ :max="field.maxDate"
125
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
126
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
127
+ @blur="handleBlur(field.id)"
128
+ @change="handleInput(field.id)"
129
+ class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
130
+ :class="{
131
+ 'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
132
+ 'border-gray-300': !hasError(field.id) || !touched.has(field.id),
133
+ }"
134
+ />
135
+
136
+ <!-- Time Field -->
137
+ <input
138
+ v-else-if="field.type === 'time'"
139
+ :id="`input-${field.id}`"
140
+ v-model="formValues[field.id]"
141
+ type="time"
142
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
143
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
144
+ @blur="handleBlur(field.id)"
145
+ @change="handleInput(field.id)"
146
+ class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
147
+ :class="{
148
+ 'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
149
+ 'border-gray-300': !hasError(field.id) || !touched.has(field.id),
150
+ }"
151
+ />
152
+
153
+ <!-- Radio Field -->
154
+ <div
155
+ v-else-if="field.type === 'radio'"
156
+ class="space-y-2"
157
+ role="radiogroup"
158
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
159
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
160
+ >
161
+ <label
162
+ v-for="(choice, index) in field.choices"
163
+ :key="index"
164
+ class="flex items-center p-3 border-2 border-gray-300 rounded-lg cursor-pointer transition-all hover:bg-gray-50"
165
+ :class="{
166
+ 'border-blue-500 bg-blue-50': formValues[field.id] === index,
167
+ 'border-gray-300': formValues[field.id] !== index,
168
+ }"
169
+ >
170
+ <input
171
+ type="radio"
172
+ :name="field.id"
173
+ :value="index"
174
+ v-model="formValues[field.id]"
175
+ @change="handleInput(field.id)"
176
+ @blur="handleBlur(field.id)"
177
+ class="mr-3 h-4 w-4 flex-shrink-0"
178
+ />
179
+ <span class="text-gray-800">{{ choice }}</span>
180
+ </label>
181
+ </div>
182
+
183
+ <!-- Dropdown Field -->
184
+ <select
185
+ v-else-if="field.type === 'dropdown'"
186
+ :id="`input-${field.id}`"
187
+ v-model="formValues[field.id]"
188
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
189
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
190
+ @blur="handleBlur(field.id)"
191
+ @change="handleInput(field.id)"
192
+ class="w-full px-4 py-2 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white"
193
+ :class="{
194
+ 'border-red-500 focus:ring-red-500': hasError(field.id) && touched.has(field.id),
195
+ 'border-gray-300': !hasError(field.id) || !touched.has(field.id),
196
+ }"
197
+ >
198
+ <option :value="null" disabled>{{ t("pluginPresentForm.selectOption") }}</option>
199
+ <option v-for="(choice, index) in field.choices" :key="index" :value="index">
200
+ {{ choice }}
201
+ </option>
202
+ </select>
203
+
204
+ <!-- Checkbox Field -->
205
+ <div
206
+ v-else-if="field.type === 'checkbox'"
207
+ class="space-y-2"
208
+ role="group"
209
+ :aria-invalid="hasError(field.id) && touched.has(field.id)"
210
+ :aria-describedby="hasError(field.id) && touched.has(field.id) ? `${field.id}-error` : undefined"
211
+ >
212
+ <label
213
+ v-for="(choice, index) in field.choices"
214
+ :key="index"
215
+ class="flex items-center p-3 border-2 border-gray-300 rounded-lg cursor-pointer transition-all hover:bg-gray-50"
216
+ :class="{
217
+ 'border-blue-500 bg-blue-50': (formValues[field.id] || []).includes(index),
218
+ 'border-gray-300': !(formValues[field.id] || []).includes(index),
219
+ }"
220
+ >
221
+ <input
222
+ type="checkbox"
223
+ :value="index"
224
+ v-model="formValues[field.id]"
225
+ @change="handleInput(field.id)"
226
+ @blur="handleBlur(field.id)"
227
+ class="mr-3 h-4 w-4 flex-shrink-0"
228
+ />
229
+ <span class="text-gray-800">{{ choice }}</span>
230
+ </label>
231
+ </div>
232
+
233
+ <!-- Error Message -->
234
+ <div
235
+ v-if="hasError(field.id) && touched.has(field.id)"
236
+ :id="`${field.id}-error`"
237
+ class="flex items-center gap-2 mt-2 text-red-600 text-sm"
238
+ role="alert"
239
+ >
240
+ <svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
241
+ <path
242
+ fill-rule="evenodd"
243
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
244
+ clip-rule="evenodd"
245
+ />
246
+ </svg>
247
+ {{ fieldErrors.get(field.id)?.message }}
248
+ </div>
249
+
250
+ <!-- Character Count (for text/textarea with maxLength) -->
251
+ <div
252
+ v-if="showCharCount(field)"
253
+ class="text-sm mt-2"
254
+ :class="{
255
+ 'text-amber-600 font-semibold': isNearLimit(field),
256
+ 'text-gray-500': !isNearLimit(field),
257
+ }"
258
+ >
259
+ <template v-if="field.maxLength">
260
+ {{ t("pluginPresentForm.charactersCount", { current: (formValues[field.id] || "").length, max: field.maxLength }) }}
261
+ </template>
262
+ <template v-else>
263
+ {{ t("pluginPresentForm.charactersCountNoMax", { current: (formValues[field.id] || "").length }) }}
264
+ </template>
265
+ </div>
266
+ </div>
267
+
268
+ <!-- Submit Button -->
269
+ <div class="mt-8 flex justify-center">
270
+ <button
271
+ type="submit"
272
+ :disabled="submitted"
273
+ :class="submitted ? 'bg-green-600 cursor-default' : 'bg-blue-600 hover:bg-blue-700'"
274
+ class="px-8 py-3 rounded-lg text-white font-semibold text-lg transition-colors"
275
+ >
276
+ {{ submitted ? t("pluginPresentForm.submitted") : t("pluginPresentForm.submit") }}
277
+ </button>
278
+ </div>
279
+
280
+ <!-- Progress Indicator -->
281
+ <div class="mt-4 text-center text-gray-600 text-sm">
282
+ {{ t("pluginPresentForm.progress", { filled: filledRequiredCount, total: requiredFieldsCount }) }}
283
+ </div>
284
+ </form>
285
+ </div>
286
+ </div>
287
+ </template>
288
+
289
+ <script setup lang="ts">
290
+ import { ref, watch, computed } from "vue";
291
+ import { useI18n } from "vue-i18n";
292
+ import type { ToolResult } from "gui-chat-protocol";
293
+ import type { FormData, FormField, TextField, TextareaField, NumberField, DateField, CheckboxField } from "./types";
294
+
295
+ const { t } = useI18n();
296
+
297
+ interface FieldError {
298
+ fieldId: string;
299
+ message: string;
300
+ type: "required" | "format" | "range" | "pattern" | "custom";
301
+ }
302
+
303
+ interface FormViewState {
304
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
305
+ userResponses: Record<string, any>;
306
+ touched: string[];
307
+ submitted?: boolean;
308
+ }
309
+
310
+ const props = defineProps<{
311
+ selectedResult: ToolResult | null;
312
+ sendTextMessage: (text?: string) => void;
313
+ }>();
314
+
315
+ const emit = defineEmits<{
316
+ updateResult: [result: ToolResult];
317
+ }>();
318
+
319
+ const formData = ref<FormData | null>(null);
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ const formValues = ref<Record<string, any>>({});
322
+ const touched = ref<Set<string>>(new Set());
323
+ const fieldErrors = ref<Map<string, FieldError>>(new Map());
324
+ const submitted = ref<boolean>(false);
325
+ const showErrorSummary = ref<boolean>(false);
326
+ const isRestoring = ref<boolean>(false);
327
+
328
+ function isFreshResult(newResult: ToolResult, oldResult: ToolResult | null | undefined): boolean {
329
+ return !oldResult || !oldResult.jsonData || oldResult.uuid !== newResult.uuid || oldResult.jsonData !== newResult.jsonData;
330
+ }
331
+
332
+ function restoreViewState(state: FormViewState): void {
333
+ if (state.userResponses) Object.assign(formValues.value, state.userResponses);
334
+ if (state.touched) {
335
+ touched.value = new Set(state.touched);
336
+ state.touched.forEach((fieldId) => validateField(fieldId));
337
+ }
338
+ if (state.submitted !== undefined) submitted.value = state.submitted;
339
+ }
340
+
341
+ function applyNewResult(newResult: ToolResult): void {
342
+ isRestoring.value = true;
343
+ formData.value = newResult.jsonData as FormData;
344
+ formValues.value = {};
345
+ formData.value.fields.forEach((field) => {
346
+ formValues.value[field.id] = getDefaultValue(field);
347
+ });
348
+ if (newResult.viewState) restoreViewState(newResult.viewState as unknown as FormViewState);
349
+ isRestoring.value = false;
350
+ }
351
+
352
+ // Initialize form data and restore state
353
+ watch(
354
+ () => props.selectedResult,
355
+ (newResult, oldResult) => {
356
+ if (!newResult || newResult.toolName !== "presentForm" || !newResult.jsonData) return;
357
+ if (!isFreshResult(newResult, oldResult)) return;
358
+ applyNewResult(newResult);
359
+ },
360
+ { immediate: true },
361
+ );
362
+
363
+ // Save state to viewState - watch all state changes together
364
+ watch(
365
+ [formValues, touched, submitted],
366
+ () => {
367
+ if (isRestoring.value || !props.selectedResult) return;
368
+
369
+ const updatedResult: ToolResult = {
370
+ ...props.selectedResult,
371
+ viewState: {
372
+ userResponses: { ...formValues.value },
373
+ touched: Array.from(touched.value),
374
+ submitted: submitted.value,
375
+ },
376
+ };
377
+ emit("updateResult", updatedResult);
378
+ },
379
+ { deep: true },
380
+ );
381
+
382
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
383
+ function convertProvidedDefault(field: FormField, provided: any): any {
384
+ if (field.type === "radio" || field.type === "dropdown") {
385
+ const choiceIndex = field.choices.indexOf(provided as string);
386
+ return choiceIndex !== -1 ? choiceIndex : null;
387
+ }
388
+ if (field.type === "checkbox") {
389
+ if (!Array.isArray(provided)) return [];
390
+ return provided.map((val: string) => field.choices.indexOf(val)).filter((idx: number) => idx !== -1);
391
+ }
392
+ return provided;
393
+ }
394
+
395
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
396
+ function emptyValueForField(field: FormField): any {
397
+ if (field.type === "text" || field.type === "textarea" || field.type === "date" || field.type === "time") return "";
398
+ if (field.type === "number") return (field as NumberField).min !== undefined ? (field as NumberField).min : 0;
399
+ if (field.type === "checkbox") return [];
400
+ return null;
401
+ }
402
+
403
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
404
+ function getDefaultValue(field: FormField): any {
405
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
406
+ const provided = (field as FormField & { defaultValue?: any }).defaultValue;
407
+ if (provided !== undefined) return convertProvidedDefault(field, provided);
408
+ return emptyValueForField(field);
409
+ }
410
+
411
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
412
+ function isEmpty(value: any): boolean {
413
+ if (value === null || value === undefined) return true;
414
+ if (typeof value === "string") return value.trim() === "";
415
+ if (Array.isArray(value)) return value.length === 0;
416
+ return false;
417
+ }
418
+
419
+ // 254 = RFC 5321 maximum address length. indexOf-based check avoids
420
+ // the chained `[^\s@]+` regex pattern that sonarjs flags as
421
+ // vulnerable to super-linear backtracking.
422
+ const EMAIL_MAX_LENGTH = 254;
423
+ function isValidEmail(email: string): boolean {
424
+ if (!email || email.length > EMAIL_MAX_LENGTH) return false;
425
+ const atIndex = email.indexOf("@");
426
+ if (atIndex <= 0 || atIndex !== email.lastIndexOf("@")) return false;
427
+ const local = email.slice(0, atIndex);
428
+ const domain = email.slice(atIndex + 1);
429
+ if (/\s/.test(local) || /\s/.test(domain)) return false;
430
+ const dot = domain.lastIndexOf(".");
431
+ return dot > 0 && dot < domain.length - 1;
432
+ }
433
+
434
+ function isValidUrl(url: string): boolean {
435
+ try {
436
+ new URL(url);
437
+ return true;
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+
443
+ function isValidPhone(phone: string): boolean {
444
+ const phoneRegex = /^[\d\s\-+()]+$/;
445
+ return phoneRegex.test(phone) && phone.replace(/\D/g, "").length >= 10;
446
+ }
447
+
448
+ function validateText(field: TextField, value: string): string | null {
449
+ if (field.validation === "email" && !isValidEmail(value)) return "Please enter a valid email address";
450
+ if (field.validation === "url" && !isValidUrl(value)) return "Please enter a valid URL";
451
+ if (field.validation === "phone" && !isValidPhone(value)) return "Please enter a valid phone number";
452
+ if (typeof field.validation === "string" && !["email", "url", "phone"].includes(field.validation)) {
453
+ try {
454
+ if (!new RegExp(field.validation).test(value)) return `${field.label} format is invalid`;
455
+ } catch {
456
+ console.warn(`Invalid regex pattern: ${field.validation}`);
457
+ }
458
+ }
459
+ return null;
460
+ }
461
+
462
+ function validateTextarea(field: TextareaField, value: string): string | null {
463
+ if (field.minLength && value.length < field.minLength) {
464
+ return `Must be at least ${field.minLength} characters (currently ${value.length})`;
465
+ }
466
+ if (field.maxLength && value.length > field.maxLength) {
467
+ return `Must be no more than ${field.maxLength} characters (currently ${value.length})`;
468
+ }
469
+ return null;
470
+ }
471
+
472
+ function validateNumber(field: NumberField, value: number): string | null {
473
+ if (field.min !== undefined && value < field.min) return `Must be at least ${field.min}`;
474
+ if (field.max !== undefined && value > field.max) return `Must be no more than ${field.max}`;
475
+ return null;
476
+ }
477
+
478
+ function validateDate(field: DateField, value: string): string | null {
479
+ if (field.minDate && value < field.minDate) return `Date must be on or after ${field.minDate}`;
480
+ if (field.maxDate && value > field.maxDate) return `Date must be on or before ${field.maxDate}`;
481
+ return null;
482
+ }
483
+
484
+ function validateCheckbox(field: CheckboxField, value: number[]): string | null {
485
+ const selectedCount = value?.length || 0;
486
+ if (field.minSelections && selectedCount < field.minSelections) {
487
+ return `Please select at least ${field.minSelections} option${field.minSelections > 1 ? "s" : ""}`;
488
+ }
489
+ if (field.maxSelections && selectedCount > field.maxSelections) {
490
+ return `Please select no more than ${field.maxSelections} option${field.maxSelections > 1 ? "s" : ""}`;
491
+ }
492
+ return null;
493
+ }
494
+
495
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
496
+ function getErrorMessage(field: FormField, value: any): string | null {
497
+ if (field.required && isEmpty(value)) return `${field.label} is required`;
498
+ if (isEmpty(value)) return null;
499
+ if (field.type === "text") return validateText(field as TextField, value);
500
+ if (field.type === "textarea") return validateTextarea(field as TextareaField, value);
501
+ if (field.type === "number") return validateNumber(field as NumberField, value);
502
+ if (field.type === "date") return validateDate(field as DateField, value);
503
+ if (field.type === "checkbox") return validateCheckbox(field as CheckboxField, value);
504
+ return null;
505
+ }
506
+
507
+ function validateField(fieldId: string): boolean {
508
+ const field = formData.value?.fields.find((candidate) => candidate.id === fieldId);
509
+ if (!field) return true;
510
+
511
+ const value = formValues.value[fieldId];
512
+ const errorMessage = getErrorMessage(field, value);
513
+
514
+ if (errorMessage) {
515
+ fieldErrors.value.set(fieldId, {
516
+ fieldId,
517
+ message: errorMessage,
518
+ type: "custom",
519
+ });
520
+ return false;
521
+ } else {
522
+ fieldErrors.value.delete(fieldId);
523
+ return true;
524
+ }
525
+ }
526
+
527
+ function handleBlur(fieldId: string): void {
528
+ touched.value.add(fieldId);
529
+ validateField(fieldId);
530
+ }
531
+
532
+ function handleInput(fieldId: string): void {
533
+ // Real-time validation for fields that are already touched
534
+ if (touched.value.has(fieldId)) {
535
+ validateField(fieldId);
536
+ }
537
+ }
538
+
539
+ function hasError(fieldId: string): boolean {
540
+ return fieldErrors.value.has(fieldId);
541
+ }
542
+
543
+ function focusField(fieldId: string): void {
544
+ const element = document.getElementById(`input-${fieldId}`);
545
+ if (element) {
546
+ element.focus();
547
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
548
+ }
549
+ }
550
+
551
+ function showCharCount(field: FormField): boolean {
552
+ return (field.type === "text" || field.type === "textarea") && (field as TextField | TextareaField).maxLength !== undefined;
553
+ }
554
+
555
+ function isNearLimit(field: FormField): boolean {
556
+ if (field.type !== "text" && field.type !== "textarea") return false;
557
+ const maxLength = (field as TextField | TextareaField).maxLength;
558
+ if (!maxLength) return false;
559
+ const currentLength = (formValues.value[field.id] || "").length;
560
+ return currentLength / maxLength > 0.9;
561
+ }
562
+
563
+ const requiredFieldsCount = computed(() => {
564
+ return formData.value?.fields.filter((field) => field.required).length || 0;
565
+ });
566
+
567
+ const filledRequiredCount = computed(() => {
568
+ if (!formData.value) return 0;
569
+ return formData.value.fields.filter((field) => field.required && !isEmpty(formValues.value[field.id])).length;
570
+ });
571
+
572
+ function handleSubmit(): void {
573
+ if (submitted.value) return;
574
+
575
+ // Mark all fields as touched
576
+ formData.value?.fields.forEach((field) => {
577
+ touched.value.add(field.id);
578
+ validateField(field.id);
579
+ });
580
+
581
+ // Check for errors
582
+ if (fieldErrors.value.size > 0) {
583
+ showErrorSummary.value = true;
584
+ // Focus first error field
585
+ const firstErrorFieldId = Array.from(fieldErrors.value.keys())[0];
586
+ focusField(firstErrorFieldId);
587
+ return;
588
+ }
589
+
590
+ // Build a markdown bullet list of `- {label}: {value}` lines so the
591
+ // chat history reads naturally for a human while still being trivial
592
+ // for the LLM to parse. Empty values become "(none)". Titles, labels,
593
+ // and choice strings are passed through singleLine() to strip any
594
+ // newlines they might contain — without that, a label like
595
+ // "Address (street\nand number)" would shatter the bullet structure.
596
+ const lines: string[] = [];
597
+ if (formData.value?.title) lines.push(`**${singleLine(formData.value.title)}**`, "");
598
+ formData.value?.fields.forEach((field) => {
599
+ lines.push(`- ${singleLine(field.label)}: ${renderValue(field, formValues.value[field.id])}`);
600
+ });
601
+
602
+ submitted.value = true;
603
+ props.sendTextMessage(lines.join("\n"));
604
+ }
605
+
606
+ // Indent every line except the first by 2 spaces — under markdown
607
+ // rules, indented continuations stay attached to the preceding bullet
608
+ // instead of starting a new top-level item. Without this, a textarea
609
+ // value containing newlines would inject phantom bullets into the
610
+ // chat-side payload that the LLM could mis-parse.
611
+ function indentContinuation(text: string): string {
612
+ return text.replace(/\n/g, "\n ");
613
+ }
614
+
615
+ // Collapse any newline (and surrounding whitespace) into a single
616
+ // space. Used on titles, labels, and choice strings (LLM-authored
617
+ // fields) so they can't smuggle a newline into the markdown bullet
618
+ // structure and reshape the payload. Free-form user input
619
+ // (text/textarea values) uses indentContinuation instead — it
620
+ // preserves multi-line values. Implemented via split-trim-join (not a
621
+ // global regex) to avoid sonarjs's slow-regex flag on `\s*\n\s*`.
622
+ function singleLine(text: string): string {
623
+ return text
624
+ .split(/\r?\n/)
625
+ .map((segment) => segment.trim())
626
+ .filter((segment) => segment.length > 0)
627
+ .join(" ");
628
+ }
629
+
630
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
631
+ function renderValue(field: FormField, value: any): string {
632
+ const empty = "(none)";
633
+ if (field.type === "radio" || field.type === "dropdown") {
634
+ return value !== null && value !== undefined ? singleLine(field.choices[value]) : empty;
635
+ }
636
+ // Checkbox values render as a nested bullet sublist rather than a
637
+ // comma-joined string. Comma-joining is ambiguous when a choice label
638
+ // itself contains a comma; the sublist is unambiguous and survives
639
+ // round-trip parsing.
640
+ if (field.type === "checkbox") {
641
+ const items: string[] = (value || []).map((idx: number) => singleLine(field.choices[idx]));
642
+ if (items.length === 0) return empty;
643
+ return "\n - " + items.join("\n - ");
644
+ }
645
+ if (typeof value === "string") {
646
+ const trimmed = value.trim();
647
+ return trimmed === "" ? empty : indentContinuation(trimmed);
648
+ }
649
+ if (value === null || value === undefined) return empty;
650
+ return String(value);
651
+ }
652
+ </script>
653
+
654
+ <style scoped>
655
+ .form-field {
656
+ transition: all 0.2s ease;
657
+ }
658
+
659
+ .form-field.has-error {
660
+ animation: shake 0.3s ease;
661
+ }
662
+
663
+ @keyframes shake {
664
+ 0%,
665
+ 100% {
666
+ transform: translateX(0);
667
+ }
668
+ 25% {
669
+ transform: translateX(-5px);
670
+ }
671
+ 75% {
672
+ transform: translateX(5px);
673
+ }
674
+ }
675
+ </style>