@kabmo-labs/kabmo 0.8.0 → 0.9.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -21,6 +21,7 @@ import {
21
21
  EDITOR_MIN_FONT_SIZE,
22
22
  } from '@/shared/lib/code/editor/typography';
23
23
  import { useEditorFontStack } from '@/client/hooks/editor/font';
24
+ import { useEditorTheme } from '@/client/hooks/editor/theme';
24
25
  import { MOBILE_SCROLL_POSITIONS_KEY } from '@/shared/lib/workspace/scroll-positions';
25
26
  import { ViewerSurface, hasViewerSurface } from '@/client/components/workspace/editor/viewers/dispatch';
26
27
  import { BINARY_VIEWERS, RENDERED_VIEWERS, VIEWER_LABELS, viewerKindFor } from '@/shared/lib/fs/document-kind';
@@ -49,6 +50,7 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
49
50
  const [fontSize, setFontSize] = useState(EDITOR_DEFAULT_FONT_SIZE);
50
51
  const [wordWrap, setWordWrap] = useState(true);
51
52
  const editorFontFamily = useEditorFontStack();
53
+ const editorTheme = useEditorTheme();
52
54
 
53
55
  const isMd = viewerKind === 'markdown';
54
56
  // A byte viewer (image, PDF, DOCX, workbook) owns the whole surface and has
@@ -76,6 +78,12 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
76
78
  missing,
77
79
  });
78
80
 
81
+ /**
82
+ * The code surface's scroller, which belongs to CodeMirror and appears only
83
+ * once its view mounts.
84
+ */
85
+ const [codeScroller, setCodeScroller] = useState<HTMLElement | null>(null);
86
+
79
87
  // Closing and reopening the same file is a remount, so the offset is restored
80
88
  // from the session's own state — the phone shows one file at a time, and its
81
89
  // key is that file's path (a rendered view keeps its own offset, as on the
@@ -88,6 +96,9 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
88
96
  // and the desktop drops entries when a tab closes — against ids that mean
89
97
  // nothing here.
90
98
  slot: MOBILE_SCROLL_POSITIONS_KEY,
99
+ // The code surface's scroller belongs to CodeMirror and appears only once
100
+ // its view mounts; the markdown preview owns its own container.
101
+ element: isMd && isPreview ? undefined : codeScroller,
91
102
  });
92
103
 
93
104
  // A search row on the phone opens this full-screen editor, so the hand-off
@@ -135,9 +146,15 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
135
146
  // The sheet's own list, from the LIVE buffer — the same derivation the
136
147
  // desktop section uses, so the two cannot disagree about what a state means.
137
148
  // A byte viewer has no text buffer to scan, which is its own sentence.
