@cueloop/client 0.1.0-alpha.47 → 0.1.0-alpha.49

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.47",
3
+ "version": "0.1.0-alpha.49",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,10 +26,10 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.47",
30
- "@cueloop/extension-api": "0.1.0-alpha.47",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.47",
32
- "@cueloop/schema": "0.1.0-alpha.47",
29
+ "@cueloop/daemon": "0.1.0-alpha.49",
30
+ "@cueloop/extension-api": "0.1.0-alpha.49",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.49",
32
+ "@cueloop/schema": "0.1.0-alpha.49",
33
33
  "@opentui/core": "^0.5.2",
34
34
  "@opentui/keymap": "0.5.2",
35
35
  "@opentui/react": "^0.5.2",
@@ -21,6 +21,7 @@ import {
21
21
  typeText,
22
22
  waitForState,
23
23
  waitForText,
24
+ waitForTextGone,
24
25
  } from "./test-support";
25
26
 
26
27
  const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
@@ -33,6 +34,7 @@ const FAKE_ELEMENT: PrototypeElement = {
33
34
  };
34
35
 
35
36
  let rendered = false;
37
+ let scrollDeltas: number[] = [];
36
38
  const fakeRenderer: PrototypeRenderer = {
37
39
  viewport: { width: 1280, height: 800 },
38
40
  screenshot: async () => {
@@ -41,6 +43,10 @@ const fakeRenderer: PrototypeRenderer = {
41
43
  },
42
44
  elementAt: async () => FAKE_ELEMENT,
43
45
  highlight: async () => undefined,
46
+ scrollBy: async (deltaY) => {
47
+ scrollDeltas.push(deltaY);
48
+ return true;
49
+ },
44
50
  close: async () => undefined,
45
51
  };
46
52
 
@@ -63,6 +69,7 @@ beforeEach(() => {
63
69
  },
64
70
  });
65
71
  rendered = false;
72
+ scrollDeltas = [];
66
73
  setPrototypeRendererFactory(async () => fakeRenderer);
67
74
  });
68
75
  afterEach(() => {
@@ -122,4 +129,27 @@ describe("prototype review", () => {
122
129
  expect(frame).toContain("tighten the padding");
123
130
  expect(frame).toContain("COMMENT");
124
131
  });
132
+
133
+ test("wheel over the preview scrolls the page down, then up, and clears a selection", async () => {
134
+ // Arrange
135
+ const setup = await renderApp();
136
+ await waitForState(setup, () => rendered);
137
+ await settle(setup);
138
+
139
+ // open a selection, then scroll: the scroll drops it (it would drift once
140
+ // the page moves under it)
141
+ await setup.mockMouse.click(6, 6);
142
+ await waitForText(setup, "comment");
143
+ await setup.mockMouse.scroll(6, 6, "down");
144
+ await waitForState(setup, () => scrollDeltas.length === 1);
145
+ await waitForTextGone(setup, "comment");
146
+
147
+ // Act - scroll back up
148
+ await setup.mockMouse.scroll(6, 6, "up");
149
+ await waitForState(setup, () => scrollDeltas.length === 2);
150
+
151
+ // Assert - down was a positive delta, up was negative
152
+ expect(scrollDeltas[0]!).toBeGreaterThan(0);
153
+ expect(scrollDeltas[1]!).toBeLessThan(0);
154
+ });
125
155
  });
