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
@@ -7,20 +7,20 @@
7
7
  aria-labelledby="todo-add-dialog-title"
8
8
  @click.stop
9
9
  >
10
- <h3 id="todo-add-dialog-title" class="text-base font-semibold text-gray-800">Add Todo</h3>
10
+ <h3 id="todo-add-dialog-title" class="text-base font-semibold text-gray-800">{{ t("todoDialogs.addTitle") }}</h3>
11
11
  <label class="block text-xs text-gray-600">
12
- Text
12
+ {{ t("todoDialogs.fieldText") }}
13
13
  <input
14
14
  ref="textInput"
15
15
  v-model="text"
16
16
  type="text"
17
- placeholder="What needs doing?"
17
+ :placeholder="t('todoDialogs.textPlaceholder')"
18
18
  class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400"
19
19
  @keydown.enter="submit"
20
20
  />
21
21
  </label>
22
22
  <label class="block text-xs text-gray-600">
23
- Note
23
+ {{ t("todoDialogs.fieldNote") }}
24
24
  <textarea
25
25
  v-model="note"
26
26
  rows="2"
@@ -29,7 +29,7 @@
29
29
  </label>
30
30
  <div class="grid grid-cols-2 gap-3">
31
31
  <label class="block text-xs text-gray-600">
32
- Status
32
+ {{ t("todoDialogs.fieldStatus") }}
33
33
  <select v-model="status" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400">
34
34
  <option v-for="col in columns" :key="col.id" :value="col.id">
35
35
  {{ col.label }}
@@ -37,16 +37,16 @@
37
37
  </select>
38
38
  </label>
39
39
  <label class="block text-xs text-gray-600">
40
- Priority
40
+ {{ t("todoDialogs.fieldPriority") }}
41
41
  <select v-model="priority" class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400">
42
- <option value="">— None —</option>
42
+ <option value="">{{ t("todoDialogs.noneOption") }}</option>
43
43
  <option v-for="p in PRIORITIES" :key="p" :value="p">
44
44
  {{ PRIORITY_LABELS[p] }}
45
45
  </option>
46
46
  </select>
47
47
  </label>
48
48
  <label class="block text-xs text-gray-600">
49
- Due date
49
+ {{ t("todoDialogs.fieldDueDate") }}
50
50
  <input
51
51
  v-model="dueDate"
52
52
  type="date"
@@ -54,18 +54,20 @@
54
54
  />
55
55
  </label>
56
56
  <label class="block text-xs text-gray-600">
57
- Labels
57
+ {{ t("todoDialogs.fieldLabels") }}
58
58
  <input
59
59
  v-model="labelsText"
60
60
  type="text"
61
- placeholder="work, urgent"
61
+ :placeholder="t('todoDialogs.labelsPlaceholder')"
62
62
  class="mt-1 w-full px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400"
63
63
  />
64
64
  </label>
65
65
  </div>
66
66
  <div class="flex justify-end gap-2 pt-1">
67
- <button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="emit('cancel')">Cancel</button>
68
- <button class="px-3 py-1.5 text-sm rounded bg-blue-500 text-white hover:bg-blue-600" @click="submit">Add</button>
67
+ <button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="emit('cancel')">
68
+ {{ t("common.cancel") }}
69
+ </button>
70
+ <button class="px-3 py-1.5 text-sm rounded bg-blue-500 text-white hover:bg-blue-600" @click="submit">{{ t("common.add") }}</button>
69
71
  </div>
70
72
  </div>
71
73
  </div>
@@ -73,10 +75,13 @@
73
75
 
74
76
  <script setup lang="ts">
75
77
  import { onMounted, onUnmounted, ref } from "vue";
78
+ import { useI18n } from "vue-i18n";
76
79
  import type { StatusColumn } from "../../plugins/todo/index";
77
80
  import { PRIORITIES, PRIORITY_LABELS } from "../../plugins/todo/priority";
78
81
  import type { CreateItemInput } from "../../plugins/todo/composables/useTodos";
79
82
 
