ompchamber 3.0.0 → 3.1.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 (114) hide show
  1. package/README.md +30 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-5yfre1kw.css +1 -0
  5. package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/stop-scope.js +23 -0
  12. package/src/cli/lib/stop-scope.test.js +41 -0
  13. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  14. package/src/client/components/common/code-editor/handler.ts +175 -0
  15. package/src/client/components/common/code-editor/index.tsx +185 -220
  16. package/src/client/components/common/code-surface/index.tsx +109 -12
  17. package/src/client/components/common/lazy-panels.tsx +5 -1
  18. package/src/client/components/common/viewport.ts +5 -3
  19. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  20. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  21. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  22. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  23. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  24. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  25. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  26. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  27. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  28. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  29. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  30. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  31. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  32. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  33. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  34. package/src/client/components/workspace/editor/index.tsx +153 -53
  35. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  36. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  37. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  38. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  39. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  40. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  41. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  42. package/src/client/hooks/editor/scroll-container.ts +26 -0
  43. package/src/client/hooks/editor/use-code-window.ts +2 -20
  44. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  45. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  46. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  47. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  48. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  49. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  50. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  51. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  52. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  53. package/src/client/hooks/workspace/session-todos.ts +102 -0
  54. package/src/client/tailwind.css +30 -0
  55. package/src/server/lib/bundler/css.test.ts +87 -0
  56. package/src/server/lib/bundler/css.ts +17 -2
  57. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  58. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  59. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  60. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  61. package/src/server/lib/lifecycle/restart.ts +9 -14
  62. package/src/server/lib/omp/session/todos.ts +100 -0
  63. package/src/server/routes/fs/action-create.test.ts +90 -0
  64. package/src/server/routes/fs/action.ts +23 -0
  65. package/src/server/routes/omp/index.ts +2 -0
  66. package/src/server/routes/omp/session-todos.ts +80 -0
  67. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  68. package/src/shared/lib/chat/todo/progress.ts +97 -0
  69. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  70. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  71. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  72. package/src/shared/lib/code/editor/commands.ts +260 -0
  73. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  74. package/src/shared/lib/code/editor/edits.ts +122 -0
  75. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  76. package/src/shared/lib/code/editor/find-status.ts +36 -0
  77. package/src/shared/lib/code/editor/find.test.ts +203 -0
  78. package/src/shared/lib/code/editor/find.ts +224 -0
  79. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  80. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  81. package/src/shared/lib/code/editor/keymap.ts +151 -0
  82. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  83. package/src/shared/lib/code/editor/layers.ts +67 -0
  84. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  85. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  86. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  87. package/src/shared/lib/code/editor/lines.ts +83 -0
  88. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  89. package/src/shared/lib/code/editor/marks.ts +106 -0
  90. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  91. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  92. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  93. package/src/shared/lib/code/editor/palette.ts +92 -0
  94. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  95. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  96. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  97. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  98. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  99. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  100. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  101. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  102. package/src/shared/lib/ui/key-binding.ts +143 -0
  103. package/src/shared/lib/util/platform.test.ts +87 -0
  104. package/src/shared/lib/util/platform.ts +30 -0
  105. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  106. package/src/shared/lib/workspace/keymap.ts +30 -0
  107. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  108. package/src/shared/lib/workspace/right-panels.ts +8 -0
  109. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/todo.ts +108 -0
  112. package/dist/client/static/js/index-ranztes9.css +0 -1
  113. package/src/server/lib/lifecycle/restart.test.ts +0 -21
  114. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Everything a file-tree row does that is not rendering: the context menu's