package/src/App.tsx CHANGED
@@ -1040,6 +1040,7 @@ export function App({
1040
1040
  controller.annotatePrototype(element.selector, element.quote, body)
1041
1041
  }
1042
1042
  onComposingChange={setPrototypeComposing}
1043
+ hidden={menuOpen || menuDialog !== null}
1043
1044
  />
1044
1045
  ) : isDiff ? (
1045
1046
  // the sheet dims to reading-quiet colors while the wizard has focus
@@ -40,6 +40,9 @@ export interface PrototypeSheetProps {
40
40
  /** Signals when the inline compose owns the keyboard, so the app suppresses
41
41
  * its global keymap and the compose textarea receives the typed note. */
42
42
  onComposingChange?: (active: boolean) => void;
43
+ /** True while an app overlay (menu, settings) covers the sheet; the image is
44
+ * removed so those overlays are not shown through the graphics layer. */
45
+ hidden?: boolean;
43
46
  theme?: Theme;
44
47
  }
45
48
 
@@ -47,6 +50,8 @@ type SheetStatus = "loading" | "ready" | "unsupported" | "error";
47
50
 
48
51
  const POPOVER_ROWS = 3;
49
52
  const PROTOTYPE_IMAGE_ID = 811;
53
+ /** Page pixels scrolled per wheel notch. */
54
+ const SCROLL_STEP = 240;
50
55
  // The capture viewport width in CSS pixels; its height matches the region's
51
56
  // cell aspect so the rendered image fills the box instead of letterboxing. The
52
57
  // aspect comes from the terminal's reported cell pixels, falling back to a
@@ -77,6 +82,7 @@ export function PrototypeSheet({
77
82
  canComment,
78
83
  onCommentElement,
79
84
  onComposingChange,
85
+ hidden = false,
80
86
  theme,
81
87
  }: PrototypeSheetProps): React.ReactNode {
82
88
  const tokens = useComponentTheme(theme);
@@ -89,6 +95,10 @@ export function PrototypeSheet({
89
95
  const transmittedRef = useRef<Uint8Array | null>(null);
90
96
  const paintRef = useRef<() => void>(() => undefined);
91
97
  const launchRef = useRef<() => void>(() => undefined);
98
+ const hiddenRef = useRef(hidden);
99
+ useEffect(() => {
100
+ hiddenRef.current = hidden;
101
+ });
92
102
 
93
103
  const [status, setStatus] = useState<SheetStatus>("loading");
94
104
  const [errorMessage, setErrorMessage] = useState<string>("");
@@ -117,6 +127,15 @@ export function PrototypeSheet({
117
127
  typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
118
128
  const paint = (): void => {
119
129
  if (!write) return;
130
+ // an app overlay is covering the sheet: pull the image so it does not show
131
+ // through the overlay's cells, and re-transmit once the overlay closes
132
+ if (hiddenRef.current) {
133
+ if (transmittedRef.current !== null) {
134
+ deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
135
+ transmittedRef.current = null;
136
+ }
137
+ return;
138
+ }
120
139
  const region = regionOf(regionRef.current);
121
140
  const png = pngRef.current;
122
141
  if (!region || !png) return;
@@ -238,17 +257,32 @@ export function PrototypeSheet({
238
257
  });
239
258
  };
240
259
 
260
+ const onRegionScroll = (event: { scroll?: { direction: string } }): void => {
261
+ const browser = browserRef.current;
262
+ if (status !== "ready" || !browser) return;
263
+ const delta = event.scroll?.direction === "up" ? -SCROLL_STEP : SCROLL_STEP;
264
+ // a selection would drift once the page scrolls under it, so drop it
265
+ clearSelection();
266
+ void browser
267
+ .scrollBy(delta)
268
+ .then(() => refresh())
269
+ .catch(() => undefined);
270
+ };
271
+
241
272
  const commit = (body: string): void => {
242
273
  if (!canComment) return;
243
274
  if (selected && body.trim()) onCommentElement(selected, body.trim());
244
275
  clearSelection();
245
276
  };
246
277
 
278
+ // the overlay carries an opaque fill so the popover and compose card read as
279
+ // solid cards over the image, matching the plan surface
247
280
  const overlayStyle = {
248
281
  position: "absolute" as const,
249
282
  left: overlayCell?.left ?? 0,
250
283
  top: Math.max(0, overlayCell?.top ?? 0),
251
284
  flexDirection: "column" as const,
285
+ backgroundColor: tokens.elevated,
252
286
  };
253
287
 
254
288
  return (
@@ -268,6 +302,7 @@ export function PrototypeSheet({
268
302
  style={{ flexGrow: 1 }}
269
303
  onSizeChange={launch}
270
304
  onMouseDown={onRegionMouseDown}
305
+ onMouseScroll={onRegionScroll}
271
306
  />
272
307
  {status !== "ready" ? (
273
308
  <box style={{ position: "absolute", left: 2, top: 1 }}>
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { placeKittyImage, transmitKittyImage } from "./kitty-image";
3
+
4
+ const REGION = { column: 2, row: 3, columns: 40, rows: 12 };
5
+
6
+ describe("kitty image stacking", () => {
7
+ test("places the prototype below cells with painted backgrounds", () => {
8
+ // Arrange
9
+ const writes: string[] = [];
10
+
11
+ // Act
12
+ transmitKittyImage((chunk) => writes.push(chunk), new Uint8Array([1]), REGION, 811);
13
+ placeKittyImage((chunk) => writes.push(chunk), REGION, 811);
14
+
15
+ // Assert
16
+ expect(writes).toHaveLength(2);
17
+ expect(writes[0]).toContain("z=-1073741825");
18
+ expect(writes[1]).toContain("z=-1073741825");
19
+ });
20
+ });
@@ -19,6 +19,9 @@ export type OrderedWrite = (chunk: string) => void;
19
19
 
20
20
  const ESCAPE_STRING_TERMINATOR = "\x1b\\";
21
21
  const CHUNK_BYTES = 4096;
22
+ // The protocol reserves values below INT32_MIN/2 for images that must sit
23
+ // beneath cells with painted backgrounds, such as the marker popover.
24
+ const IMAGE_Z_INDEX = -1_073_741_825;
22
25
 
23
26
  /** Save the cursor, run the placement at the region's corner, restore it. */
24
27
  function atRegion(region: CellRegion, body: string): string {
@@ -43,9 +46,7 @@ export function transmitKittyImage(
43
46
  ): void {
44
47
  if (region.columns < 1 || region.rows < 1) return;
45
48
  const base64 = Buffer.from(png).toString("base64");
46
- // z<0 places the image beneath the text layer, so the marker popover and
47
- // compose card (ordinary cells) still paint over it
48
- const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`;
49
+ const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`;
49
50
  let sequence = "";
50
51
  for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
51
52
  const chunk = base64.slice(offset, offset + CHUNK_BYTES);
@@ -62,7 +63,7 @@ export function transmitKittyImage(
62
63
  export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
63
64
  if (region.columns < 1 || region.rows < 1) return;
64
65
  const command = graphicsCommand(
65
- `a=p,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`,
66
+ `a=p,i=${imageId},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`,
66
67
  "",
67
68
  );
68
69
  write(atRegion(region, command));
@@ -31,6 +31,8 @@ export interface PrototypeRenderer {
31
31
  screenshot(): Promise<Uint8Array>;
32
32
  elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
33
33
  highlight(selector: string | null): Promise<void>;
34
+ /** Scroll the page by a pixel delta; returns whether the scroll position moved. */
35
+ scrollBy(deltaY: number): Promise<boolean>;
34
36
  close(): Promise<void>;
35
37
  }
36
38
 
@@ -130,6 +132,9 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
130
132
  async highlight(selector) {
131
133
  await page.evaluate(highlightScript, selector);
132
134
  },
135
+ async scrollBy(deltaY) {
136
+ return (await page.evaluate(scrollByScript, deltaY)) as boolean;
137
+ },
133
138
  async close() {
134
139
  await browser.close();
135
140
  },
@@ -201,6 +206,12 @@ function elementAtScript(x: number, y: number): unknown {
201
206
  return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
202
207
  }
203
208
 
209
+ function scrollByScript(deltaY: number): boolean {
210
+ const before = window.scrollY;
211
+ window.scrollBy(0, deltaY);
212
+ return window.scrollY !== before;
213
+ }
214
+
204
215
  function highlightScript(selector: string | null): void {
205
216
  const ATTRIBUTE = "data-cueloop-selected";
206
217
  const STYLE_ID = "cueloop-highlight-style";