138
- const { symbols: outlineSymbols, notice: outlineNotice } = useOutlineView({
149
+ const {
150
+ symbols: outlineSymbols,
151
+ total: outlineTotal,
152
+ truncated: outlineTruncated,
153
+ notice: outlineNotice,
154
+ } = useOutlineView({
139
155
  content,
140
156
  language: lang,
157
+ path: file.path ?? null,
141
158
  state: byteViewer ? 'not-a-document' : editor.loadError ? 'error' : editor.loaded ? 'ready' : 'loading',
142
159
  note: byteViewer
143
160
  ? `${VIEWER_LABELS[viewerKind]} files have no text outline.`
@@ -148,6 +165,7 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
148
165
  <div
149
166
  className="fixed inset-x-0 top-0 h-dvh z-[60] bg-paper flex flex-col font-mono text-xs select-none"
150
167
  {...drop.dropProps}
168
+ {...drop.captureDropProps}
151
169
  >
152
170
  <EditorDropOverlay visible={drop.isDragging} />
153
171
 
@@ -208,7 +226,9 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
208
226
  content={editor.content}
209
227
  revision={editor.revision}
210
228
  />
211
- ) : (
229
+ ) : editor.isLoading || editor.unsupported || editor.loadError || (isMd && isPreview) ? (
230
+ // The states that draw their own scrolling content — and the markdown
231
+ // preview, which is a document, not an editor — share one scroller.
212
232
  <div
213
233
  ref={scroll.ref}
214
234
  onScroll={scroll.onScroll}
@@ -233,7 +253,7 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
233
253
  <span className="text-xs text-error font-sans">Could not read {file.name}</span>
234
254
  <span className="text-[11px] text-ink/50 font-mono break-all">{editor.loadError}</span>
235
255
  </div>
236
- ) : isMd && isPreview ? (
256
+ ) : (
237
257
  /* Markdown Preview Mode — styled by `.prose-content` (the chat
238
258
  timeline's system), see the desktop editor for why there is no
239
259
  `prose` wrapper. */
@@ -244,42 +264,41 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
244
264
  scope={{ path: file.path ?? file.name, root: file.root, repo: file.repo }}
245
265
  />
246
266
  </div>
247
- ) : (
248
- /* Code Editor with Line Numbers - Desktop styled: gutter bg-canvas border-r border-ink/10 */
249
- <CodeSurface
250
- ref={surfaceRef}
251
- value={content}
252
- onValueChange={editor.onChange}
253
- language={lang}
254
- wordWrap={wordWrap}
255
- marks={revealMarks}
256
- currentMark={currentMark}
257
- rootClassName="flex min-h-full bg-paper"
258
- // The gutter auto-sizes to its widest number rather than taking a
259
- // fixed width: the numbers scale with the zoom control, and a
260
- // pinned `w-10` clipped a 3-digit line number at 18px.
261
- gutterClassName="flex flex-col text-right pr-2 select-none text-ink/30 bg-canvas border-r border-ink/10 font-mono flex-shrink-0"
262
- gutterLineClassName="min-w-[1.5rem]"
263
- gutterStyle={{
264
- fontSize,
265
- paddingTop: 12,
266
- paddingBottom: 12,
267
- lineHeight: EDITOR_LINE_HEIGHT,
268
- fontFamily: editorFontFamily,
269
- }}
270
- editorWrapperClassName="flex-1 p-3 overflow-x-auto min-w-0 bg-paper text-ink code-surface"
271
- editorClassName="focus:outline-none"
272
- editorStyle={{
273
- fontFamily: editorFontFamily,
274
- fontSize,
275
- lineHeight: EDITOR_LINE_HEIGHT,
276
- backgroundColor: 'transparent',
277
- minHeight: '100%',
278
- color: 'var(--theme-ink)',
279
- }}
280
- />
281
267
  )}
282
268
  </div>
269
+ ) : (
270
+ /* The code surface owns its own scroller (CodeMirror's), so it is NOT
271
+ wrapped in one: an outer `overflow-auto` resolves the editor's
272
+ `height: 100%` against an auto-height box, so the editor grew to the
273
+ document's full height and the panel had nothing to scroll. */
274
+ <CodeSurface
275
+ ref={surfaceRef}
276
+ value={content}
277
+ onValueChange={editor.onChange}
278
+ language={lang}
279
+ wordWrap={wordWrap}
280
+ documentKey={fileKey}
281
+ marks={revealMarks}
282
+ currentMark={currentMark}
283
+ onScroller={setCodeScroller}
284
+ scrollTestId="editor-scroller"
285
+ // No `bg-paper`: an explicit editor theme paints its own surface, and
286
+ // the panel's colour under it would be a border of the wrong shade.
287
+ // `select-text` is the desktop pane's rule, and it matters more here:
288
+ // the sheet's own root is `select-none` (it is a header-and-status
289
+ // layout), and the editor is now a `contenteditable` whose selection
290
+ // and caret are the BROWSER's business — an inherited `user-select:
291
+ // none` is the one thing that can take that away on a phone.
292
+ className="flex-1 min-h-0 text-ink select-text"
293
+ themeVars={editorTheme.vars}
294
+ themes={editorTheme.themes}
295
+ padding={12}
296
+ style={{
297
+ fontFamily: editorFontFamily,
298
+ fontSize: `${fontSize}px`,
299
+ lineHeight: EDITOR_LINE_HEIGHT,
300
+ }}
301
+ />
283
302
  )}
284
303
 
285
304
  {/* The outline is a floating control rather than a panel section: on a
@@ -289,7 +308,13 @@ export function MobileFullEditor({ file, missing = false, onClose, onFileSaved,
289
308
  target over the content, list in a sheet. Hidden for a byte viewer,
290
309
  which has no text to outline. */}