83
+ const { t } = useI18n();
84
+
80
85
  const props = defineProps<{
81
86
  columns: StatusColumn[];
82
87
  defaultStatus?: string;
@@ -8,8 +8,10 @@
8
8
  @click.stop
9
9
  >
10
10
  <div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
11
- <h3 id="todo-edit-dialog-title" class="text-base font-semibold text-gray-800">Edit Todo</h3>
12
- <button class="text-gray-400 hover:text-red-500 text-xs px-2 py-0.5" title="Delete this item" @click="emit('delete', item.id)">Delete</button>
11
+ <h3 id="todo-edit-dialog-title" class="text-base font-semibold text-gray-800">{{ t("todoDialogs.editTitle") }}</h3>
12
+ <button class="text-gray-400 hover:text-red-500 text-xs px-2 py-0.5" :title="t('todoDialogs.deleteTitle')" @click="emit('delete', item.id)">
13
+ {{ t("todoDialogs.deleteButton") }}
14
+ </button>
13
15
  </div>
14
16
  <TodoEditPanel :item="item" :columns="columns" @save="(input) => emit('save', input)" @cancel="emit('cancel')" />
15
17
  </div>
@@ -18,10 +20,13 @@
18
20
 
19
21
  <script setup lang="ts">
20
22
  import { onMounted, onUnmounted } from "vue";
23
+ import { useI18n } from "vue-i18n";
21
24
  import type { StatusColumn, TodoItem } from "../../plugins/todo/index";
22
25
  import type { PatchItemInput } from "../../plugins/todo/composables/useTodos";
23
26
  import TodoEditPanel from "./TodoEditPanel.vue";
24
27
 
28
+ const { t } = useI18n();
29
+
25
30
  defineProps<{
26
31
  item: TodoItem;
27
32
  columns: StatusColumn[];
@@ -2,7 +2,7 @@
2
2
  <div class="border-t border-blue-100 bg-blue-50 p-4 space-y-3 rounded-b-lg">
3
3
  <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
4
4
  <label class="block text-xs text-gray-600 sm:col-span-2">
5
- Text
5
+ {{ t("todoDialogs.fieldText") }}
6
6
  <input
7
7
  v-model="text"
8
8
  type="text"
@@ -10,7 +10,7 @@
10
10
  />
11
11
  </label>
12
12
  <label class="block text-xs text-gray-600 sm:col-span-2">
13
- Note
13
+ {{ t("todoDialogs.fieldNote") }}
14
14
  <textarea
15
15
  v-model="note"
16
16
  rows="2"
@@ -18,7 +18,7 @@
18
18
  />
19
19
  </label>
20
20
  <label class="block text-xs text-gray-600">
21
- Status
21
+ {{ t("todoDialogs.fieldStatus") }}
22
22
  <select v-model="status" class="mt-1 w-full px-2 py-1.5 text-sm bg-white border border-blue-300 rounded focus:outline-none focus:border-blue-500">
23
23
  <option v-for="col in columns" :key="col.id" :value="col.id">
24
24
  {{ col.label }}
@@ -26,16 +26,16 @@
26
26
  </select>
27
27
  </label>
28
28
  <label class="block text-xs text-gray-600">
29
- Priority
29
+ {{ t("todoDialogs.fieldPriority") }}
30
30
  <select v-model="priority" class="mt-1 w-full px-2 py-1.5 text-sm bg-white border border-blue-300 rounded focus:outline-none focus:border-blue-500">
31
- <option value="">— None —</option>
31
+ <option value="">{{ t("todoDialogs.noneOption") }}</option>
32
32
  <option v-for="p in PRIORITIES" :key="p" :value="p">
33
33
  {{ PRIORITY_LABELS[p] }}
34
34
  </option>
35
35
  </select>
36
36
  </label>
37
37
  <label class="block text-xs text-gray-600">
38
- Due date
38
+ {{ t("todoDialogs.fieldDueDate") }}
39
39
  <input
40
40
  v-model="dueDate"
41
41
  type="date"
@@ -43,28 +43,33 @@
43
43
  />
44
44
  </label>
45
45
  <label class="block text-xs text-gray-600">
46
- Labels (comma-separated)
46
+ {{ t("todoDialogs.fieldLabelsCommaSeparated") }}
47
47
  <input
48
48
  v-model="labelsText"
49
49
  type="text"
50
- placeholder="work, urgent"
50
+ :placeholder="t('todoDialogs.labelsPlaceholder')"
51
51
  class="mt-1 w-full px-2 py-1.5 text-sm bg-white border border-blue-300 rounded focus:outline-none focus:border-blue-500"
52
52
  />
53
53
  </label>
54
54
  </div>
55
55
  <div class="flex items-center gap-2 pt-1">
56
- <button class="px-3 py-1.5 text-sm rounded bg-blue-500 text-white hover:bg-blue-600" @click="save">Save</button>
57
- <button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="emit('cancel')">Cancel</button>
56
+ <button class="px-3 py-1.5 text-sm rounded bg-blue-500 text-white hover:bg-blue-600" @click="save">{{ t("common.save") }}</button>
57
+ <button class="px-3 py-1.5 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50" @click="emit('cancel')">
58
+ {{ t("common.cancel") }}
59
+ </button>
58
60
  </div>
59
61
  </div>
60
62
  </template>
61
63
 
62
64
  <script setup lang="ts">
63
65
  import { ref } from "vue";
66
+ import { useI18n } from "vue-i18n";
64
67
  import type { StatusColumn, TodoItem, TodoPriority } from "../../plugins/todo/index";
65
68
  import { PRIORITIES, PRIORITY_LABELS } from "../../plugins/todo/priority";
66
69
  import type { PatchItemInput } from "../../plugins/todo/composables/useTodos";
67
70
 
71
+ const { t } = useI18n();
72
+
68
73
  const props = defineProps<{
69
74
  item: TodoItem;
70
75
  columns: StatusColumn[];
@@ -32,7 +32,7 @@
32
32
  <span class="text-xs text-gray-500 shrink-0">{{ itemsByColumn(col.id).length }}</span>
33
33
  </div>
34
34
  <div class="relative">
35
- <button class="text-gray-400 hover:text-gray-600 px-1" title="Column actions" @click="toggleMenu(col.id)">
35
+ <button class="text-gray-400 hover:text-gray-600 px-1" :title="t('todoKanban.columnActions')" @click="toggleMenu(col.id)">
36
36
  <span class="material-icons text-base">more_horiz</span>
37
37
  </button>
38
38
  <div
@@ -40,11 +40,13 @@
40
40
  class="absolute right-0 top-6 z-20 bg-white border border-gray-200 rounded shadow-md text-xs w-40 py-1"
41
41
  @click.stop
42
42
  >
43
- <button class="w-full text-left px-3 py-1.5 hover:bg-gray-50" @click="startRename(col)">Rename</button>
43
+ <button class="w-full text-left px-3 py-1.5 hover:bg-gray-50" @click="startRename(col)">{{ t("todoKanban.rename") }}</button>
44
44
  <button class="w-full text-left px-3 py-1.5 hover:bg-gray-50" @click="markAsDone(col.id)">
45
- {{ col.isDone ? "Already done column" : "Mark as done column" }}
45
+ {{ col.isDone ? t("todoKanban.alreadyDoneColumn") : t("todoKanban.markAsDoneColumn") }}
46
+ </button>
47
+ <button class="w-full text-left px-3 py-1.5 text-red-600 hover:bg-red-50" @click="deleteColumn(col.id)">
48
+ {{ t("todoKanban.deleteColumn") }}
46
49
  </button>
47
- <button class="w-full text-left px-3 py-1.5 text-red-600 hover:bg-red-50" @click="deleteColumn(col.id)">Delete column</button>
48
50
  </div>
49
51
  </div>
50
52
  </div>
@@ -61,9 +63,14 @@
61
63
  <template #item="{ element }: { element: TodoItem }">
62
64
  <div
63
65
  :data-testid="`todo-card-${element.id}`"
64
- class="bg-white border border-l-4 border-gray-200 rounded shadow-sm p-2 cursor-grab hover:shadow active:cursor-grabbing"
66
+ tabindex="0"
67
+ role="button"
68
+ :aria-label="t('todoKanban.openCardAria', { task: element.text })"
69
+ class="bg-white border border-l-4 border-gray-200 rounded shadow-sm p-2 cursor-grab hover:shadow active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
65
70
  :class="element.priority ? PRIORITY_BORDER[element.priority] : 'border-l-gray-200'"
66
71
  @click="emit('open', element)"
72
+ @keydown.enter.prevent.self="(e) => !e.repeat && emit('open', element)"
73
+ @keydown.space.prevent.self="(e) => !e.repeat && emit('open', element)"
67
74
  >
68
75
  <div class="flex items-start gap-2">
69
76
  <input
@@ -103,7 +110,7 @@
103
110
 
104
111
  <!-- Add card stub -->
105
112
  <button class="m-2 text-xs text-gray-500 hover:text-gray-800 hover:bg-gray-200 rounded py-1.5 transition-colors" @click="emit('quickAdd', col.id)">
106
- + Add card
113
+ {{ t("todoKanban.addCard") }}
107
114
  </button>
108
115
  </div>
109
116
  </template>
@@ -113,11 +120,14 @@
113
120
 
114
121
  <script setup lang="ts">
115
122
  import { computed, nextTick, ref, watch } from "vue";
123
+ import { useI18n } from "vue-i18n";
116
124
  import draggable from "vuedraggable";
117
125
  import type { StatusColumn, TodoItem } from "../../plugins/todo/index";
118
126
  import { colorForLabel } from "../../plugins/todo/labels";
119
127
  import { PRIORITY_BORDER, PRIORITY_CLASSES, PRIORITY_LABELS, dueDateClasses, formatDueLabel } from "../../plugins/todo/priority";
120
128
 
129
+ const { t } = useI18n();
130
+
121
131
  // vuedraggable @change event shape. The library emits one of these
122
132
  // three keys depending on whether the move was within the same list,
123
133
  // added from another list, or removed from the current list. We only
@@ -1,9 +1,17 @@
1
1
  <template>
2
2
  <div class="h-full overflow-y-auto p-4">
3
- <div v-if="filteredItems.length === 0" class="h-full flex items-center justify-center text-gray-400 text-sm">No items match the current filter</div>
3
+ <div v-if="filteredItems.length === 0" class="h-full flex items-center justify-center text-gray-400 text-sm">{{ t("todoTableList.noMatchingFilter") }}</div>
4
4
  <ul v-else class="space-y-2 max-w-3xl mx-auto">
5
5
  <li v-for="item in filteredItems" :key="item.id" class="rounded-lg border border-gray-200 hover:border-gray-300 transition-colors">
6
- <div class="flex items-center gap-3 p-3 cursor-pointer group" @click="toggleExpand(item.id)">
6
+ <div
7
+ tabindex="0"
8
+ role="button"
9
+ :aria-label="t('todoTableList.expandRowAria', { task: item.text })"
10
+ class="flex items-center gap-3 p-3 cursor-pointer group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 rounded"
11
+ @click="toggleExpand(item.id)"
12
+ @keydown.enter.prevent.self="(e) => !e.repeat && toggleExpand(item.id)"
13
+ @keydown.space.prevent.self="(e) => !e.repeat && toggleExpand(item.id)"
14
+ >
7
15
  <input type="checkbox" :checked="item.completed" class="cursor-pointer shrink-0" @click.stop @change="toggleComplete(item)" />
8
16
  <div class="flex-1 min-w-0">
9
17
  <div class="flex items-center gap-2 flex-wrap">
@@ -25,7 +33,7 @@
25
33
  </div>
26
34
  <button
27
35
  class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 text-xs px-1 shrink-0"
28
- title="Delete item"
36
+ :title="t('pluginTodo.deleteItem')"
29
37
  @click.stop="emit('delete', item.id)"
30
38
  >
31
39
 
@@ -39,12 +47,15 @@
39
47
 
40
48
  <script setup lang="ts">
41
49
  import { ref } from "vue";
50
+ import { useI18n } from "vue-i18n";
42
51
  import type { StatusColumn, TodoItem } from "../../plugins/todo/index";
43
52
  import { colorForLabel } from "../../plugins/todo/labels";
44
53
  import { PRIORITY_CLASSES, PRIORITY_LABELS, dueDateClasses, formatDueLabel } from "../../plugins/todo/priority";
45
54
  import type { PatchItemInput } from "../../plugins/todo/composables/useTodos";
46
55
  import TodoEditPanel from "./TodoEditPanel.vue";
47
56
 
57
+ const { t } = useI18n();
58
+
48
59
  const props = defineProps<{
49
60
  filteredItems: TodoItem[];
50
61
  columns: StatusColumn[];
@@ -1,10 +1,24 @@
1
1
  <template>
2
2
  <div class="h-full overflow-auto">
3
- <div v-if="filteredItems.length === 0" class="h-full flex items-center justify-center text-gray-400 text-sm">No items match the current filter</div>
3
+ <div v-if="filteredItems.length === 0" class="h-full flex items-center justify-center text-gray-400 text-sm">{{ t("todoTableList.noMatchingFilter") }}</div>
4
4
  <table v-else class="min-w-full text-sm">
5
5
  <thead class="bg-gray-50 sticky top-0 z-10">
6
6
  <tr class="text-left text-xs font-medium text-gray-500 uppercase">
7
- <th v-for="col in COLUMNS" :key="col.key" class="px-3 py-2 cursor-pointer hover:bg-gray-100 select-none" @click="setSort(col.key)">
7
+ <!-- Keep the native <th> column-header role so aria-sort and
8
+ table navigation work for screen readers. role="button"
9
+ would overwrite the header role and defeat aria-sort.
10
+ Keyboard activation goes via tabindex + key handlers. -->
11
+ <th
12
+ v-for="col in COLUMNS"
13
+ :key="col.key"
14
+ tabindex="0"
15
+ :aria-sort="sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
16
+ :aria-label="t('todoTableList.sortColumnAria', { column: col.ariaLabel ?? col.label })"
17
+ class="px-3 py-2 cursor-pointer hover:bg-gray-100 select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
18
+ @click="setSort(col.key)"
19
+ @keydown.enter.prevent.self="(e) => !e.repeat && setSort(col.key)"
20
+ @keydown.space.prevent.self="(e) => !e.repeat && setSort(col.key)"
21
+ >
8
22
  {{ col.label }}
9
23
  <span v-if="sortKey === col.key" class="material-icons text-xs align-middle">{{ sortDir === "asc" ? "arrow_upward" : "arrow_downward" }}</span>
10
24
  </th>
@@ -17,7 +31,18 @@
17
31
  <td class="px-3 py-2">
18
32
  <input type="checkbox" :checked="item.completed" @change="emit('toggleComplete', item)" />
19
33
  </td>
20
- <td class="px-3 py-2 max-w-md cursor-pointer" @click="toggleExpand(item.id)">
34
+ <!-- Same rationale as the <th> above: keep the native <td>
35
+ cell role so screen-reader table navigation still works;
36
+ activation goes via tabindex + key handlers, described
37
+ via aria-label. -->
38
+ <td
39
+ tabindex="0"
40
+ :aria-label="t('todoTableList.expandRowAria', { task: item.text })"
41
+ class="px-3 py-2 max-w-md cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
42
+ @click="toggleExpand(item.id)"
43
+ @keydown.enter.prevent.self="(e) => !e.repeat && toggleExpand(item.id)"
44
+ @keydown.space.prevent.self="(e) => !e.repeat && toggleExpand(item.id)"
45
+ >
21
46
  <div :class="item.completed ? 'line-through text-gray-400' : 'text-gray-800'">
22
47
  {{ item.text }}
23
48
  </div>
@@ -53,7 +78,7 @@
53
78
  {{ formatShortDate(item.createdAt) }}
54
79
  </td>
55
80
  <td class="px-3 py-2 text-right">
56
- <button class="text-gray-300 hover:text-red-500 text-xs" title="Delete item" @click="emit('delete', item.id)">✕</button>
81
+ <button class="text-gray-300 hover:text-red-500 text-xs" :title="t('pluginTodo.deleteItem')" @click="emit('delete', item.id)">✕</button>
57
82
  </td>
58
83
  </tr>
59
84
  <tr v-if="expandedId === item.id">
@@ -69,6 +94,7 @@
69
94
 
70
95
  <script setup lang="ts">
71
96
  import { computed, ref } from "vue";
97
+ import { useI18n } from "vue-i18n";
72
98
  import type { StatusColumn, TodoItem } from "../../plugins/todo/index";
73
99
  import { colorForLabel } from "../../plugins/todo/labels";
74
100
  import { PRIORITY_CLASSES, PRIORITY_LABELS, PRIORITY_ORDER, dueDateClasses, formatDueLabel } from "../../plugins/todo/priority";
@@ -76,16 +102,21 @@ import type { PatchItemInput } from "../../plugins/todo/composables/useTodos";
76
102
  import TodoEditPanel from "./TodoEditPanel.vue";
77
103
  import { formatShortDate } from "../../utils/format/date";
78
104
 
105
+ const { t } = useI18n();
106
+
79
107
  type SortKey = "completed" | "text" | "status" | "priority" | "labels" | "dueDate" | "createdAt";
80
108
  type SortDir = "asc" | "desc";
81
109
 
82
110
  interface ColumnDef {
83
111
  key: SortKey;
84
112
  label: string;
113
+ // Fallback name for screen readers when `label` is empty or is a
114
+ // glyph / checkbox indicator that wouldn't survive being spoken.
115
+ ariaLabel?: string;
85
116
  }
86
117
 
87
118
  const COLUMNS: ColumnDef[] = [
88
- { key: "completed", label: "" },
119
+ { key: "completed", label: "", ariaLabel: "Completion" },
89
120
  { key: "text", label: "Text" },
90
121
  { key: "status", label: "Status" },
91
122
  { key: "priority", label: "Priority" },
@@ -0,0 +1,76 @@
1
+ // Pure predicates for the favicon resolver. Every function takes
2
+ // only its inputs — no global `Date`, no env, no network — so a test
3
+ // can pin an arbitrary moment and assert the branch deterministically.
4
+
5
+ // Night runs from 22:00 (inclusive) to 05:00 (exclusive). Chosen so a
6
+ // typical "I'm winding down" window (22–24) and a "still up" window
7
+ // (00–05) share the same deep-work indigo.
8
+ export function isLateNight(now: Date): boolean {
9
+ const hour = now.getHours();
10
+ return hour >= 22 || hour < 5;
11
+ }
12
+
13
+ // Morning covers 05:00–09:00 — the "before the inbox wins" window.
14
+ // Disjoint from `isLateNight`, so the two rules never both fire.
15
+ export function isMorning(now: Date): boolean {
16
+ const hour = now.getHours();
17
+ return hour >= 5 && hour < 9;
18
+ }
19
+
20
+ // Weekend fires only during waking hours so the late-night rule wins
21
+ // overnight on Fri→Sat (that's still "deep work", not "weekend vibes").
22
+ // Saturday = 6, Sunday = 0 per `Date.getDay()`.
23
+ export function isWeekend(now: Date): boolean {
24
+ const day = now.getDay();
25
+ const hour = now.getHours();
26
+ const onWeekend = day === 0 || day === 6;
27
+ const duringDay = hour >= 9 && hour < 22;
28
+ return onWeekend && duringDay;
29
+ }
30
+
31
+ // Birthday match: context stores "MM-DD"; we compare to today in the
32
+ // caller's local clock. Returns false for any malformed input rather
33
+ // than throwing — the favicon must never crash on a typo.
34
+ export function isBirthday(now: Date, userBirthdayMMDD: string | null): boolean {
35
+ if (!userBirthdayMMDD) return false;
36
+ if (!/^\d{2}-\d{2}$/.test(userBirthdayMMDD)) return false;
37
+ const month = String(now.getMonth() + 1).padStart(2, "0");
38
+ const day = String(now.getDate()).padStart(2, "0");
39
+ return `${month}-${day}` === userBirthdayMMDD;
40
+ }
41
+
42
+ // New Year window — Jan 1–3 inclusive. Short enough that it stays
43
+ // special; 3 days covers the common "holiday still going" case
44
+ // (especially in Japan).
45
+ export function isNewYear(now: Date): boolean {
46
+ return now.getMonth() === 0 && now.getDate() >= 1 && now.getDate() <= 3;
47
+ }
48
+
49
+ // Christmas window — Dec 24–25. Eve + day.
50
+ export function isChristmas(now: Date): boolean {
51
+ return now.getMonth() === 11 && (now.getDate() === 24 || now.getDate() === 25);
52
+ }
53
+
54
+ // Overloaded: server 1-minute load average divided by logical core
55
+ // count. A ratio > 0.9 means "nearly all cores saturated for a full
56
+ // minute". Null input (no data yet, or Windows) → the rule is off.
57
+ export function isOverloaded(cpuLoadRatio: number | null): boolean {
58
+ if (cpuLoadRatio === null || !Number.isFinite(cpuLoadRatio)) return false;
59
+ return cpuLoadRatio > 0.9;
60
+ }
61
+
62
+ // Many-unread threshold — tuned so a typical day with 1–3 sleepy
63
+ // tabs stays green, but a noisy ping storm bumps up to fuchsia.
64
+ export const MANY_UNREAD_THRESHOLD = 5;
65
+ export function isManyUnread(count: number): boolean {
66
+ return Number.isFinite(count) && count >= MANY_UNREAD_THRESHOLD;
67
+ }
68
+
69
+ // Running-long threshold — anything past ~1 minute is a "real" run
70
+ // (code gen, long web searches) as opposed to a quick reply.
71
+ export const RUNNING_LONG_MS = 60 * 1000;
72
+ export function isRunningLong(runningSinceMs: number | null, now: Date): boolean {
73
+ if (runningSinceMs === null) return false;
74
+ const elapsed = now.getTime() - runningSinceMs;
75
+ return Number.isFinite(elapsed) && elapsed >= RUNNING_LONG_MS;
76
+ }
@@ -0,0 +1,93 @@
1
+ // Priority-ordered rule chain that picks the favicon's backing
2
+ // colour from the full runtime context. See
3
+ // `plans/done/feat-favicon-conditional-palette.md` for the reasoning
4
+ // behind the ordering and the chosen hex values.
5
+ //
6
+ // The function is pure: one input → one output. No clock, no DOM,
7
+ // no network. Every test pins the branch it wants by constructing
8
+ // the context verbatim.
9
+
10
+ import { FAVICON_REASONS, FAVICON_STATES, type FaviconContext, type FaviconPick } from "./types";
11
+ import { isBirthday, isChristmas, isLateNight, isManyUnread, isMorning, isNewYear, isOverloaded, isRunningLong, isWeekend } from "./conditions";
12
+
13
+ // Keeping the palette values adjacent to the resolver (rather than
14
+ // in a separate constants module) makes the "why does this colour
15
+ // fire?" audit a one-file read.
16
+ // "running" and "has-unread" no longer have background colors — the
17
+ // yellow dot (top-left) and red dot (top-right) communicate those
18
+ // states. The background is reserved for ambient context (error,
19
+ // load, calendar, time-of-day) plus the two *escalations* of the
20
+ // dot states (runningLong, manyUnread) that carry severity.
21
+ const COLORS = {
22
+ error: "#DC2626", // red-600
23
+ overloaded: "#EA580C", // orange-600 — machine is burning
24
+ manyUnread: "#D946EF", // fuchsia-500 — attention pile-up
25
+ runningLong: "#06B6D4", // cyan-500 — still thinking (> 60 s)
26
+ birthday: "#EAB308", // yellow-500
27
+ newYear: "#B91C1C", // red-700 — festive deeper red
28
+ christmas: "#15803D", // green-700 — festive deeper green
29
+ lateNight: "#6366F1", // indigo-500 — deep work
30
+ morning: "#F59E0B", // amber-500 — sunrise
31
+ weekend: "#14B8A6", // teal-500 — relaxed
32
+ idle: "#6B7280", // gray-500 — fallback
33
+ } as const;
34
+
35
+ // Split the state-driven vs flavour branches so the priority
36
+ // argument is in one place and each helper stays under the
37
+ // cognitive-complexity threshold.
38
+
39
+ // Rules 1–4: error + load + escalations. These always fire when
40
+ // applicable, beating any flavour. The plain `running` and
41
+ // `hasUnread` rules are gone — the yellow / red corner dots own
42
+ // those signals now, so the background stays free for ambient
43
+ // context even while a session is running.
44
+ function resolveByState(ctx: FaviconContext): FaviconPick | null {
45
+ if (ctx.state === FAVICON_STATES.error) {
46
+ return { color: COLORS.error, reason: FAVICON_REASONS.error };
47
+ }
48
+ if (isOverloaded(ctx.cpuLoadRatio)) {
49
+ return { color: COLORS.overloaded, reason: FAVICON_REASONS.overloaded };
50
+ }
51
+ if (isManyUnread(ctx.sessionsUnreadCount)) {
52
+ return { color: COLORS.manyUnread, reason: FAVICON_REASONS.manyUnread };
53
+ }
54
+ if (ctx.state === FAVICON_STATES.running && isRunningLong(ctx.runningSinceMs, ctx.now)) {
55
+ return { color: COLORS.runningLong, reason: FAVICON_REASONS.runningLong };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // Rules 7–12: flavour / easter eggs. Only consulted when no
61
+ // state-driven rule matched, so a running agent never gets a
62
+ // "cute" colour. Calendar beats clock; hour-based rules are
63
+ // disjoint so we pick whichever matches first.
64
+ function resolveByFlavour(ctx: FaviconContext): FaviconPick {
65
+ if (isBirthday(ctx.now, ctx.userBirthdayMMDD)) {
66
+ return { color: COLORS.birthday, reason: FAVICON_REASONS.birthday };
67
+ }
68
+ if (isNewYear(ctx.now)) {
69
+ return { color: COLORS.newYear, reason: FAVICON_REASONS.newYear };
70
+ }
71
+ if (isChristmas(ctx.now)) {
72
+ return { color: COLORS.christmas, reason: FAVICON_REASONS.christmas };
73
+ }
74
+ if (isLateNight(ctx.now)) {
75
+ return { color: COLORS.lateNight, reason: FAVICON_REASONS.lateNight };
76
+ }
77
+ if (isMorning(ctx.now)) {
78
+ return { color: COLORS.morning, reason: FAVICON_REASONS.morning };
79
+ }
80
+ if (isWeekend(ctx.now)) {
81
+ return { color: COLORS.weekend, reason: FAVICON_REASONS.weekend };
82
+ }
83
+ return { color: COLORS.idle, reason: FAVICON_REASONS.idle };
84
+ }
85
+
86
+ export function resolveFaviconColor(ctx: FaviconContext): FaviconPick {
87
+ return resolveByState(ctx) ?? resolveByFlavour(ctx);
88
+ }
89
+
90
+ // Expose the palette for test assertions so the hex values aren't
91
+ // hard-coded in two places. Not exported from types.ts because it's
92
+ // resolver-implementation state, not part of the public contract.
93
+ export const FAVICON_COLORS = COLORS;
@@ -0,0 +1,61 @@
1
+ // Shared types for the favicon palette resolver. Keeping them in a
2
+ // dedicated file lets `conditions.ts` and `resolveColor.ts` import
3
+ // them without cycling, and the unit tests pin the exact shape the
4
+ // context must satisfy.
5
+
6
+ export const FAVICON_STATES = {
7
+ idle: "idle",
8
+ running: "running",
9
+ done: "done",
10
+ error: "error",
11
+ } as const;
12
+
13
+ export type FaviconState = (typeof FAVICON_STATES)[keyof typeof FAVICON_STATES];
14
+
15
+ // Every possible path through `resolveColor` — the same enum used for
16
+ // the returned `reason` field so callers (and the log breadcrumb) can
17
+ // name the branch without memorising hex codes.
18
+ export const FAVICON_REASONS = {
19
+ error: "error",
20
+ overloaded: "overloaded",
21
+ manyUnread: "many-unread",
22
+ runningLong: "running-long",
23
+ birthday: "birthday",
24
+ newYear: "new-year",
25
+ christmas: "christmas",
26
+ lateNight: "late-night",
27
+ morning: "morning",
28
+ weekend: "weekend",
29
+ idle: "idle",
30
+ } as const;
31
+
32
+ export type FaviconReason = (typeof FAVICON_REASONS)[keyof typeof FAVICON_REASONS];
33
+
34
+ // The full runtime context fed to `resolveColor`. Everything is
35
+ // plumbed in so the function stays pure: no `new Date()` inside, no
36
+ // global fetches — test fixtures can pin any branch.
37
+ export interface FaviconContext {
38
+ state: FaviconState;
39
+ /** Unread across every session, not just the active one. */
40
+ sessionsUnreadCount: number;
41
+ /** Epoch ms when the current agent run started, or null if idle. */
42
+ runningSinceMs: number | null;
43
+ /** "now" — caller's clock. Tests pass a fixed Date. */
44
+ now: Date;
45
+ /**
46
+ * "MM-DD" if the user has a birthday stored in memory.md, else
47
+ * null. Null → the birthday rule is skipped.
48
+ */
49
+ userBirthdayMMDD: string | null;
50
+ /**
51
+ * Server's 1-minute load average divided by logical core count.
52
+ * `null` when the server hasn't reported yet or the platform
53
+ * doesn't support `loadavg` (Windows).
54
+ */
55
+ cpuLoadRatio: number | null;
56
+ }
57
+
58
+ export interface FaviconPick {
59
+ color: string;
60
+ reason: FaviconReason;
61
+ }