ompchamber 1.2.1 → 2.0.1

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 (237) hide show
  1. package/README.md +143 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.cb81980498.js +2 -0
  14. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  15. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  16. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  17. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  18. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  19. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  20. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  21. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  22. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  23. package/dist/client/static/js/async/q.22588845a9.js +21 -0
  24. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  25. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  26. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  27. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  28. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  29. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  31. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  32. package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
  33. package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
  34. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  35. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  36. package/dist/client/static/js/index.1fd9e1c543.js +38 -0
  37. package/package.json +3 -3
  38. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  39. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  40. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  41. package/src/client/components/settings/Modal.tsx +11 -2
  42. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  43. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  45. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  46. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  47. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  48. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  49. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  50. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  51. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  52. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  53. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  54. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  55. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  56. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  57. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  58. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  60. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  64. package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
  65. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
  66. package/src/client/components/workspace/diff-panel/index.tsx +8 -1
  67. package/src/client/components/workspace/editor/index.tsx +18 -2
  68. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  69. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  70. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  71. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  72. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  73. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  74. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  75. package/src/client/data/theme/terminal.ts +50 -52
  76. package/src/client/hooks/chat/btw/index.ts +139 -18
  77. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  78. package/src/client/hooks/chat/btw/mode.ts +111 -16
  79. package/src/client/hooks/chat/composer/index.ts +10 -4
  80. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  81. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  82. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  83. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  84. package/src/client/hooks/chat/timeline/index.ts +8 -20
  85. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  86. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  87. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  88. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  89. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  90. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  91. package/src/client/hooks/editor/save-file.ts +62 -0
  92. package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
  93. package/src/client/hooks/editor/use-file-editor.ts +38 -29
  94. package/src/client/hooks/editor/use-file-transfer.ts +5 -2
  95. package/src/client/hooks/settings/providers.ts +34 -3
  96. package/src/client/hooks/ui/open-file.ts +7 -1
  97. package/src/client/hooks/ui/theme.ts +82 -57
  98. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  99. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  100. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  101. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  102. package/src/client/hooks/workspace/search-stream.ts +24 -5
  103. package/src/client/main.tsx +6 -0
  104. package/src/client/tailwind.css +19 -56
  105. package/src/server/lib/btw/child.server.ts +41 -0
  106. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  107. package/src/server/lib/btw/prompt.ts +13 -5
  108. package/src/server/lib/btw/registry.server.ts +13 -4
  109. package/src/server/lib/btw/runtime.server.ts +149 -156
  110. package/src/server/lib/btw/schema.server.ts +27 -0
  111. package/src/server/lib/btw/service.server.ts +106 -5
  112. package/src/server/lib/btw/store.server.ts +57 -6
  113. package/src/server/lib/db/schema.ts +4 -0
  114. package/src/server/lib/db/settings-store.test.ts +65 -0
  115. package/src/server/lib/db/settings-store.ts +25 -0
  116. package/src/server/lib/fs/atomic-write.ts +42 -4
  117. package/src/server/lib/fs/config-lock.ts +74 -0
  118. package/src/server/lib/fs/git-ignore.ts +55 -5
  119. package/src/server/lib/fs/ripgrep.ts +119 -0
  120. package/src/server/lib/lifecycle/identity.ts +10 -64
  121. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  122. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  123. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  124. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  125. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  126. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  127. package/src/server/lib/models/provider-registry.server.ts +2 -2
  128. package/src/server/lib/models/registry.server.ts +1 -1
  129. package/src/server/lib/omp/config/behavior.ts +17 -17
  130. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  131. package/src/server/lib/omp/config/document.ts +128 -0
  132. package/src/server/lib/omp/config/extensions.ts +11 -10
  133. package/src/server/lib/omp/config/mcp.ts +4 -57
  134. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  135. package/src/server/lib/omp/config/models-config.ts +92 -0
  136. package/src/server/lib/omp/config/models-validation.ts +159 -0
  137. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  138. package/src/server/lib/omp/config/providers.ts +241 -211
  139. package/src/server/lib/omp/config/roles.ts +6 -14
  140. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  141. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  143. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  144. package/src/server/lib/omp/rpc/manager.ts +21 -21
  145. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  146. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  147. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  148. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  149. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  150. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  151. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  152. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  153. package/src/server/lib/queue/delivery-harness.ts +88 -0
  154. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  155. package/src/server/lib/queue/delivery.server.ts +105 -13
  156. package/src/server/lib/queue/store.server.ts +30 -5
  157. package/src/server/lib/terminal/foreground.test.ts +164 -0
  158. package/src/server/lib/terminal/foreground.ts +34 -6
  159. package/src/server/lib/terminal/history.ts +8 -7
  160. package/src/server/plugins/compress.ts +33 -5
  161. package/src/server/plugins/ssr.ts +14 -7
  162. package/src/server/routes/btw/topics.ts +89 -2
  163. package/src/server/routes/fs/action.test.ts +64 -0
  164. package/src/server/routes/fs/action.ts +10 -1
  165. package/src/server/routes/fs/git.ts +5 -6
  166. package/src/server/routes/fs/search.ts +17 -26
  167. package/src/server/routes/sessions/queue.ts +17 -4
  168. package/src/server/routes/settings/index.ts +2 -0
  169. package/src/server/routes/settings/model-override.ts +69 -0
  170. package/src/server/routes/settings/provider-models.ts +35 -12
  171. package/src/server/routes/settings/providers.ts +31 -1
  172. package/src/server/routes/settings/root.ts +8 -3
  173. package/src/shared/lib/chat/btw/client.ts +34 -0
  174. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  175. package/src/shared/lib/code/line-endings.test.ts +38 -0
  176. package/src/shared/lib/code/line-endings.ts +56 -0
  177. package/src/shared/lib/code/shiki-themes.ts +9 -5
  178. package/src/shared/lib/fs/file-tree.ts +17 -12
  179. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  180. package/src/shared/lib/markdown/autolink.ts +50 -0
  181. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  182. package/src/shared/lib/markdown/document-urls.ts +118 -0
  183. package/src/shared/lib/markdown/marked.ts +27 -15
  184. package/src/shared/lib/markdown/mermaid.ts +2 -2
  185. package/src/shared/lib/models/catalog.ts +1 -1
  186. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  187. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  188. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  189. package/src/shared/lib/settings/client.ts +10 -4
  190. package/src/shared/lib/settings/diff.test.ts +31 -0
  191. package/src/shared/lib/settings/diff.ts +37 -0
  192. package/src/shared/lib/theme/catalog.test.ts +168 -0
  193. package/src/shared/lib/theme/catalog.ts +98 -0
  194. package/src/shared/lib/theme/color.ts +88 -0
  195. package/src/shared/lib/theme/css.ts +70 -0
  196. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  197. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  198. package/src/shared/lib/theme/palettes/light.ts +313 -0
  199. package/src/shared/types/btw.ts +83 -4
  200. package/src/shared/types/index.ts +1 -0
  201. package/src/shared/types/settings/provider.ts +8 -1
  202. package/src/shared/types/settings/state.ts +10 -4
  203. package/src/shared/types/theme.ts +48 -0
  204. package/dist/client/static/css/index.b938f4a693.css +0 -2
  205. package/dist/client/static/js/77.8022a800b5.js +0 -139
  206. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  207. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  208. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  209. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  210. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  211. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  212. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  213. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  214. package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
  215. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  216. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  217. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  218. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  219. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  220. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  221. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  222. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  223. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  224. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  225. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  226. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  227. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  228. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  229. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The catalog is the single source of truth for what the chamber can paint, and