291
310
  {!byteViewer && (
292
- <MobileOutlineButton path={file.path ?? null} symbols={outlineSymbols} emptyNotice={outlineNotice} />
311
+ <MobileOutlineButton
312
+ path={file.path ?? null}
313
+ symbols={outlineSymbols}
314
+ total={outlineTotal}
315
+ truncated={outlineTruncated}
316
+ emptyNotice={outlineNotice}
317
+ />
293
318
  )}
294
319
 
295
320
  <EditorStatusBar
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's outline sheet.
8
+ *
9
+ * Same two facts the desktop section shows, on a surface that cannot share its
10
+ * markup (the drawer has no room for a section pinned to a floor, so this is a
11
+ * floating button and a modal). Both are pinned here because each fails
12
+ * silently:
13
+ *
14
+ * - **the button names the TOTAL**, so a truncated list cannot read as "this
15
+ * file has 500 symbols";
16
+ * - **a truncated list says what it is not showing**, in the sheet, where the
17
+ * reader is actually looking.
18
+ *
19
+ * Rendered with `h()` against happy-dom. `Modal` is the shared shell every
20
+ * dialog in the app uses, so opening one here is the same code path a phone
21
+ * takes.
22
+ */
23
+
24
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
25
+ import { Window } from 'happy-dom';
26
+ import { h, render } from 'preact';
27
+ import { act } from 'preact/test-utils';
28
+ import { MobileOutlineButton } from '@/client/components/mobile/mobile-right-sidebar/OutlineButton';
29
+ import { MAX_OUTLINE_ROWS, outlineTruncatedNotice } from '@/shared/lib/code/outline/rows';
30
+ import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
31
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
32
+
33
+ let container: HTMLElement;
34
+
35
+ beforeAll(() => {
36
+ installDomGlobals(new Window({ url: 'http://localhost' }));
37
+ });
38
+
39
+ afterEach(() => {
40
+ if (container) {
41
+ render(null, container);
42
+ container.remove();
43
+ }
44
+ });
45
+
46
+ afterAll(() => {
47
+ restoreDomGlobals();
48
+ });
49
+
50
+ function symbols(count: number): OutlineSymbol[] {
51
+ return Array.from({ length: count }, (_, i) => ({
52
+ name: `fn${i}`,
53
+ kind: 'function' as const,
54
+ line: i + 1,
55
+ column: 0,
56
+ depth: 0,
57
+ }));
58
+ }
59
+
60
+ async function mount(props: {
61
+ symbols: OutlineSymbol[];
62
+ total?: number;
63
+ truncated?: boolean;
64
+ path?: string | null;
65
+ emptyNotice?: string;
66
+ }): Promise<void> {
67
+ container = document.createElement('div');
68
+ document.body.appendChild(container);
69
+ await act(async () => {
70
+ render(
71
+ h(MobileOutlineButton, {
72
+ path: props.path === undefined ? 'src/a.ts' : props.path,
73
+ symbols: props.symbols,
74
+ total: props.total ?? props.symbols.length,
75
+ truncated: props.truncated ?? false,
76
+ emptyNotice: props.emptyNotice ?? 'No symbols found.',
77
+ }),
78
+ container,
79
+ );
80
+ });
81
+ }
82
+
83
+ function trigger(): HTMLButtonElement {
84
+ const el = container.querySelector('button[aria-label^="Outline"]');
85
+ if (!el) throw new Error('no outline trigger rendered');
86
+ return el as HTMLButtonElement;
87
+ }
88
+
89
+ async function open(): Promise<void> {
90
+ await act(async () => {
91
+ trigger().click();
92
+ });
93
+ for (let i = 0; i < 4; i += 1) await act(async () => {});
94
+ }
95
+
96
+ describe('MobileOutlineButton', () => {
97
+ test('no path means no control — an attachment chip has nothing to outline', async () => {
98
+ await mount({ symbols: symbols(3), path: null });
99
+
100
+ expect(container.querySelector('button[aria-label^="Outline"]')).toBeNull();
101
+ });
102
+
103
+ test('the trigger names the TOTAL, not the window', async () => {
104
+ await mount({ symbols: symbols(MAX_OUTLINE_ROWS), total: 63_618, truncated: true });
105
+
106
+ // The accessible name is what a screen reader announces and what a spec
107
+ // addresses; a window count here would describe the panel, not the file.
108
+ expect(trigger().getAttribute('aria-label')).toBe('Outline (63618 symbols)');
109
+ });
110
+
111
+ test('a truncated sheet renders the window and says what it is not showing', async () => {
112
+ await mount({ symbols: symbols(MAX_OUTLINE_ROWS), total: 700, truncated: true });
113
+ await open();
114
+
115
+ const rows = container.querySelectorAll('button[aria-label*=", line "]');
116
+ expect(rows).toHaveLength(MAX_OUTLINE_ROWS);
117
+ expect(container.textContent).toContain(outlineTruncatedNotice(700));
118
+ // The header count reads window/total, so it cannot be mistaken for the
119
+ // number of symbols the file has.
120
+ expect(container.textContent).toContain(`${MAX_OUTLINE_ROWS}/700`);
121
+ });
122
+
123
+ test('a complete list shows no truncation sentence', async () => {
124
+ await mount({ symbols: symbols(4) });
125
+ await open();
126
+
127
+ expect(container.querySelectorAll('button[aria-label*=", line "]')).toHaveLength(4);
128
+ expect(container.textContent).not.toContain('Showing the first');
129
+ });
130
+
131
+ test('an empty list prints the reason it is empty, not a blank sheet', async () => {
132
+ await mount({ symbols: [], emptyNotice: 'This tab has no outline.' });
133
+ await open();
134
+
135
+ expect(container.textContent).toContain('This tab has no outline.');
136
+ });
137
+ });
@@ -27,15 +27,21 @@ import { Modal } from '@/client/components/common/Modal';
27
27
  import { OutlineKindIcon, outlineKindLabel } from '@/client/components/common/outline-kind-icon';