8
+ * action dispatch, the create/rename/delete dialogs and their server calls.
9
+ *
10
+ * Extracted from the row component because it is a stateful cluster with one
11
+ * shared dependency (`/api/fs/action` and the fetcher that submits to it), and
12
+ * because the row is at the repository's line ceiling. The component keeps the
13
+ * markup and the handlers that need its own DOM node; this keeps the behavior.
14
+ */
15
+
16
+ import { useEffect, useRef, useState } from 'preact/hooks';
17
+ import type { FormEvent, RefObject } from 'preact/compat';
18
+ import { useFetcher } from '@/client/lib/router/fetcher';
19
+ import { toAbsolutePath } from '@/shared/lib/fs/paths';
20
+ import { copyToClipboard } from '@/client/hooks/ui/clipboard';
21
+
22
+ /** The shape `/api/fs/action` answers with, as far as a row reads it. */
23
+ interface FsActionResponse {
24
+ success?: boolean;
25
+ /** Present on the git-history payload, which the history modal reads. */
26
+ type?: string;
27
+ /** Relative path of a freshly created file. */
28
+ path?: string;
29
+ error?: string;
30
+ }
31
+
32
+ export interface FileActionTarget {
33
+ path: string;
34
+ name: string;
35
+ /** Absolute base dir of the listing, anchoring Copy Path. */
36
+ basePath?: string;
37
+ rootPath?: string;
38
+ repo?: string;
39
+ }
40
+
41
+ export interface FileActionDeps {
42
+ file: FileActionTarget;
43
+ /** Which diff to open, when the row is a changed file. */
44
+ diff?: { staged?: boolean; status?: string };
45
+ onOpenFile?: (file: { path: string; name: string; root?: string; repo?: string }) => void;
46
+ onActionComplete: () => void;
47
+ }
48
+
49
+ export interface FileActions {
50
+ contextMenu: { x: number; y: number } | null;
51
+ contextMenuRef: RefObject<HTMLDivElement>;
52
+ openContextMenu: (x: number, y: number) => void;
53
+ closeContextMenu: () => void;
54
+ showRenameModal: boolean;
55
+ showDeleteModal: boolean;
56
+ showHistoryModal: boolean;
57
+ showCreateModal: boolean;
58
+ /** The server's refusal for the create request, rendered inside its dialog. */
59
+ createError: string | null;
60
+ createName: string;
61
+ setCreateName: (value: string) => void;
62
+ renameValue: string;
63
+ setRenameValue: (value: string) => void;
64
+ /** True while a request is in flight; every dialog's buttons read it. */
65
+ busy: boolean;
66
+ /** The fetcher's own state/data, for the history modal. */
67
+ fetcherState: string;
68
+ fetcherData: FsActionResponse | undefined;
69
+ handleAction: (actionType: string) => void;
70
+ submitCreate: (e: FormEvent) => void;
71
+ submitRename: (e: FormEvent) => void;
72
+ submitDelete: () => void;
73
+ closeCreate: () => void;
74
+ closeDelete: () => void;
75
+ closeRename: () => void;
76
+ closeHistory: () => void;
77
+ }
78
+
79
+ export function useFileActions({ file, diff, onOpenFile, onActionComplete }: FileActionDeps): FileActions {
80
+ const actionFetcher = useFetcher<FsActionResponse>();
81
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
82
+ const contextMenuRef = useRef<HTMLDivElement>(null);
83
+ const [showRenameModal, setShowRenameModal] = useState(false);
84
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
85
+ const [showHistoryModal, setShowHistoryModal] = useState(false);
86
+ const [showCreateModal, setShowCreateModal] = useState(false);
87
+ const [createName, setCreateName] = useState('');
88
+ const [createError, setCreateError] = useState<string | null>(null);
89
+ const [renameValue, setRenameValue] = useState(file.path);
90
+ /** Which dialog the in-flight request belongs to, so its result is routed. */
91
+ const pendingActionRef = useRef<'create' | null>(null);
92
+
93
+ const repo = file.repo;
94
+ const rootPath = file.rootPath;
95
+
96
+ /**
97
+ * The payload already acted on.
98
+ *
99
+ * The fetcher keeps `data` until the next request, and the callbacks below
100
+ * (`onActionComplete` → the panel's refresh → a re-render with a fresh
101
+ * callback identity) would otherwise re-run this effect for the SAME
102
+ * response forever: an unguarded effect here is an infinite refresh loop that
103
+ * pins the tab the moment a file is created.
104
+ */
105
+ const handledRef = useRef<FsActionResponse | null>(null);
106
+
107
+ useEffect(() => {
108
+ if (actionFetcher.state !== 'idle' || !actionFetcher.data) return;
109
+ const data = actionFetcher.data;
110
+ if (handledRef.current === data) return;
111
+ handledRef.current = data;
112
+ // The fetcher parses the body whatever the status, so a refusal arrives as
113
+ // `data.error` rather than as a throw — which is what lets the create
114
+ // dialog stay open and put the reason beside the field.
115
+ if (pendingActionRef.current === 'create') {
116
+ pendingActionRef.current = null;
117
+ if (data.success && data.path) {
118
+ // The user asked for a file to work on, so it opens: closing the dialog
119
+ // and refreshing the tree alone would leave them hunting for the empty
120
+ // row they just created.
121
+ setShowCreateModal(false);
122
+ setCreateName('');
123
+ setCreateError(null);
124
+ onActionComplete();
125
+ onOpenFile?.({
126
+ path: data.path,
127
+ name: data.path.split('/').pop() || data.path,
128
+ root: rootPath,
129
+ repo: repo || '.',
130
+ });
131
+ } else {
132
+ setCreateError(data.error || 'Could not create the file');
133
+ }
134
+ return;
135
+ }
136
+ if (data.success && !data.type) {
137
+ setShowRenameModal(false);
138
+ setShowDeleteModal(false);
139
+ onActionComplete();
140
+ }
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, [actionFetcher.state, actionFetcher.data]);
143
+
144
+ const submitAction = (formData: FormData) => {
145
+ if (repo && repo !== '.') formData.append('repo', repo);
146
+ if (rootPath) formData.append('root', rootPath);
147
+ void actionFetcher.submit(formData, { method: 'post', action: '/api/fs/action' });
148
+ };
149
+
150
+ const openFile = () => {
151
+ onOpenFile?.({ path: file.path, name: file.name, root: rootPath, repo: repo || '.' });
152
+ };
153
+
154
+ const handleAction = (actionType: string) => {
155
+ setContextMenu(null);
156
+ if (actionType === 'view') {
157
+ openFile();
158
+ } else if (actionType === 'diff') {
159
+ window.dispatchEvent(new CustomEvent('omp:open-diff', {
160
+ detail: {
161
+ file: file.path,
162
+ staged: diff?.staged,
163
+ status: diff?.status || 'M',
164
+ repo: repo || '.',
165
+ root: rootPath,
166
+ },
167
+ }));
168
+ } else if (actionType === 'explorer') {
169
+ const fd = new FormData();
170
+ fd.append('actionType', 'open_explorer');
171
+ fd.append('path', file.path);
172
+ submitAction(fd);
173
+ } else if (actionType === 'copy_path') {
174
+ void copyToClipboard(toAbsolutePath(file.basePath, file.path));
175
+ } else if (actionType === 'copy_relative') {
176
+ void copyToClipboard(file.path);
177
+ } else if (actionType === 'history') {
178
+ setShowHistoryModal(true);
179
+ const fd = new FormData();
180
+ fd.append('actionType', 'git_history');
181
+ fd.append('path', file.path);
182
+ submitAction(fd);
183
+ } else if (actionType === 'rename') {
184
+ setRenameValue(file.path);
185
+ setShowRenameModal(true);
186
+ } else if (actionType === 'new_file') {
187
+ setCreateName('');
188
+ setCreateError(null);
189
+ setShowCreateModal(true);
190
+ } else if (actionType === 'delete') {
191
+ setShowDeleteModal(true);
192
+ }
193
+ };
194
+
195
+ const submitCreate = (e: FormEvent) => {
196
+ e.preventDefault();
197
+ const name = createName.trim();
198
+ if (!name) return;
199
+ setCreateError(null);
200
+ pendingActionRef.current = 'create';
201
+ const fd = new FormData();
202
+ fd.append('actionType', 'create');
203
+ fd.append('path', file.path);
204
+ fd.append('name', name);
205
+ submitAction(fd);
206
+ };
207
+
208
+ const submitRename = (e: FormEvent) => {
209
+ e.preventDefault();
210
+ const fd = new FormData();
211
+ fd.append('actionType', 'rename');
212
+ fd.append('path', file.path);
213
+ fd.append('newPath', renameValue);
214
+ submitAction(fd);
215
+ };
216
+
217
+ const submitDelete = () => {
218
+ const fd = new FormData();
219
+ fd.append('actionType', 'delete');
220
+ fd.append('path', file.path);
221
+ submitAction(fd);
222
+ };
223
+
224
+ return {
225
+ contextMenu,
226
+ contextMenuRef,
227
+ openContextMenu: (x, y) => setContextMenu({ x, y }),
228
+ closeContextMenu: () => setContextMenu(null),
229
+ showRenameModal,
230
+ showDeleteModal,
231
+ showHistoryModal,
232
+ showCreateModal,
233
+ createError,
234
+ createName,
235
+ setCreateName,
236
+ renameValue,
237
+ setRenameValue,
238
+ busy: actionFetcher.state !== 'idle',
239
+ fetcherState: actionFetcher.state,
240
+ fetcherData: actionFetcher.data,
241
+ handleAction,
242
+ submitCreate,
243
+ submitRename,
244
+ submitDelete,
245
+ closeCreate: () => setShowCreateModal(false),
246
+ closeDelete: () => setShowDeleteModal(false),
247
+ closeRename: () => setShowRenameModal(false),
248
+ closeHistory: () => setShowHistoryModal(false),
249
+ };
250
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Reads a session's live todo list for the right-panel view.
8
+ *
9
+ * The list belongs to omp and its only durable home is the session transcript,
10
+ * so this hook is a reader: it fetches `GET /api/omp/session-todos` and re-reads
11
+ * on two triggers —
12
+ *
13
+ * - a poll (`usePanelRefresh`, paused while hidden), which covers a list the
14
+ * agent changed while this client was not the one driving it;
15
+ * - `omp:session-updated`, which the chat dispatches at every turn boundary
16
+ * and therefore right after each `todo` tool call, so the panel follows a
17
+ * running agent without waiting out the poll.
18
+ *
19
+ * The event path is throttled: one run emits the signal several times around a
20
+ * single tool call, and the read is a disk parse. A read already in flight is
21
+ * not cancelled by a newer trigger — the server answers an unchanged session
22
+ * from a stat, so the extra request is cheap, and dropping the newer one would
23
+ * leave the panel showing the list from before the call that just finished.
24
+ */
25
+
26
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
27
+ import { useChamberEvent } from '@/client/hooks/ui/window-event';
28
+ import { usePanelRefresh } from '@/client/hooks/workspace/panel-refresh';
29
+ import { TODO_REFRESH_EVENT_THROTTLE_MS } from '@/shared/lib/workspace/refresh-cadence';
30
+ import type { SessionTodosPayload } from '@/shared/types/todo';
31
+
32
+ export interface SessionTodosState {
33
+ data: SessionTodosPayload | null;
34
+ isLoading: boolean;
35
+ /** Set when the last read failed; the previous payload is kept meanwhile. */
36
+ error: string | null;
37
+ reload: () => void;
38
+ }
39
+
40
+ export function useSessionTodos(sessionId: string | null, enabled: boolean): SessionTodosState {
41
+ const [data, setData] = useState<SessionTodosPayload | null>(null);
42
+ const [isLoading, setIsLoading] = useState(false);
43
+ const [error, setError] = useState<string | null>(null);
44
+
45
+ // Latest session id for the fetcher, so switching sessions does not have to
46
+ // rebuild the callback (and with it the poll interval) on every render.
47
+ const sessionIdRef = useRef(sessionId);
48
+ sessionIdRef.current = sessionId;
49
+ // Guards against a response for a session the user has already left being
50
+ // written into the panel that now shows a different one.
51
+ const requestRef = useRef(0);
52
+
53
+ const load = useCallback(() => {
54
+ const id = sessionIdRef.current;
55
+ if (!id) {
56
+ setData(null);
57
+ return;
58
+ }
59
+ const request = ++requestRef.current;
60
+ setIsLoading(true);
61
+ fetch(`/api/omp/session-todos?sessionId=${encodeURIComponent(id)}`)
62
+ .then((response) => {
63
+ if (!response.ok) throw new Error(`Todo request failed (${response.status})`);
64
+ return response.json() as Promise<SessionTodosPayload>;
65
+ })
66
+ .then((payload) => {
67
+ if (requestRef.current !== request) return;
68
+ setData(payload);
69
+ setError(null);
70
+ })
71
+ .catch((err: unknown) => {
72
+ if (requestRef.current !== request) return;
73
+ setError(err instanceof Error ? err.message : 'Failed to load todos');
74
+ })
75
+ .finally(() => {
76
+ if (requestRef.current === request) setIsLoading(false);
77
+ });
78
+ }, []);
79
+
80
+ // A session switch must not render the previous session's list under the new
81
+ // title: drop the payload and re-read immediately.
82
+ useEffect(() => {
83
+ setData(null);
84
+ setError(null);
85
+ if (sessionId) load();
86
+ }, [sessionId, load]);
87
+
88
+ usePanelRefresh(load, enabled);
89
+
90
+ const throttleRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
91
+ useChamberEvent('omp:session-updated', () => {
92
+ if (!enabled || throttleRef.current !== undefined) return;
93
+ throttleRef.current = setTimeout(() => {
94
+ throttleRef.current = undefined;
95
+ load();
96
+ }, TODO_REFRESH_EVENT_THROTTLE_MS);
97
+ });
98
+
99
+ useEffect(() => () => clearTimeout(throttleRef.current), []);
100
+
101
+ return { data, isLoading, error, reload: load };
102
+ }
@@ -246,6 +246,36 @@
246
246
  * The selector keys off the variant attribute, not a list of theme ids. */
247
247
  .shiki span { color: var(--shiki-light); }
248
248
  html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
249
+
250
+ /* Find matches, painted inside the token spans the highlighter emits. An
251
+ * inline <mark> colours a background without moving a glyph, which is what
252
+ * keeps the transparent <textarea> above aligned with this layer — a block or
253
+ * a padding would shift the text under the caret. `color: inherit` is required:
254
+ * a <mark> would otherwise take the UA's own black-on-yellow, ignoring the
255
+ * dual-theme token colour it sits inside.
256
+ *
257
+ * No `border-radius`, and the current match is marked with TOP/BOTTOM borders
258
+ * rather than an outline: a match spanning several tokens (`const alpha` is
259
+ * three) is emitted as one <mark> per token, so any per-fragment decoration
260
+ * that draws vertical edges — radius, outline, left/right border — paints a
261
+ * seam in the middle of the match. Horizontal edges join seamlessly. */
262
+ .shiki .find-match {
263
+ color: inherit;
264
+ background: color-mix(in srgb, var(--theme-warning) 32%, transparent);
265
+ }
266
+ /* An extra ⌘D selection: same geometry as a find match, different ink. The
267
+ * caret can only live in the primary range, so the two must not look alike —
268
+ * the user has to see where their keystrokes will land. */
269
+ .shiki .find-match[data-occurrence] {
270
+ background: color-mix(in srgb, var(--theme-info) 30%, transparent);
271
+ border-top: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
272
+ border-bottom: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
273
+ }
274
+ .shiki .find-match[data-find-current] {
275
+ background: color-mix(in srgb, var(--theme-warning) 55%, transparent);
276
+ border-top: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
277
+ border-bottom: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
278
+ }
249
279
  .prose-content .katex-display { overflow-x: auto; overflow-y: hidden; }
250
280
 
251
281
  /* Math placeholder: `marked.ts` emits a `.math-pending` span and KaTeX is
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdtemp, rm } from 'fs/promises';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+
11
+ import cssPlugin from '@/server/lib/bundler/css';
12
+
13
+ /**
14
+ * Build one stylesheet through the REAL plugin with the options the client
15
+ * build uses (`target: 'bun'`, `minify: true`), and return the emitted CSS.
16
+ *
17
+ * This is the end-to-end gate for the nesting bug: Bun's minifier re-nests a
18
+ * rule's nested at-rule as `@supports (…) { & { … } }` for a non-browser
19
+ * target, and Chrome then drops it whenever the parent selector names a
20
+ * pseudo-element. Asserting on the flattener's own output cannot catch that —
21
+ * only the bundle the browser is actually served can.
22
+ */
23
+ async function buildThroughPlugin(css: string): Promise<string> {
24
+ const dir = await mkdtemp(join(tmpdir(), 'ompchamber-css-'));
25
+ try {
26
+ await Bun.write(join(dir, 'probe.css'), css);
27
+ const result = await Bun.build({
28
+ entrypoints: [join(dir, 'probe.css')],
29
+ outdir: join(dir, 'out'),
30
+ target: 'bun',
31
+ minify: true,
32
+ plugins: [cssPlugin],
33
+ });
34
+ if (!result.success) throw new Error(result.logs.map((log) => log.message).join('\n'));
35
+ return await Bun.file(join(dir, 'out', 'probe.css')).text();
36
+ } finally {
37
+ await rm(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ describe('css plugin — nested at-rules reach the browser flattened', () => {
42
+ test('a placeholder colour refinement survives as a top-level @supports', async () => {
43
+ const out = await buildThroughPlugin(`.probe::placeholder {
44
+ color: var(--color-ink);
45
+ @supports (color: color-mix(in lab, red, red)) {
46
+ color: color-mix(in oklab, var(--color-ink) 40%, transparent);
47
+ }
48
+ }
49
+ `);
50
+
51
+ // No `&` anywhere: a nested rule under `::placeholder` is inert in Chrome,
52
+ // which leaves the rule's full-opacity fallback as the final colour.
53
+ expect(out).not.toMatch(/(?<!\\)&/);
54
+ expect(out).toContain('@supports');
55
+ expect(out).toContain('::placeholder');
56
+ expect(out).toContain('40%');
57
+ });
58
+
59
+ test('a scrollbar thumb keeps its translucent refinement', async () => {
60
+ const out = await buildThroughPlugin(`::-webkit-scrollbar-thumb {
61
+ background: var(--theme-ink);
62
+ @supports (color: color-mix(in lab, red, red)) {
63
+ background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
64
+ }
65
+ }
66
+ `);
67
+
68
+ expect(out).not.toMatch(/(?<!\\)&/);
69
+ expect(out).toContain('20%');
70
+ });
71
+
72
+ test('declarations written after the at-rule keep their order in the bundle', async () => {
73
+ const out = await buildThroughPlugin(`.probe {
74
+ background: var(--theme-ink);
75
+ @supports (color: color-mix(in lab, red, red)) {
76
+ background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
77
+ }
78
+ border-radius: 3px;
79
+ }
80
+ `);
81
+
82
+ // The trailing declaration must not be hoisted above the at-rule: that
83
+ // would let the at-rule's `background` win over a later one.
84
+ expect(out.indexOf('border-radius')).toBeGreaterThan(out.indexOf('@supports'));
85
+ expect(out).not.toMatch(/(?<!\\)&/);
86
+ });
87
+ });
@@ -42,6 +42,15 @@
42
42
  * never does), so there is no "build finished" moment to flush at. Writing
43
43
  * each source's faces as its `onLoad` returns is order-independent and works
44
44
  * identically under both entry points.
45
+ *
46
+ * 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
47
+ * AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
48
+ * nesting it is handed and, for `target: 'bun'`, re-nests a rule's nested
49
+ * at-rule as `@supports (…) { & { … } }` — and Chrome drops a nested rule
50
+ * whose parent selector names a pseudo-element. Tailwind emits its colour
51
+ * fallbacks in exactly that shape, so the whole app's placeholder and
52
+ * scrollbar colours silently collapsed to their fallback. See that module for
53
+ * the measurement; this one only has to run it.
45
54
  */
46
55
 
47
56
  import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'path';
@@ -51,6 +60,7 @@ import type { BunPlugin } from 'bun';
51
60
 
52
61
  import { FONT_FACE_DIR } from '@/server/lib/assets/font-css.server';
53
62
  import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
63
+ import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
54
64
 
55
65
  /** A `url(...)` whose target is a font file. */
56
66
  const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
@@ -100,8 +110,13 @@ const plugin: BunPlugin = {
100
110
  build.onLoad({ filter: /\.css$/ }, async (args) => {
101
111
  const source = await Bun.file(args.path).text();
102
112
  // Tailwind first: it parses `@import "tailwindcss"` and `@theme`, and the
103
- // urls it emits are the ones the extraction below has to recognise.
104
- const expanded = await postcss([tailwind()]).process(source, { from: args.path });
113
+ // urls it emits are the ones the extraction below has to recognise. The
114
+ // flattener runs after it, because the nesting it removes is Tailwind's
115
+ // own output — and before the font extraction, so a hoisted face is still
116
+ // recognised as a face.
117
+ const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
118
+ from: args.path,
119
+ });
105
120
  const { rest, faces } = splitFontFaces(expanded.css, dirname(args.path));
106
121
  if (faces.length > 0) {
107
122
  const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import postcss from 'postcss';
8
+
9
+ import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
10
+
11
+ /** Run the flattener and return the CSS it produced. */
12
+ async function flatten(css: string): Promise<string> {
13
+ const result = await postcss([flattenNestedAtRules()]).process(css, { from: undefined });
14
+ return result.css;
15
+ }
16
+
17
+ /** Every `&` left outside an escaped class name — an unflattened nesting marker. */
18
+ const unescapedAmpersands = (css: string): number => (css.match(/(?<!\\)&/g) ?? []).length;
19
+
20
+ describe('flattenNestedAtRules', () => {
21
+ test('hoists a nested @supports out of a pseudo-element rule', async () => {
22
+ // Tailwind's shape: a fallback declaration, then the color-mix refinement.
23
+ const out = await flatten(
24
+ `.placeholder-ink\\/40::placeholder {
25
+ color: var(--color-ink);
26
+ @supports (color: color-mix(in lab, red, red)) {
27
+ color: color-mix(in oklab, var(--color-ink) 40%, transparent);
28
+ }
29
+ }`,
30
+ );
31
+
32
+ // The at-rule is at the top level and carries the resolved selector itself:
33
+ // a nested rule whose parent names a pseudo-element is dropped by Chrome.
34
+ expect(unescapedAmpersands(out)).toBe(0);
35
+ expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
36
+ const root = postcss.parse(out);
37
+ const atRules = root.nodes.filter((n) => n.type === 'atrule');
38
+ expect(atRules).toHaveLength(1);
39
+ expect(atRules[0].name).toBe('supports');
40
+ expect(atRules[0].toString()).toContain('.placeholder-ink\\/40::placeholder');
41
+ expect(atRules[0].toString()).toContain('color-mix');
42
+ });
43
+
44
+ test('keeps declaration order, so the cascade is unchanged', async () => {
45
+ const out = await flatten(
46
+ `.a { color: red; @supports (display: block) { color: blue; } border-radius: 3px; }`,
47
+ );
48
+
49
+ const root = postcss.parse(out);
50
+ const order = root.nodes.map((node) =>
51
+ node.toString().replace(/\s+/g, ' '),
52
+ );
53
+ // `border-radius` was written AFTER the at-rule and must still follow it —
54
+ // moving it before would let the at-rule's `color` be overridden.
55
+ expect(order).toHaveLength(3);
56
+ expect(order[0]).toBe('.a { color: red; }');
57
+ expect(order[1]).toContain('@supports');
58
+ expect(order[2]).toBe('.a { border-radius: 3px; }');
59
+ });
60
+
61
+ test('hoists inside the layer that contained the rule, not to the root', async () => {
62
+ const out = await flatten(
63
+ `@layer base { .b::placeholder { color: red; @supports (display: block) { color: blue; } } }`,
64
+ );
65
+ expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
66
+ expect(unescapedAmpersands(out)).toBe(0);
67
+ // The refinement stays inside `@layer base` — hoisting it past the layer
68
+ // boundary would change which layer wins.
69
+ const layer = postcss.parse(out).nodes.find((n) => n.type === 'atrule' && n.name === 'layer');
70
+ expect(layer?.toString()).toContain('@supports');
71
+ });
72
+
73
+ test('resolves an explicit & and leaves other rules alone', async () => {
74
+ const out = await flatten(
75
+ `.card { color: red; @supports (display: block) { & { color: blue; } } }
76
+ .plain { color: green; }`,
77
+ );
78
+ expect(out).not.toContain('&');
79
+ const root = postcss.parse(out);
80
+ const supports = root.nodes.find((n) => n.type === 'atrule');
81
+ expect(supports?.toString()).toContain('.card');
82
+ expect(root.toString()).toContain('.plain');
83
+ });
84
+
85
+ test('flattens a rule nested inside the hoisted at-rule on the next pass', async () => {
86
+ const out = await flatten(
87
+ `.outer { @supports (display: block) { .inner { color: blue; } } }`,
88
+ );
89
+ expect(unescapedAmpersands(out)).toBe(0);
90
+ // The inner rule's selector is resolved against its parent, not left bare.
91
+ expect(postcss.parse(out).toString()).toContain('.outer .inner');
92
+ });
93
+
94
+ test('leaves an already-flat stylesheet byte-identical', async () => {
95
+ const flat = `.a { color: red; }
96
+ @supports (display: block) { .a { color: blue; } }
97
+ @layer base { .b { color: green; } }`;
98
+ expect(await flatten(flat)).toBe(flat);
99
+ });
100
+ });