8
+ * every failure mode here is silent at runtime: an unknown id renders the
9
+ * default palette (a theme that "doesn't work"), a missing color renders as an
10
+ * empty declaration (an invisible swatch, a transparent panel), a wrong variant
11
+ * leaves a dark theme with light scrollbars and light-theme code blocks.
12
+ */
13
+
14
+ import { describe, expect, test } from 'bun:test';
15
+
16
+ import {
17
+ DEFAULT_THEME_ID,
18
+ THEMES,
19
+ THEME_SWATCHES,
20
+ isDarkTheme,
21
+ resolveTheme,
22
+ resolveThemeId,
23
+ } from '@/shared/lib/theme/catalog';
24
+ import { themeStyleSheet } from '@/shared/lib/theme/css';
25
+ import { mixHex, parseHexColor } from '@/shared/lib/theme/color';
26
+ import { getXtermTheme } from '@/client/data/theme/terminal';
27
+
28
+ const COLOR_KEYS = ['canvas', 'paper', 'ink', 'error', 'success', 'info', 'warning', 'meta'] as const;
29
+ const COLOR_VALUE = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8})$/i;
30
+
31
+ describe('theme catalog', () => {
32
+ test('ships the chamber palettes plus every ported variant', () => {
33
+ expect(THEMES.length).toBe(45);
34
+ // 21 ported light + paper/contrast; 21 ported dark + one-dark-pro-soft.
35
+ expect(THEMES.filter((t) => t.variant === 'light').length).toBe(23);
36
+ expect(THEMES.filter((t) => t.variant === 'dark').length).toBe(22);
37
+ });
38
+
39
+ test('ids are unique and each palette carries a paintable color for every slot', () => {
40
+ const ids = new Set<string>();
41
+ for (const theme of THEMES) {
42
+ expect(ids.has(theme.id)).toBe(false);
43
+ ids.add(theme.id);
44
+ expect(theme.name.length).toBeGreaterThan(0);
45
+ expect(theme.family.length).toBeGreaterThan(0);
46
+ for (const key of COLOR_KEYS) {
47
+ expect(theme[key]).toMatch(COLOR_VALUE);
48
+ }
49
+ }
50
+ });
51
+
52
+ test('the default is the shipped E-Ink palette', () => {
53
+ expect(DEFAULT_THEME_ID).toBe('paper');
54
+ expect(resolveTheme(DEFAULT_THEME_ID).name).toBe('E-Ink Paper');
55
+ });
56
+
57
+ test('an id the catalog does not know resolves to the default, never to nothing', () => {
58
+ // `noir` shipped until this change as a byte-identical alias of
59
+ // `one-dark-pro-soft`; a settings row naming it must still render.
60
+ for (const unknown of ['noir', 'nonsense', '', undefined, null]) {
61
+ expect(resolveTheme(unknown).id).toBe(DEFAULT_THEME_ID);
62
+ expect(resolveThemeId(unknown)).toBe(DEFAULT_THEME_ID);
63
+ }
64
+ });
65
+
66
+ test('every ported family pairs a light entry with a dark one', () => {
67
+ const byFamily = new Map<string, Set<string>>();
68
+ for (const theme of THEMES) {
69
+ const variants = byFamily.get(theme.family) ?? new Set<string>();
70
+ variants.add(theme.variant);
71
+ byFamily.set(theme.family, variants);
72
+ }
73
+ // `paper`/`contrast` (light-only) and `one-dark-pro-soft` (dark-only) are
74
+ // the chamber's own single-variant palettes; every ported family has both.
75
+ const singles = [...byFamily].filter(([, variants]) => variants.size === 1).map(([family]) => family);
76
+ expect(singles.sort()).toEqual(['contrast', 'one-dark-pro-soft', 'paper']);
77
+ });
78
+
79
+ test('swatches name every color the cards can show', () => {
80
+ expect(THEME_SWATCHES.map((s) => s.key)).toEqual([...COLOR_KEYS]);
81
+ });
82
+ });
83
+
84
+ describe('generated stylesheet', () => {
85
+ const css = themeStyleSheet();
86
+
87
+ test('defines every palette under a selector that beats :root', () => {
88
+ for (const theme of THEMES) {
89
+ // `html[...]` (0,1,1) outranks the stylesheet's `:root` (0,1,0); a bare
90
+ // `[data-theme]` would tie and lose on document order.
91
+ expect(css).toContain(`html[data-theme="${theme.id}"]`);
92
+ }
93
+ expect(css.match(/html\[data-theme="/g)?.length).toBe(THEMES.length);
94
+ });
95
+
96
+ test('carries each palette\'s colors and its color-scheme', () => {
97
+ expect(css).toContain('--theme-canvas: #1F2430;');
98
+ expect(css).toContain('color-scheme: dark;');
99
+ expect(css).toContain('color-scheme: light;');
100
+ });
101
+
102
+ test('derives the hover shades instead of pinning a hex that fits one palette', () => {
103
+ expect(css).toContain('--theme-info-hover: color-mix(in srgb, var(--theme-info) 85%, var(--theme-ink));');
104
+ });
105
+ });
106
+
107
+ describe('theme variant verdict', () => {
108
+ test('follows the palette, not a hand-kept id list', () => {
109
+ expect(isDarkTheme('paper')).toBe(false);
110
+ expect(isDarkTheme('one-dark-pro-soft')).toBe(true);
111
+ expect(isDarkTheme('nord-dark')).toBe(true);
112
+ expect(isDarkTheme('nord-light')).toBe(false);
113
+ expect(isDarkTheme('tokyonight-light')).toBe(false);
114
+ expect(isDarkTheme('tokyonight-dark')).toBe(true);
115
+ });
116
+
117
+ test('every dark palette is dark and every light palette is not', () => {
118
+ for (const theme of THEMES) {
119
+ expect(isDarkTheme(theme.id)).toBe(theme.variant === 'dark');
120
+ }
121
+ });
122
+ });
123
+
124
+ describe('color math', () => {
125
+ test('parses the notations the ported palettes actually use', () => {
126
+ expect(parseHexColor('#FFF')).toEqual({ r: 255, g: 255, b: 255, a: 1 });
127
+ expect(parseHexColor('#f4f1ea')).toEqual({ r: 244, g: 241, b: 234, a: 1 });
128
+ expect(parseHexColor('#dbd7caee')?.a).toBeCloseTo(0.933, 3);
129
+ expect(parseHexColor('rebeccapurple')).toBeNull();
130
+ });
131
+
132
+ test('mixes toward the blend color and composites its alpha', () => {
133
+ expect(mixHex('#000000', '#ffffff', 0.5)).toBe('#808080');
134
+ expect(mixHex('#000000', '#ffffff', 0)).toBe('#000000');
135
+ expect(mixHex('#000000', '#ffffff', 1)).toBe('#ffffff');
136
+ // A fully transparent blend leaves the base untouched rather than
137
+ // producing a black hole.
138
+ expect(mixHex('#123456', '#ffffff00', 1)).toBe('#123456');
139
+ expect(mixHex('nonsense', '#ffffff', 1)).toBe('nonsense');
140
+ });
141
+ });
142
+
143
+ describe('terminal palette', () => {
144
+ test('follows the active theme rather than a fixed light/dark pair', () => {
145
+ const nord = getXtermTheme('nord-dark');
146
+ const paper = getXtermTheme('paper');
147
+ expect(nord.background).toBe('#1F2430');
148
+ expect(nord.foreground).toBe('#E5E9F0');
149
+ expect(paper.background).toBe('#f4f1ea');
150
+ expect(paper.foreground).toBe('#141310');
151
+ });
152
+
153
+ test('emits literal colors — xterm cannot parse color-mix()', () => {
154
+ for (const theme of THEMES) {
155
+ for (const value of Object.values(getXtermTheme(theme.id))) {
156
+ expect(value).toMatch(COLOR_VALUE);
157
+ }
158
+ }
159
+ });
160
+
161
+ test('takes its signal colors from the palette', () => {
162
+ const dracula = getXtermTheme('dracula-dark');
163
+ expect(dracula.red).toBe('#FF5555');
164
+ expect(dracula.green).toBe('#50FA7B');
165
+ expect(dracula.yellow).toBe('#FFB86C');
166
+ expect(dracula.cursor).toBe('#BD93F9');
167
+ });
168
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The theme catalog: every palette the chamber can paint, the lookups the whole
8
+ * app resolves a stored theme id through, and the light/dark verdict that used
9
+ * to be re-derived by hand in four places (`hooks/ui/theme.ts`,
10
+ * `code/shiki-themes.ts`, `markdown/mermaid.ts`, `server/plugins/ssr.ts`) — each
11
+ * with its own hardcoded id list that a new theme would have silently missed.
12
+ *
13
+ * Pure data plus pure functions: no DOM, no CSS, no server imports, so the
14
+ * client, the SSR shell and the tests all read the same catalog.
15
+ */
16
+
17
+ import type { ThemePalette, ThemeSwatch } from '@/shared/types/theme';
18
+ import { CHAMBER_PALETTES } from '@/shared/lib/theme/palettes/chamber';
19
+ import { DARK_PALETTES } from '@/shared/lib/theme/palettes/dark';
20
+ import { LIGHT_PALETTES } from '@/shared/lib/theme/palettes/light';
21
+
22
+ /** The shipped default, and the fallback for any id the catalog does not know. */
23
+ export const DEFAULT_THEME_ID = 'paper';
24
+
25
+ /**
26
+ * Every id in the catalog, as a literal union. `SettingsState.theme` is typed
27
+ * with it, so a typo — or a theme dropped from the catalog — is a compile error
28
+ * at the writer instead of an unstyled page at runtime.
29
+ */
30
+ export type ThemeId =
31
+ | (typeof CHAMBER_PALETTES)[number]['id']
32
+ | (typeof LIGHT_PALETTES)[number]['id']
33
+ | (typeof DARK_PALETTES)[number]['id'];
34
+
35
+ const ALL_PALETTES = [...CHAMBER_PALETTES, ...LIGHT_PALETTES, ...DARK_PALETTES];
36
+
37
+ const BY_ID: Record<string, ThemePalette | undefined> = Object.fromEntries(
38
+ ALL_PALETTES.map((theme) => [theme.id, theme]),
39
+ );
40
+
41
+ const DEFAULT_PALETTE: ThemePalette = CHAMBER_PALETTES[0];
42
+
43
+ /**
44
+ * Every palette, in appearance-grid order: the chamber's own three first (the
45
+ * ones an existing install is already using), then each ported family with its
46
+ * light and dark entry adjacent.
47
+ *
48
+ * Pairing is by family key rather than by array index, so a re-port that
49
+ * updates one variant of a family cannot silently shift every pair after it.
50
+ */
51
+ export const THEMES: readonly ThemePalette[] = (() => {
52
+ const families = new Map<string, ThemePalette[]>();
53
+ for (const palette of [...LIGHT_PALETTES, ...DARK_PALETTES]) {
54
+ const group = families.get(palette.family);
55
+ if (group) group.push(palette);
56
+ else families.set(palette.family, [palette]);
57
+ }
58
+ return [...CHAMBER_PALETTES, ...[...families.values()].flat()];
59
+ })();
60
+
61
+ /**
62
+ * Resolve a stored theme id to a palette the catalog can actually paint.
63
+ *
64
+ * A settings row can name a theme this build no longer ships — a downgrade, or
65
+ * the `noir` alias that was byte-identical to `one-dark-pro-soft` and is gone.
66
+ * Every reader goes through here, so an unknown id renders the default instead
67
+ * of an unstyled page.
68
+ */
69
+ export function resolveTheme(id: string | undefined | null): ThemePalette {
70
+ return (id ? BY_ID[id] : undefined) ?? DEFAULT_PALETTE;
71
+ }
72
+
73
+ export function isDarkTheme(id: string | undefined | null): boolean {
74
+ return resolveTheme(id).variant === 'dark';
75
+ }
76
+
77
+ /**
78
+ * The catalog's own id for `id`, falling back to the default.
79
+ *
80
+ * The single narrowing point between a runtime string (a stored setting, a
81
+ * button's payload) and `ThemeId`: every palette's `id` is one of the union's
82
+ * members by construction, which is the fact the cast asserts.
83
+ */
84
+ export function resolveThemeId(id: string | undefined | null): ThemeId {
85
+ return resolveTheme(id).id as ThemeId;
86
+ }
87
+
88
+ /** The eight slots a palette card shows, in paint order. */
89
+ export const THEME_SWATCHES: readonly ThemeSwatch[] = [
90
+ { key: 'canvas', label: 'Canvas' },
91
+ { key: 'paper', label: 'Surface' },
92
+ { key: 'ink', label: 'Ink' },
93
+ { key: 'error', label: 'Error' },
94
+ { key: 'success', label: 'Success' },
95
+ { key: 'info', label: 'Info' },
96
+ { key: 'warning', label: 'Warning' },
97
+ { key: 'meta', label: 'Accent' },
98
+ ];
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Hex color arithmetic for the places that cannot use CSS.
8
+ *
9
+ * `color-mix()` covers every blend inside the document, but xterm parses theme
10
+ * colors itself (it accepts `#rgb`, `#rrggbb`, `rgb()`, `hsl()` and named
11
+ * colors — not `color-mix()`, in either the DOM or the WebGL renderer), so the
12
+ * terminal palette has to be computed here and handed over as literal hex.
13
+ *
14
+ * The palette values are ported verbatim from OpenChamber and are therefore not
15
+ * uniform: `#FFF` (jetbrains-light) and 8-digit alpha (`#dbd7caee`,
16
+ * vitesse-dark) both appear, and a blend has to composite the alpha rather than
17
+ * pretend it is opaque.
18
+ */
19
+
20
+ export interface Rgba {
21
+ r: number;
22
+ g: number;
23
+ b: number;
24
+ /** 0–1. */
25
+ a: number;
26
+ }
27
+
28
+ const HEX_SHORT = /^#?([0-9a-f]{3})$/i;
29
+ const HEX_LONG = /^#?([0-9a-f]{6})$/i;
30
+ const HEX_ALPHA = /^#?([0-9a-f]{8})$/i;
31
+
32
+ /** Parse `#rgb`, `#rrggbb` or `#rrggbbaa`. `null` for anything else. */
33
+ export function parseHexColor(value: string): Rgba | null {
34
+ const trimmed = value.trim();
35
+ const short = HEX_SHORT.exec(trimmed);
36
+ if (short) {
37
+ const [r, g, b] = short[1].split('');
38
+ return { r: parseInt(r + r, 16), g: parseInt(g + g, 16), b: parseInt(b + b, 16), a: 1 };
39
+ }
40
+ const long = HEX_LONG.exec(trimmed);
41
+ if (long) {
42
+ const n = parseInt(long[1], 16);
43
+ return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff, a: 1 };
44
+ }
45
+ const alpha = HEX_ALPHA.exec(trimmed);
46
+ if (alpha) {
47
+ const n = parseInt(alpha[1], 16);
48
+ return { r: (n >>> 24) & 0xff, g: (n >> 16) & 0xff, b: (n >> 8) & 0xff, a: (n & 0xff) / 255 };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function toHex2(value: number): string {
54
+ return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0');
55
+ }
56
+
57
+ export function rgbaToHex({ r, g, b }: Rgba): string {
58
+ return `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`;
59
+ }
60
+
61
+ /**
62
+ * `ratio` of `blend` laid over `base` (0 = base, 1 = blend), compositing
63
+ * `blend`'s own alpha. Falls back to `base` when either value cannot be parsed,
64
+ * so a palette with an unexpected notation still yields a paintable color.
65
+ */
66
+ export function mixHex(base: string, blend: string, ratio: number): string {
67
+ const under = parseHexColor(base);
68
+ const over = parseHexColor(blend);
69
+ if (!under || !over) return base;
70
+ const weight = over.a * ratio;
71
+ return rgbaToHex({
72
+ r: under.r * (1 - weight) + over.r * weight,
73
+ g: under.g * (1 - weight) + over.g * weight,
74
+ b: under.b * (1 - weight) + over.b * weight,
75
+ a: 1,
76
+ });
77
+ }
78
+
79
+ /** WCAG relative luminance, used to order two palette colors light-to-dark. */
80
+ export function relativeLuminance(value: string): number {
81
+ const color = parseHexColor(value);
82
+ if (!color) return 0;
83
+ const channel = (raw: number) => {
84
+ const c = raw / 255;
85
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
86
+ };
87
+ return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b);
88
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `[data-theme]` CSS for every palette in the catalog, as one string.
8
+ *
9
+ * Generated rather than hand-written because the palette values already exist
10
+ * in TypeScript — the appearance cards render the same eight colors as chips —
11
+ * so a second hand-maintained copy in `tailwind.css` would be 45 blocks that
12
+ * drift from the cards the moment either side is edited.
13
+ *
14
+ * Two details are load-bearing:
15
+ *
16
+ * - **Selector specificity is `html[data-theme=…]`, not `[data-theme=…]`.**
17
+ * `tailwind.css` carries the default palette on `:root`, which is also
18
+ * (0,1,0); at equal specificity the later declaration wins by document order,
19
+ * so a bare attribute selector injected before the stylesheet would lose and
20
+ * every theme would render as `paper`.
21
+ * - **`color-scheme` is set per variant.** Scrollbars, form controls and the
22
+ * browser's own canvas background are painted by the UA from it; without it a
23
+ * dark theme keeps white scrollbars and a white overscroll area.
24
+ *
25
+ * No DOM here — the SSR shell inlines this, and the client injects the same
26
+ * string only when a served document predates it.
27
+ */
28
+
29
+ import type { ThemePalette } from '@/shared/types/theme';
30
+ import { THEMES } from '@/shared/lib/theme/catalog';
31
+
32
+ /** The `<style>` element's id: the SSR shell writes it, the client checks it. */
33
+ export const THEME_STYLE_ELEMENT_ID = 'omp-theme-palettes';
34
+
35
+ /**
36
+ * A hover shade of a signal color, mixed toward the theme's own ink so the
37
+ * direction follows the palette: darker on a light theme, lighter on a dark one.
38
+ * `--theme-info-hover` / `--theme-warning-hover` used to be fixed hex values in
39
+ * `tailwind.css` and could only be right for one palette.
40
+ */
41
+ const HOVER_MIXES: ReadonlyArray<readonly [string, string]> = [
42
+ ['--theme-info-hover', '--theme-info'],
43
+ ['--theme-warning-hover', '--theme-warning'],
44
+ ];
45
+
46
+ function paletteBlock(theme: ThemePalette): string {
47
+ const lines = [
48
+ ` --theme-paper: ${theme.paper};`,
49
+ ` --theme-canvas: ${theme.canvas};`,
50
+ ` --theme-ink: ${theme.ink};`,
51
+ ` --theme-error: ${theme.error};`,
52
+ ` --theme-success: ${theme.success};`,
53
+ ` --theme-info: ${theme.info};`,
54
+ ` --theme-warning: ${theme.warning};`,
55
+ ` --theme-meta: ${theme.meta};`,
56
+ ];
57
+ for (const [name, source] of HOVER_MIXES) {
58
+ lines.push(` ${name}: color-mix(in srgb, var(${source}) 85%, var(--theme-ink));`);
59
+ }
60
+ lines.push(` color-scheme: ${theme.variant};`);
61
+ return `html[data-theme="${theme.id}"] {\n${lines.join('\n')}\n}`;
62
+ }
63
+
64
+ /** Cached: the palette set is fixed for the life of the process. */
65
+ let cached: string | null = null;
66
+
67
+ export function themeStyleSheet(): string {
68
+ cached ??= `/* Generated from src/shared/lib/theme/palettes — do not edit by hand. */\n${THEMES.map(paletteBlock).join('\n')}\n`;
69
+ return cached;
70
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The chamber's own palettes, carried over from the hand-written
8
+ * `[data-theme]` blocks in `tailwind.css` with their values unchanged.
9
+ *
10
+ * They stay in the catalog rather than becoming OpenChamber ports so an
11
+ * existing install keeps the exact colors it was using: `paper` is the shipped
12
+ * default and `one-dark-pro-soft` is what every dark-theme user has selected
13
+ * until now.
14
+ */
15
+
16
+ import type { ThemePalette } from '@/shared/types/theme';
17
+
18
+ export const CHAMBER_PALETTES = [
19
+ {
20
+ id: 'paper',
21
+ name: 'E-Ink Paper',
22
+ family: 'paper',
23
+ variant: 'light',
24
+ canvas: '#f4f1ea',
25
+ paper: '#faf8f3',
26
+ ink: '#141310',
27
+ error: '#c8321e',
28
+ success: '#047857',
29
+ info: '#0284c7',
30
+ warning: '#c2410c',
31
+ meta: '#4f46e5',
32
+ },
33
+ {
34
+ id: 'contrast',
35
+ name: 'High Contrast',
36
+ family: 'contrast',
37
+ variant: 'light',
38
+ canvas: '#f4f4f4',
39
+ paper: '#ffffff',
40
+ ink: '#111111',
41
+ error: '#b42318',
42
+ success: '#067647',
43
+ info: '#0369a1',
44
+ warning: '#b54708',
45
+ meta: '#4338ca',
46
+ },
47
+ {
48
+ id: 'one-dark-pro-soft',
49
+ name: 'One Dark Pro Soft',
50
+ family: 'one-dark-pro-soft',
51
+ variant: 'dark',
52
+ canvas: '#21252b',
53
+ paper: '#282c34',
54
+ ink: '#abb2bf',
55
+ error: '#e06c75',
56
+ success: '#34d399',
57
+ info: '#61afef',
58
+ warning: '#fb923c',
59
+ meta: '#818cf8',
60
+ },
61
+ ] as const satisfies readonly ThemePalette[];