28
28
  import { setLineReveal } from '@/client/lib/line-reveal';
29
29
  import type { OutlineSymbol } from '@/shared/lib/code/outline/types';
30
+ import { outlineTruncatedNotice } from '@/shared/lib/code/outline/rows';
30
31
 
31
32
  interface MobileOutlineButtonProps {
32
33
  path: string | null;
34
+ /** The rows to render — already bounded by the hook. */
33
35
  symbols: OutlineSymbol[];
36
+ /** Every symbol found, so the button can say when it is showing a window. */
37
+ total: number;
38
+ /** True when `symbols` is only the first `MAX_OUTLINE_ROWS`. */
39
+ truncated: boolean;
34
40
  /** Why there is nothing to list, when `symbols` is empty. */
35
41
  emptyNotice: string;
36
42
  }
37
43
 
38
- export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlineButtonProps) {
44
+ export function MobileOutlineButton({ path, symbols, total, truncated, emptyNotice }: MobileOutlineButtonProps) {
39
45
  const [open, setOpen] = useState(false);
40
46
  if (!path) return null;
41
47
 
@@ -52,7 +58,7 @@ export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlin
52
58
  <button
53
59
  type="button"
54
60
  onClick={() => setOpen(true)}
55
- aria-label={`Outline (${symbols.length} symbols)`}
61
+ aria-label={`Outline (${total} symbols)`}
56
62
  title="Outline"
57
63
  className="flex h-9 w-9 items-center justify-center rounded-full border border-ink/20 bg-paper text-ink/60 shadow-sm transition-all hover:bg-ink/5 hover:text-ink active:scale-95"
58
64
  >
@@ -69,7 +75,9 @@ export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlin
69
75
  <div className="flex min-w-0 items-center gap-2">
70
76
  <ListTree size={15} className="flex-shrink-0 text-ink/50" />
71
77
  <span className="text-sm font-medium">Outline</span>
72
- <span className="font-mono text-[11px] text-ink/40">{symbols.length}</span>
78
+ <span className="font-mono text-[11px] text-ink/40">
79
+ {truncated ? `${symbols.length}/${total}` : total}
80
+ </span>
73
81
  </div>
74
82
  }
75
83
  >
@@ -92,6 +100,9 @@ export function MobileOutlineButton({ path, symbols, emptyNotice }: MobileOutlin
92
100
  </button>
93
101
  ))
94
102
  )}
103
+ {truncated && (
104
+ <div className="px-4 py-2 text-[11px] italic text-ink/40">{outlineTruncatedNotice(total)}</div>
105
+ )}
95
106
  </div>
96
107
  </Modal>
97
108
  )}
