magus-cli 0.0.0-placeholder → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +445 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +174 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +335 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { TABS, barWidth, layoutTabs } from "../ui/components/TabBar.js";
3
+
4
+ const FULL = TABS.map((t) => `${t.key}:${t.label}`);
5
+ const FULL_WIDTH = barWidth(FULL);
6
+
7
+ /** The widest the compacted bar ever gets — whichever active label is longest. */
8
+ const WIDEST_COMPACT = Math.max(
9
+ ...TABS.map((tab) => barWidth(layoutTabs(TABS, tab.screen, 0))),
10
+ );
11
+
12
+ describe("tab bar layout", () => {
13
+ test("shows every label when the bar fits", () => {
14
+ expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual(FULL);
15
+ });
16
+
17
+ test("drops inactive labels when the bar does not fit", () => {
18
+ // A 94-column pane leaves ~90 usable, and ten full tabs need ~110 — the
19
+ // width at which OpenTUI clipped each cell into a meaningless stub.
20
+ const texts = layoutTabs(TABS, "styles", 90);
21
+ expect(texts).toContain("9:Styles");
22
+ expect(texts).toContain("1");
23
+ expect(texts).not.toContain("1:Plugins");
24
+ });
25
+
26
+ test("the compact bar actually fits a narrow pane", () => {
27
+ // The point of compacting is that the result fits. If it did not, the
28
+ // fallback would just be a differently-truncated mess.
29
+ expect(barWidth(layoutTabs(TABS, "styles", 90))).toBeLessThanOrEqual(90);
30
+ });
31
+
32
+ test("the full bar no longer fits a standard window, so compacting is the norm", () => {
33
+ // The tenth tab pushed the full bar past 100 columns. That is not a
34
+ // regression — it is why the compact form has to stay readable, and why
35
+ // the test below measures it rather than assuming.
36
+ expect(FULL_WIDTH).toBeGreaterThan(100);
37
+ expect(layoutTabs(TABS, "models", 100)).not.toEqual(FULL);
38
+ });
39
+
40
+ test("the compact bar fits an 80-column terminal, on every tab", () => {
41
+ // The real question a tenth tab raises. ScreenLayout pads the bar by 1
42
+ // each side inside a container padded by 1, so an 80-column terminal
43
+ // leaves 76 — and the widest compact bar is well under it.
44
+ expect(WIDEST_COMPACT).toBeLessThanOrEqual(76);
45
+ for (const tab of TABS) {
46
+ expect(barWidth(layoutTabs(TABS, tab.screen, 76))).toBeLessThanOrEqual(
47
+ 76,
48
+ );
49
+ }
50
+ });
51
+
52
+ test("keeps the label of whichever tab is active", () => {
53
+ for (const tab of TABS) {
54
+ const texts = layoutTabs(TABS, tab.screen, 40);
55
+ expect(texts).toContain(`${tab.key}:${tab.label}`);
56
+ // Exactly one tab keeps its label.
57
+ expect(texts.filter((t) => t.includes(":"))).toHaveLength(1);
58
+ }
59
+ });
60
+
61
+ test("compacting never changes how many tabs are drawn", () => {
62
+ expect(layoutTabs(TABS, "plugins", 20)).toHaveLength(TABS.length);
63
+ expect(layoutTabs(TABS, "plugins", 10_000)).toHaveLength(TABS.length);
64
+ });
65
+
66
+ test("barWidth counts padding and separators", () => {
67
+ // " a " + "│" + " bb " = 3 + 1 + 4
68
+ expect(barWidth(["a", "bb"])).toBe(8);
69
+ expect(barWidth([])).toBe(0);
70
+ });
71
+
72
+ test("every tab key is a digit the global handler binds", () => {
73
+ // The compact bar shows only the number, so the number must be the key
74
+ // that actually navigates. Models is keyed "0" and sits last, because the
75
+ // number row reads 1…9 then 0 — the order here and the order under the
76
+ // fingers have to agree.
77
+ expect(TABS.map((t) => t.key)).toEqual([
78
+ "1",
79
+ "2",
80
+ "3",
81
+ "4",
82
+ "5",
83
+ "6",
84
+ "7",
85
+ "8",
86
+ "9",
87
+ "0",
88
+ ]);
89
+ expect(TABS.at(-1)).toEqual({
90
+ key: "0",
91
+ label: "Models",
92
+ screen: "models",
93
+ });
94
+ });
95
+
96
+ test("no two tabs share a key or a screen", () => {
97
+ expect(new Set(TABS.map((t) => t.key)).size).toBe(TABS.length);
98
+ expect(new Set(TABS.map((t) => t.screen)).size).toBe(TABS.length);
99
+ });
100
+ });
@@ -0,0 +1,214 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import { splitFrontmatter } from "../services/styles-manager.js";
7
+ import {
8
+ buildPrompt,
9
+ fillTemplate,
10
+ fillTerminology,
11
+ parseTerminologyRows,
12
+ terminologyStyleFile,
13
+ } from "../services/terminology-filler.js";
14
+
15
+ /** The shape the shipped template actually has. */
16
+ const TEMPLATE = [
17
+ "### Terminology",
18
+ "",
19
+ "One concept, one name, everywhere.",
20
+ "",
21
+ "| Use | Not | Because |",
22
+ "|---|---|---|",
23
+ "| <!-- filled during apply --> | | |",
24
+ "",
25
+ "Rules that hold regardless of the table above:",
26
+ "",
27
+ "- Use the domain's word, not the implementation's.",
28
+ ].join("\n");
29
+
30
+ describe("parseTerminologyRows", () => {
31
+ test("reads well-formed rows", () => {
32
+ expect(
33
+ parseTerminologyRows(
34
+ "| booking | reservation | the domain says booking\n",
35
+ ),
36
+ ).toEqual([
37
+ {
38
+ use: "booking",
39
+ not: "reservation",
40
+ because: "the domain says booking",
41
+ },
42
+ ]);
43
+ });
44
+
45
+ test("ignores commentary around the table", () => {
46
+ // `claude -p` often frames its answer, however firmly it is told not to.
47
+ const out = [
48
+ "Here are the rows I found:",
49
+ "",
50
+ "| booking | reservation | the domain says booking |",
51
+ "| tenant | account | billing is per tenant |",
52
+ "",
53
+ "Let me know if you want more.",
54
+ ].join("\n");
55
+ expect(parseTerminologyRows(out)).toHaveLength(2);
56
+ });
57
+
58
+ test("drops a header row and separator the model added anyway", () => {
59
+ const out = [
60
+ "| Use | Not | Because |",
61
+ "|---|---|---|",
62
+ "| booking | reservation | the domain says booking |",
63
+ ].join("\n");
64
+ expect(parseTerminologyRows(out)).toEqual([
65
+ {
66
+ use: "booking",
67
+ not: "reservation",
68
+ because: "the domain says booking",
69
+ },
70
+ ]);
71
+ });
72
+
73
+ test("drops an aligned separator too", () => {
74
+ expect(parseTerminologyRows("|:---|:---:|---:|\n| a | b | c |")).toEqual([
75
+ { use: "a", not: "b", because: "c" },
76
+ ]);
77
+ });
78
+
79
+ test("skips rows with the wrong number of cells", () => {
80
+ expect(parseTerminologyRows("| only | two |")).toEqual([]);
81
+ expect(parseTerminologyRows("| a | b | c | d |")).toEqual([]);
82
+ });
83
+
84
+ test("skips rows missing the two cells that carry meaning", () => {
85
+ expect(parseTerminologyRows("| | | because |")).toEqual([]);
86
+ expect(parseTerminologyRows("| use | | because |")).toEqual([]);
87
+ });
88
+
89
+ test("returns nothing for output with no table at all", () => {
90
+ expect(parseTerminologyRows("I could not find any terminology.")).toEqual(
91
+ [],
92
+ );
93
+ });
94
+ });
95
+
96
+ describe("fillTemplate", () => {
97
+ test("replaces the placeholder row", () => {
98
+ const filled = fillTemplate(TEMPLATE, [
99
+ { use: "booking", not: "reservation", because: "the domain says so" },
100
+ ]);
101
+ expect(filled).not.toContain("filled during");
102
+ expect(filled).toContain("| booking | reservation | the domain says so |");
103
+ });
104
+
105
+ test("keeps the rules below the table verbatim", () => {
106
+ // These are substantive guidance. Losing them would leave a style that
107
+ // looks filled in and says almost nothing.
108
+ const filled = fillTemplate(TEMPLATE, [
109
+ { use: "a", not: "b", because: "c" },
110
+ ]);
111
+ expect(filled).toContain("Rules that hold regardless of the table above:");
112
+ expect(filled).toContain(
113
+ "- Use the domain's word, not the implementation's.",
114
+ );
115
+ expect(filled).toContain("### Terminology");
116
+ });
117
+
118
+ test("still places rows if the placeholder is gone", () => {
119
+ // Guards against the upstream template changing shape and this silently
120
+ // producing an empty table.
121
+ const noPlaceholder = TEMPLATE.replace(
122
+ "| <!-- filled during apply --> | | |\n",
123
+ "",
124
+ );
125
+ const filled = fillTemplate(noPlaceholder, [
126
+ { use: "a", not: "b", because: "c" },
127
+ ]);
128
+ expect(filled).toContain("| a | b | c |");
129
+ });
130
+
131
+ test("refuses to produce a table with no rows", () => {
132
+ expect(() => fillTemplate(TEMPLATE, [])).toThrow(/No usable table rows/);
133
+ });
134
+ });
135
+
136
+ describe("terminologyStyleFile", () => {
137
+ test("keeps coding instructions on", () => {
138
+ const { frontmatter } = splitFrontmatter(terminologyStyleFile("### x"));
139
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
140
+ expect(frontmatter.name).toBe("terminology");
141
+ });
142
+ });
143
+
144
+ describe("buildPrompt", () => {
145
+ test("asks for rows only and includes the template for context", () => {
146
+ const prompt = buildPrompt(TEMPLATE);
147
+ expect(prompt).toContain("ONLY markdown table rows");
148
+ expect(prompt).toContain("No header row");
149
+ expect(prompt).toContain("### Terminology");
150
+ });
151
+ });
152
+
153
+ describe("fillTerminology", () => {
154
+ let dir: string;
155
+
156
+ beforeEach(async () => {
157
+ dir = await mkdtemp(join(tmpdir(), "magus-terminology-"));
158
+ });
159
+ afterEach(async () => {
160
+ await rm(dir, { recursive: true, force: true });
161
+ });
162
+
163
+ test("writes the filled style into the project so it commits with the repo", async () => {
164
+ const result = await fillTerminology({
165
+ projectPath: dir,
166
+ templateBody: TEMPLATE,
167
+ run: async () =>
168
+ "| booking | reservation | the domain says booking |\n| tenant | account | billing is per tenant |",
169
+ });
170
+
171
+ expect(result.rows).toHaveLength(2);
172
+ expect(result.path).toBe(
173
+ join(dir, ".claude", "output-styles", "terminology.md"),
174
+ );
175
+
176
+ const written = await fs.readFile(result.path, "utf8");
177
+ const { frontmatter, body } = splitFrontmatter(written);
178
+ expect(frontmatter.name).toBe("terminology");
179
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
180
+ expect(body).toContain("| booking | reservation |");
181
+ expect(body).toContain("Rules that hold regardless of the table above:");
182
+ });
183
+
184
+ test("writes nothing when the model returns no usable rows", async () => {
185
+ await expect(
186
+ fillTerminology({
187
+ projectPath: dir,
188
+ templateBody: TEMPLATE,
189
+ run: async () => "I could not determine any terminology.",
190
+ }),
191
+ ).rejects.toThrow(/no usable table rows/i);
192
+
193
+ // A half-written or empty style file is worse than none: it would show up
194
+ // under Team looking finished.
195
+ expect(await fs.pathExists(join(dir, ".claude", "output-styles"))).toBe(
196
+ false,
197
+ );
198
+ });
199
+
200
+ test("propagates a failure from the subprocess without writing", async () => {
201
+ await expect(
202
+ fillTerminology({
203
+ projectPath: dir,
204
+ templateBody: TEMPLATE,
205
+ run: async () => {
206
+ throw new Error("claude exited 1");
207
+ },
208
+ }),
209
+ ).rejects.toThrow(/claude exited 1/);
210
+ expect(await fs.pathExists(join(dir, ".claude", "output-styles"))).toBe(
211
+ false,
212
+ );
213
+ });
214
+ });
@@ -0,0 +1,354 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { RGBA } from "@opentui/core";
4
+ import fs from "fs-extra";
5
+ import { CONTRAST_REFERENCE, brand, theme } from "../ui/theme.js";
6
+
7
+ /**
8
+ * magus uses its own vivid accents on the terminal's own canvas.
9
+ *
10
+ * The bug this file exists for: `theme.colors.text` was the string `"white"`,
11
+ * which OpenTUI resolves to rgb(255,255,255) — not "the terminal's white". On a
12
+ * light theme every installed plugin was drawn white-on-cream and vanished,
13
+ * while the *un*installed ones, drawn in grey, stayed readable. The list read
14
+ * exactly backwards.
15
+ *
16
+ * The first fix pushed everything onto ANSI slots 0-15. That made it legible and
17
+ * flattened it: a theme rendering slots 2 and 3 as neighbouring olives leaves the
18
+ * scope squares indistinguishable, and slot-8 chips melt into a light page. So
19
+ * accents are now chosen by us — which means WE are responsible for their
20
+ * contrast, and these tests are how that responsibility is discharged.
21
+ *
22
+ * Invariants:
23
+ * 1. Backgrounds and body text stay the terminal's (it is the only colour that
24
+ * can clear a text-grade ratio against that terminal's own background).
25
+ * 2. Every accent clears 3:1 against BOTH reference backgrounds.
26
+ * 3. Ink on our own badges clears 4.5:1 against that badge.
27
+ * 4. The three scope colours are far enough apart in hue to read as squares.
28
+ * 5. No component invents a colour outside the palette.
29
+ */
30
+
31
+ // ─── Contrast maths (WCAG 2.1 relative luminance) ─────────────────────────────
32
+
33
+ function relativeLuminance(hex: string): number {
34
+ const n = hex.replace("#", "");
35
+ const channels = [0, 2, 4].map(
36
+ (i) => Number.parseInt(n.slice(i, i + 2), 16) / 255,
37
+ );
38
+ const linear = channels.map((c) =>
39
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
40
+ );
41
+ return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
42
+ }
43
+
44
+ function contrastRatio(a: string, b: string): number {
45
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
46
+ (x, y) => y - x,
47
+ );
48
+ return (hi + 0.05) / (lo + 0.05);
49
+ }
50
+
51
+ /**
52
+ * Perceptual distance (CIE76 ΔE) in L*a*b*.
53
+ *
54
+ * Contrast ratio is the wrong tool for "are these two swatches telling apart":
55
+ * it only compares luminance, and the scope colours are deliberately close in
56
+ * luminance — that is precisely what lets each of them clear 3:1 against both a
57
+ * light and a dark background. Measured, they sit at ratios of 1.00-1.03 to each
58
+ * other while being obviously different colours. ΔE sees the hue difference that
59
+ * a luminance ratio is blind to.
60
+ */
61
+ function deltaE(aHex: string, bHex: string): number {
62
+ const toLab = (hex: string): [number, number, number] => {
63
+ const n = hex.replace("#", "");
64
+ const [r, g, b] = [0, 2, 4].map((i) => {
65
+ const c = Number.parseInt(n.slice(i, i + 2), 16) / 255;
66
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
67
+ });
68
+ // sRGB -> XYZ (D65), then XYZ -> Lab
69
+ const x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
70
+ const y = r * 0.2126 + g * 0.7152 + b * 0.0722;
71
+ const z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
72
+ const f = (t: number) =>
73
+ t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
74
+ const [fx, fy, fz] = [f(x), f(y), f(z)];
75
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
76
+ };
77
+ const [l1, a1, b1] = toLab(aHex);
78
+ const [l2, a2, b2] = toLab(bHex);
79
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
80
+ }
81
+
82
+ /** WCAG threshold for UI components and large text. 4.5 is unreachable against
83
+ * both a light and a dark background at once — the luminance bands do not overlap. */
84
+ const UI_CONTRAST = 3;
85
+ const INK_CONTRAST = 4.5;
86
+
87
+ describe("contrast maths", () => {
88
+ // Guard the ruler before measuring with it: a broken luminance function would
89
+ // make every assertion below meaningless in whichever direction it was broken.
90
+ test("known reference ratios are correct", () => {
91
+ expect(contrastRatio("#FFFFFF", "#000000")).toBeCloseTo(21, 1);
92
+ expect(contrastRatio("#000000", "#FFFFFF")).toBeCloseTo(21, 1);
93
+ expect(contrastRatio("#777777", "#777777")).toBeCloseTo(1, 5);
94
+ });
95
+ });
96
+
97
+ // ─── The palette ──────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Inks we paint on a fill WE own, so they are measured against that fill and
101
+ * not against the terminal. They cannot satisfy the both-backgrounds rule and
102
+ * are not supposed to: `selectionDim` is a light grey chosen for the selection
103
+ * purple, which on a cream terminal would be invisible — and is never drawn
104
+ * there.
105
+ */
106
+ const OWN_FILL_INKS = new Set(["ink", "selectionDim"]);
107
+
108
+ const ACCENTS = Object.entries(brand).filter(
109
+ ([name]) => !OWN_FILL_INKS.has(name),
110
+ );
111
+
112
+ describe("accents are legible on light AND dark terminals", () => {
113
+ test("the palette is non-empty (guards the loops below)", () => {
114
+ expect(ACCENTS.length).toBeGreaterThanOrEqual(6);
115
+ });
116
+
117
+ for (const [name, hex] of ACCENTS) {
118
+ test(`${name} clears ${UI_CONTRAST}:1 on both backgrounds`, () => {
119
+ const onLight = contrastRatio(hex, CONTRAST_REFERENCE.light);
120
+ const onDark = contrastRatio(hex, CONTRAST_REFERENCE.dark);
121
+ // Reported together so a failure names the side that broke.
122
+ expect({
123
+ name,
124
+ onLight: onLight >= UI_CONTRAST,
125
+ onDark: onDark >= UI_CONTRAST,
126
+ }).toEqual({ name, onLight: true, onDark: true });
127
+ });
128
+ }
129
+
130
+ test("badge ink clears 4.5:1 on every badge colour we paint", () => {
131
+ const failures: string[] = [];
132
+ for (const [tone, colors] of Object.entries(theme.category)) {
133
+ const r = contrastRatio(brand.ink, colors.bg as string);
134
+ if (r < INK_CONTRAST) failures.push(`category.${tone} ${r.toFixed(2)}`);
135
+ }
136
+ for (const key of ["defaultBg", "primaryBg", "dangerBg"] as const) {
137
+ const r = contrastRatio(brand.ink, theme.hints[key]);
138
+ if (r < INK_CONTRAST) failures.push(`hints.${key} ${r.toFixed(2)}`);
139
+ }
140
+ const sel = contrastRatio(theme.selection.fg, theme.selection.bg);
141
+ if (sel < INK_CONTRAST) failures.push(`selection ${sel.toFixed(2)}`);
142
+ expect(failures).toEqual([]);
143
+ });
144
+
145
+ test("a SELECTED row has two legible inks, and the page palette is not among them", () => {
146
+ // Found on a screenshot: `(default)` was drawn in `theme.colors.muted` on
147
+ // the selection purple and simply was not there. Every page-level tone
148
+ // fails the same way — they are chosen against the terminal's background,
149
+ // and the selection fill is neither of the two references.
150
+ const bg = theme.selection.bg;
151
+ expect(contrastRatio(theme.selection.fg, bg)).toBeGreaterThanOrEqual(
152
+ INK_CONTRAST,
153
+ );
154
+ expect(contrastRatio(theme.selection.dim, bg)).toBeGreaterThanOrEqual(
155
+ UI_CONTRAST,
156
+ );
157
+
158
+ // And it must READ as secondary next to the primary ink, or the
159
+ // distinction it exists to make is not visible.
160
+ expect(
161
+ contrastRatio(theme.selection.dim, theme.selection.fg),
162
+ ).toBeGreaterThan(1.2);
163
+ expect(contrastRatio(theme.selection.dim, bg)).toBeLessThan(
164
+ contrastRatio(theme.selection.fg, bg),
165
+ );
166
+ });
167
+
168
+ test("the meta tones are exactly why a selected row cannot use them", () => {
169
+ // The negative half of the rule above, kept measurable: if a future
170
+ // palette change made one of these legible on the selection, the
171
+ // selection-aware branch in MetaText would still be right, but this test
172
+ // would tell us the reasoning had changed.
173
+ const unreadable = Object.entries(theme.meta).filter(
174
+ ([, hex]) => contrastRatio(hex, theme.selection.bg) < UI_CONTRAST,
175
+ );
176
+ expect(unreadable.map(([name]) => name).sort()).toEqual(
177
+ Object.keys(theme.meta).sort(),
178
+ );
179
+ });
180
+
181
+ test("the three scope colours are perceptually far apart", () => {
182
+ // They render as single-character squares, where a subtle shift is not
183
+ // readable at all. Deferring to ANSI put project and local on slots 2 and 3,
184
+ // which one real theme drew as two near-identical olives — the reason this
185
+ // rule exists. ΔE > 25 is comfortably past "different colour" (~2.3 is the
186
+ // just-noticeable difference); the bar is set high because these are tiny.
187
+ const scopes = Object.entries(theme.scopes) as [string, string][];
188
+ const tooClose: string[] = [];
189
+ for (let i = 0; i < scopes.length; i++) {
190
+ for (let j = i + 1; j < scopes.length; j++) {
191
+ const d = deltaE(scopes[i][1], scopes[j][1]);
192
+ if (d < 25)
193
+ tooClose.push(`${scopes[i][0]}~${scopes[j][0]} ΔE=${d.toFixed(1)}`);
194
+ }
195
+ }
196
+ expect(tooClose).toEqual([]);
197
+ expect(new Set(scopes.map(([, hex]) => hex)).size).toBe(scopes.length);
198
+ });
199
+
200
+ test("ΔE agrees that identical colours are identical (guards the metric)", () => {
201
+ expect(deltaE(brand.success, brand.success)).toBeCloseTo(0, 5);
202
+ expect(deltaE("#000000", "#FFFFFF")).toBeGreaterThan(90);
203
+ });
204
+ });
205
+
206
+ describe("the canvas stays the terminal's", () => {
207
+ test("normal text is the terminal's own foreground", () => {
208
+ // The original regression: this was `"white"`.
209
+ expect(theme.colors.text).toBeInstanceOf(RGBA);
210
+ expect((theme.colors.text as RGBA).intent).toBe("default");
211
+ });
212
+
213
+ test("surfaces use the terminal background, never an absolute fill", () => {
214
+ expect(theme.surface.bg).toBeInstanceOf(RGBA);
215
+ expect((theme.surface.bg as RGBA).intent).toBe("default");
216
+ });
217
+ });
218
+
219
+ // ─── Source guard ─────────────────────────────────────────────────────────────
220
+
221
+ const UI_DIR = path.join(import.meta.dir, "..", "ui");
222
+ /** The palette layer. Colour literals are these files' whole job. */
223
+ const PALETTE_FILES = new Set([
224
+ path.join(UI_DIR, "theme.ts"),
225
+ path.join(UI_DIR, "theme-mode.ts"),
226
+ ]);
227
+
228
+ async function uiSourceFiles(dir: string): Promise<string[]> {
229
+ const out: string[] = [];
230
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
231
+ const full = path.join(dir, entry.name);
232
+ if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
233
+ else if (/\.tsx?$/.test(entry.name) && !PALETTE_FILES.has(full))
234
+ out.push(full);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ const CSS_NAMES =
240
+ "white|black|gray|grey|red|green|yellow|blue|cyan|magenta|orange|purple";
241
+
242
+ /** A line that is purely a comment. Prose describing a colour is not a defect. */
243
+ function isComment(line: string): boolean {
244
+ const t = line.trim();
245
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
246
+ }
247
+
248
+ describe("components draw only from the palette", () => {
249
+ test("no colour literal outside theme.ts", async () => {
250
+ const files = await uiSourceFiles(UI_DIR);
251
+ expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
252
+
253
+ const offenders: string[] = [];
254
+ for (const file of files) {
255
+ const text = await fs.readFile(file, "utf8");
256
+ text.split("\n").forEach((line, i) => {
257
+ if (isComment(line)) return;
258
+ const hex = /#[0-9a-fA-F]{6}\b/.test(line);
259
+ const named = new RegExp(
260
+ `\\b(fg|bg|backgroundColor|borderColor|titleColor|textColor|color)\\s*[=:]\\s*"(${CSS_NAMES})"`,
261
+ ).test(line);
262
+ if (hex || named) {
263
+ offenders.push(
264
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
265
+ );
266
+ }
267
+ });
268
+ }
269
+ expect(offenders).toEqual([]);
270
+ });
271
+
272
+ test("no <text> element renders without naming a colour", async () => {
273
+ // OpenTUI's fallback for an unstyled cell is truecolor white, NOT the
274
+ // terminal's foreground. Measured on the real TUI: after the palette was
275
+ // first fixed, `tmux capture-pane -e` still showed 139 truecolor-white runs,
276
+ // and the one carrying a visible glyph was a bare `<text>Plugins: 17</text>`.
277
+ // A correct palette cannot save an element that never asks for a colour.
278
+ const files = await uiSourceFiles(UI_DIR);
279
+ const offenders: string[] = [];
280
+ for (const file of files) {
281
+ const text = await fs.readFile(file, "utf8");
282
+ text.split("\n").forEach((line, i) => {
283
+ if (isComment(line)) return;
284
+ if (/<text>/.test(line)) {
285
+ offenders.push(
286
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
287
+ );
288
+ }
289
+ });
290
+ }
291
+ expect(offenders).toEqual([]);
292
+ });
293
+
294
+ test("no element falls back to `undefined` for a colour", async () => {
295
+ // `fg={selected ? x : undefined}` reads as "leave it alone", but OpenTUI's
296
+ // fallback for an unstyled cell is truecolor white — so every unselected
297
+ // SelectableRow rendered white-on-cream and vanished on a light theme. Same
298
+ // root cause as the original bug, reached by omission rather than by a wrong
299
+ // literal, which is why the `<text>` guard alone did not catch it.
300
+ const files = await uiSourceFiles(UI_DIR);
301
+ const offenders: string[] = [];
302
+ for (const file of files) {
303
+ const text = await fs.readFile(file, "utf8");
304
+ text.split("\n").forEach((line, i) => {
305
+ if (isComment(line)) return;
306
+ if (/\bfg=\{[^}]*\bundefined\b/.test(line)) {
307
+ offenders.push(
308
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
309
+ );
310
+ }
311
+ });
312
+ }
313
+ // `bg` legitimately falls back to the terminal page — that is how a row sits
314
+ // on the terminal's own background — so only `fg` is checked. The pattern is
315
+ // anchored to fg itself: filtering whole lines flagged
316
+ // `fg={x} bg={cond ? y : undefined}`, which is correct code.
317
+ expect(offenders).toEqual([]);
318
+ });
319
+
320
+ test("a background we paint never carries the terminal's ink", async () => {
321
+ // `theme.colors.text` is the TERMINAL's foreground. It is the right choice on
322
+ // the terminal's own page and the wrong one on a block we painted: on a light
323
+ // theme it is dark, so `bg={accent} fg={colors.text}` renders dark-on-purple.
324
+ // Reported from the real UI as the top tab bar showing "black on purple".
325
+ // It was not one tab — 16 sites shared it, across tabs, badges and chips.
326
+ //
327
+ // The rule: if we own the background, we own the ink (theme.hints.fg).
328
+ const files = await uiSourceFiles(UI_DIR);
329
+ const offenders: string[] = [];
330
+ for (const file of files) {
331
+ const text = await fs.readFile(file, "utf8");
332
+ text.split("\n").forEach((line, i) => {
333
+ if (isComment(line)) return;
334
+ if (/\bbg=\{[^}]+\}\s+fg=\{theme\.colors\.text\}/.test(line)) {
335
+ offenders.push(
336
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
337
+ );
338
+ }
339
+ });
340
+ }
341
+ expect(offenders).toEqual([]);
342
+ });
343
+
344
+ test("the comment filter does not swallow a real offender", () => {
345
+ // The guards skip comment lines, because a doc comment that merely mentions
346
+ // `<text>` or a hex value is not a defect — one legitimately did, and failed
347
+ // the build. A filter that silently hid real code would be worse than the
348
+ // false positive it replaced, so pin both directions.
349
+ expect(isComment(" // <text>foo</text>")).toBe(true);
350
+ expect(isComment(' * fg="white" in a docblock')).toBe(true);
351
+ expect(isComment(" <text>real</text>")).toBe(false);
352
+ expect(isComment(' <span fg="white">real</span>')).toBe(false);
353
+ });
354
+ });