@@ -1,10 +1,7 @@
1
- import { Download, Info, Monitor, Settings } from 'lucide-preact';
1
+ import { Info, Monitor, Settings } from 'lucide-preact';
2
2
  import packageJson from '@/../package.json';
3
- import {
4
- shellDownloadLabel,
5
- shellDownloadShortLabel,
6
- type ShellDownload,
7
- } from '@/client/hooks/ui/updates/shell-download';
3
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
4
+ import { ShellUpdateButton } from '@/client/components/common/shell-update-button';
8
5
 
9
6
  interface MobileSessionFooterProps {
10
7
  onSettings: () => void;
@@ -49,27 +46,18 @@ export function MobileSessionFooter({
49
46
  <span>About</span>
50
47
  </button>
51
48
  {shellDownload && (
52
- // Directly after About, matching the desktop footer. An `<a>`, not a
53
- // button: the download is the browser's (or the WebView's) job, and
54
- // `target="_blank"` hands it to the system browser so the console is
55
- // not replaced by a file download.
49
+ // Directly after About, matching the desktop footer.
56
50
  //
57
51
  // The visible word is the OFFER, not the file type: `APK` told a
58
52
  // reader what the bytes are but not what the control does, and on a
59
53
  // browser it is the only place the native shell is ever mentioned. An
60
54
  // iOS update is an IPA, so the word names the platform instead of a
61
55
  // file extension.
62
- <a
63
- href={shellDownload.url}
64
- target="_blank"
65
- rel="noopener noreferrer"
66
- title={shellDownloadLabel(shellDownload)}
67
- aria-label={shellDownloadLabel(shellDownload)}
68
- className="flex items-center space-x-1 hover:text-ink transition-colors"
69
- >
70
- <Download size={14} />
71
- <span>{shellDownloadShortLabel(shellDownload)}</span>
72
- </a>
56
+ //
57
+ // The shared control decides link-versus-button: a desktop shell that
58
+ // knows its architecture installs the artifact itself, and a link
59
+ // there would leave the file in Downloads.
60
+ <ShellUpdateButton download={shellDownload} variant="label" />
73
61
  )}
74
62
  {onDesktopToggle && (
75
63
  <button
@@ -9,7 +9,9 @@ import { useRestartKabmo } from '@/client/hooks/settings/restart-kabmo';
9
9
  import { applyDocumentTheme } from '@/client/hooks/ui/theme';
10
10
  import { DEFAULT_THEME_ID } from '@/shared/lib/theme/catalog';
11
11
  import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
12
+ import { EDITOR_DEFAULT_THEME_ID } from '@/shared/lib/code/editor/themes';
12
13
  import { EDITOR_FONT_CHANGED_EVENT } from '@/client/hooks/editor/font';
14
+ import { EDITOR_THEME_CHANGED_EVENT } from '@/client/hooks/editor/theme';
13
15
  import { useKabmoEvent } from '@/client/hooks/ui/window-event';
14
16
  import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
15
17
  import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
@@ -41,6 +43,7 @@ export interface SettingsModalProps {
41
43
  const DEFAULT_SETTINGS: SettingsState = {
42
44
  theme: DEFAULT_THEME_ID,
43
45
  editorFont: EDITOR_DEFAULT_FONT_FAMILY,
46
+ editorTheme: EDITOR_DEFAULT_THEME_ID,
44
47
  autoSessionTitle: true,
45
48
  soundAlerts: true,
46
49
  chatCompletionSound: true,
@@ -120,6 +123,11 @@ export function SettingsModal({
120
123
  if (typeof window !== 'undefined' && next.editorFont !== prev.editorFont) {
121
124
  window.dispatchEvent(new CustomEvent(EDITOR_FONT_CHANGED_EVENT, { detail: next.editorFont }));
122
125
  }
126
+ // Same rule for the editor's colour theme: it is visual, and every open
127
+ // editor subscribes rather than reading the boot-time `appSettings`.
128
+ if (typeof window !== 'undefined' && next.editorTheme !== prev.editorTheme) {
129
+ window.dispatchEvent(new CustomEvent(EDITOR_THEME_CHANGED_EVENT, { detail: next.editorTheme }));
130
+ }
123
131
  // Only the keys this update actually touched are persisted. The modal
124
132
  // holds a full `SettingsState` snapshot taken when it opened, so writing
125
133
  // the whole object back would also republish every value it read at that
@@ -3,6 +3,7 @@ import type { SettingsState } from '@/shared/types';
3
3
  import { resolveThemeId } from '@/shared/lib/theme/catalog';
4
4
  import { ThemeGrid } from '@/client/components/settings/categories/appearance-settings/ThemeGrid';
5
5
  import { EditorFontSection } from '@/client/components/settings/categories/appearance-settings/EditorFontSection';
6
+ import { EditorThemeSection } from '@/client/components/settings/categories/appearance-settings/EditorThemeSection';
6
7
 
7
8
  interface AppearanceSettingsProps {
8
9
  settings: SettingsState;
@@ -25,6 +26,8 @@ export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsPro
25
26
  </section>
26
27
 
27
28
  <EditorFontSection settings={settings} onUpdate={onUpdate} />
29
+
30
+ <EditorThemeSection settings={settings} onUpdate={onUpdate} />
28
31
  </div>
29
32
  );
30
33
  }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor theme picker.
8
+ *
9
+ * Three properties are invisible to a markup check and each is a bug if dropped:
10
+ *
11
+ * - **`Auto` is a card and is selected by default**, so an install that never
12
+ * touches this section renders exactly what it did before the feature.
13
+ * - **The swatch is drawn from the row's own colours**, not from the app
14
+ * palette: a card that previewed every theme in the app's ink would make 65
15
+ * themes look identical, which is the whole point of the grid.
16
+ * - **A click writes the theme id through `onUpdate`**, which is what the modal
17
+ * turns into the event an already-open editor subscribes to.
18
+ *
19
+ * Rendered with `h()` against happy-dom; the modal's write path is pinned in
20
+ * `settings/Modal`'s own suite.
21
+ */
22
+
23
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
24
+ import { Window } from 'happy-dom';
25
+ import { h, render } from 'preact';
26
+ import { act } from 'preact/test-utils';
27
+
28
+ import { EditorThemeSection } from '@/client/components/settings/categories/appearance-settings/EditorThemeSection';
29
+ import { EDITOR_AUTO_THEME_ID, EDITOR_THEMES, resolveEditorTheme } from '@/shared/lib/code/editor/themes';
30
+ import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
31
+ import type { SettingsState } from '@/shared/types';
32
+
33
+ const DOM_GLOBALS = [
34
+ 'window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent',
35
+ 'getComputedStyle',
36
+ ] as const;
37
+
38
+ const native: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
39
+
40
+ beforeAll(() => {
41
+ const win = new Window({ url: 'http://localhost' });
42
+ const target = globalThis as unknown as Record<string, unknown>;
43
+ for (const key of DOM_GLOBALS) {
44
+ if (!(key in native)) native[key] = target[key];
45
+ const value = (win as unknown as Record<string, unknown>)[key];
46
+ if (value !== undefined) target[key] = value;
47
+ }
48
+ });
49
+
50
+ afterAll(() => {
51
+ const target = globalThis as unknown as Record<string, unknown>;
52
+ for (const key of DOM_GLOBALS) {
53
+ if (native[key] === undefined) delete target[key];
54
+ else target[key] = native[key];
55
+ }
56
+ });
57
+
58
+ let container: HTMLElement | null = null;
59
+ type Update = Partial<SettingsState> | ((prev: SettingsState) => SettingsState);
60
+ let lastUpdate: Update | null = null;
61
+
62
+ /** Reset through a function: assigning `null` inline would narrow the variable
63
+ * at every later read, and the assertions below exist to see the VALUE. */
64
+ function resetUpdate(): void {
65
+ lastUpdate = null;
66
+ }
67
+
68
+ /** What the last click asked to persist, as a value the assertions can read. */
69
+ function lastPatch(): unknown {
70
+ return lastUpdate;
71
+ }
72
+
73
+ function settings(editorTheme: string): SettingsState {
74
+ return { editorTheme, editorFont: EDITOR_DEFAULT_FONT_FAMILY } as SettingsState;
75
+ }
76
+
77
+ async function mount(editorTheme: string): Promise<HTMLElement> {
78
+ if (container) render(null, container);
79
+ container = document.body.appendChild(document.createElement('div'));
80
+ await act(async () => {
81
+ render(
82
+ h(EditorThemeSection, {
83
+ settings: settings(editorTheme),
84
+ onUpdate: (update: Update) => {
85
+ lastUpdate = update;
86
+ },
87
+ }),
88
+ container!,
89
+ );
90
+ });
91
+ return container;
92
+ }
93
+
94
+ /** The card whose label starts with `label` (the label is the first text). */
95
+ function card(root: HTMLElement, label: string): HTMLButtonElement | undefined {
96
+ return Array.from(root.querySelectorAll('button')).find((button) =>
97
+ (button.textContent ?? '').trim().startsWith(label),
98
+ );
99
+ }
100
+
101
+ describe('editor theme picker', () => {
102
+ test('Auto is a card, and it is the selected one by default', async () => {
103
+ const root = await mount(EDITOR_AUTO_THEME_ID);
104
+ const auto = card(root, 'Auto');
105
+ expect(auto).toBeDefined();
106
+ expect(auto!.getAttribute('aria-pressed')).toBe('true');
107
+ // Every shipped theme is offered beside it, so the grid is not a two-item
108
+ // list with the catalogue hidden behind a filter.
109
+ expect(root.querySelectorAll('button[aria-pressed]').length).toBe(EDITOR_THEMES.length + 1);
110
+ });
111
+
112
+ test('a stored theme is the selected card, and Auto is not', async () => {
113
+ const root = await mount('dracula');
114
+ expect(card(root, 'Dracula Theme')!.getAttribute('aria-pressed')).toBe('true');
115
+ expect(card(root, 'Auto')!.getAttribute('aria-pressed')).toBe('false');
116
+ });
117
+
118
+ test('the swatch is drawn in the theme’s OWN colours', async () => {
119
+ const root = await mount('dracula');
120
+ const theme = resolveEditorTheme('dracula');
121
+ expect(theme).not.toBeNull();
122
+ const swatch = Array.from(card(root, 'Dracula Theme')!.querySelectorAll('span')).find(
123
+ (node) => (node as HTMLElement).style.backgroundColor !== '',
124
+ ) as HTMLElement | undefined;
125
+ expect(swatch).toBeDefined();
126
+ // The card paints the theme's own surface, not the app's — which is what
127
+ // makes 65 themes distinguishable in one grid.
128
+ expect(swatch!.style.backgroundColor.toLowerCase()).toBe(theme!.bg.toLowerCase());
129
+ });
130
+
131
+ test('a click writes the theme id', async () => {
132
+ resetUpdate();
133
+ const root = await mount(EDITOR_AUTO_THEME_ID);
134
+ await act(async () => {
135
+ card(root, 'Nord')!.click();
136
+ });
137
+ expect(lastPatch()).toEqual({ editorTheme: 'nord' });
138
+ });
139
+
140
+ test('the Auto card writes the sentinel, not an empty string', async () => {
141
+ resetUpdate();
142
+ const root = await mount('nord');
143
+ await act(async () => {
144
+ card(root, 'Auto')!.click();
145
+ });
146
+ // An empty value would resolve to `auto` too, but it would also be a value
147
+ // no reader can distinguish from a missing setting.
148
+ expect(lastPatch()).toEqual({ editorTheme: EDITOR_AUTO_THEME_ID });
149
+ });
150
+
151
+ test('the light/dark filters narrow the grid without dropping Auto', async () => {
152
+ const root = await mount(EDITOR_AUTO_THEME_ID);
153
+ const darkFilter = Array.from(root.querySelectorAll('button')).find(
154
+ (button) => (button.textContent ?? '').trim() === 'Dark',
155
+ );
156
+ await act(async () => {
157
+ darkFilter!.click();
158
+ });
159
+ const labels = Array.from(root.querySelectorAll('button[aria-pressed]')).map((button) =>
160
+ (button.textContent ?? '').trim().split(/\d/)[0],
161
+ );
162
+ expect(labels.length).toBe(EDITOR_THEMES.filter((theme) => theme.variant === 'dark').length + 1);
163
+ expect(labels.some((label) => label.startsWith('GitHub Light'))).toBe(false);
164
+ // Auto is a choice, not a light/dark theme, so a variant filter keeps it.
165
+ expect(labels.some((label) => label.startsWith('Auto'))).toBe(true);
166
+ });
167
+ });