@cueloop/client 0.1.0-alpha.53 → 0.1.0-alpha.56
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 +5 -5
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.tsx +14 -5
- package/src/prototype-browser.ts +17 -1
- package/src/run.ts +6 -0
- package/src/serve.ts +9 -1
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +24 -6
- package/src/theme.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.56",
|
|
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.
|
|
30
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.56",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.56",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.56",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.56",
|
|
33
33
|
"@opentui/core": "^0.5.8",
|
|
34
34
|
"@opentui/keymap": "0.5.8",
|
|
35
35
|
"@opentui/react": "^0.5.8",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The App renders in a light virtual terminal: passing appearance="light" threads the light branded theme through the provider without breaking the mount (the char frame is color-blind, so this covers propagation and wiring, not the pixel colors). */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import { isolateUserConfig, settle, waitForText } from "./test-support";
|
|
13
|
+
|
|
14
|
+
const PLAN = "# Sync Plan\n\n## Steps\n\n- flush the queue\n";
|
|
15
|
+
|
|
16
|
+
let home: string;
|
|
17
|
+
let server: DaemonServer;
|
|
18
|
+
let session: ReviewSession;
|
|
19
|
+
let restoreUserConfig: () => void;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-appearance-"));
|
|
23
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
24
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
25
|
+
server.start();
|
|
26
|
+
session = server.core.sessionCreate({
|
|
27
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
28
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Sync Plan", planPath: "plan.md" } },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
restoreUserConfig();
|
|
33
|
+
server.stop();
|
|
34
|
+
rmSync(home, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("appearance", () => {
|
|
38
|
+
test("renders the review on a light terminal", async () => {
|
|
39
|
+
// Arrange + Act
|
|
40
|
+
const setup = await testRender(<App home={home} sessionId={session.id} appearance="light" />, {
|
|
41
|
+
width: 120,
|
|
42
|
+
height: 32,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
await waitForText(setup, "cueloop");
|
|
47
|
+
await waitForText(setup, "Sync Plan");
|
|
48
|
+
await settle(setup);
|
|
49
|
+
expect(setup.captureCharFrame()).toContain("flush the queue");
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -22,7 +22,7 @@ import type { Clock, MouseEvent } from "@opentui/core";
|
|
|
22
22
|
import { type VerdictKind } from "@cueloop/schema";
|
|
23
23
|
import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
|
|
24
24
|
import { noteForFile, viewedCount } from "./walk";
|
|
25
|
-
import {
|
|
25
|
+
import { dimmedTheme } from "./theme";
|
|
26
26
|
import {
|
|
27
27
|
DEFAULT_KEYS,
|
|
28
28
|
DEFAULT_QUICK_ACTIONS,
|
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
DEFAULT_THEME_NAME,
|
|
40
40
|
THEME_LABELS,
|
|
41
41
|
THEME_NAMES,
|
|
42
|
+
themeForName,
|
|
43
|
+
type Appearance,
|
|
42
44
|
type ThemeName,
|
|
43
45
|
} from "./theme-presets";
|
|
44
46
|
import type { Theme } from "./theme";
|
|
@@ -112,6 +114,12 @@ export interface AppProps {
|
|
|
112
114
|
* name prompt so their notes attribute to a name, not a fingerprint.
|
|
113
115
|
*/
|
|
114
116
|
selfAuthor?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The terminal's background appearance (from an OSC query at startup). The
|
|
119
|
+
* branded transparent theme darkens its text on a light terminal so it is not
|
|
120
|
+
* light-on-light. Defaults to dark - the historical assumption.
|
|
121
|
+
*/
|
|
122
|
+
appearance?: Appearance;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
export function App({
|
|
@@ -123,6 +131,7 @@ export function App({
|
|
|
123
131
|
openClient,
|
|
124
132
|
role = "owner",
|
|
125
133
|
selfAuthor,
|
|
134
|
+
appearance = "dark",
|
|
126
135
|
}: AppProps): React.ReactNode {
|
|
127
136
|
// Observer stays fully read-only; a collaborator writes annotations but not
|
|
128
137
|
// the plan or a verdict. `observer` is what the controller and every write
|
|
@@ -200,7 +209,7 @@ export function App({
|
|
|
200
209
|
// keymap from layered config; the loaded theme swaps the provider value
|
|
201
210
|
const keysRef = useRef(DEFAULT_KEYS);
|
|
202
211
|
const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
|
|
203
|
-
const [theme, setTheme] = useState(
|
|
212
|
+
const [theme, setTheme] = useState(() => themeForName(DEFAULT_THEME_NAME, appearance));
|
|
204
213
|
const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
|
|
205
214
|
const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
|
|
206
215
|
const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
|
|
@@ -211,7 +220,7 @@ export function App({
|
|
|
211
220
|
const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
212
221
|
keysRef.current = config.keys;
|
|
213
222
|
keyBindings.setKeys(config.keys);
|
|
214
|
-
setTheme(config.theme);
|
|
223
|
+
setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
|
|
215
224
|
setThemeName(config.ui.theme);
|
|
216
225
|
setThemeOverrides(config.themeOverrides);
|
|
217
226
|
setReviewMode(config.ui.reviewState);
|
|
@@ -221,7 +230,7 @@ export function App({
|
|
|
221
230
|
setQuickActions(config.actions);
|
|
222
231
|
setAutoClose(config.ui.autoClose);
|
|
223
232
|
controller.applyConfig(config);
|
|
224
|
-
}, [session?.workspace.repoRoot, controller, keyBindings]);
|
|
233
|
+
}, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
|
|
225
234
|
useEffect(
|
|
226
235
|
() => () => {
|
|
227
236
|
if (pulseTimer.current) clearTimeout(pulseTimer.current);
|
|
@@ -541,7 +550,7 @@ export function App({
|
|
|
541
550
|
} else if (rowKey === "theme") {
|
|
542
551
|
const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
|
|
543
552
|
setThemeName(next);
|
|
544
|
-
setTheme(composeTheme(next, themeOverrides));
|
|
553
|
+
setTheme(composeTheme(next, themeOverrides, appearance));
|
|
545
554
|
persistTheme(next);
|
|
546
555
|
}
|
|
547
556
|
};
|
package/src/prototype-browser.ts
CHANGED
|
@@ -140,6 +140,16 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
140
140
|
// `load` waits for images and styles but skips networkidle0's fixed 500ms
|
|
141
141
|
// idle window, which a static local file would otherwise always pay
|
|
142
142
|
await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
|
|
143
|
+
// Render the mockup on the terminal's own surface rather than on an opaque
|
|
144
|
+
// page: strip the root background so it never paints a square box, and the
|
|
145
|
+
// capture (taken with alpha, below) lets the active terminal theme show
|
|
146
|
+
// through around the mockup's own components - the prototype emerges into
|
|
147
|
+
// whatever theme is running instead of sitting in a grey card.
|
|
148
|
+
await page.evaluate(() => {
|
|
149
|
+
for (const element of [document.documentElement, document.body]) {
|
|
150
|
+
element?.style.setProperty("background", "transparent", "important");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
143
153
|
} catch (error) {
|
|
144
154
|
await page.close().catch(() => undefined);
|
|
145
155
|
throw error;
|
|
@@ -148,7 +158,13 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
148
158
|
return {
|
|
149
159
|
viewport: options.viewport,
|
|
150
160
|
async screenshot() {
|
|
151
|
-
|
|
161
|
+
// omitBackground keeps the alpha from the stripped page background, so the
|
|
162
|
+
// terminal composites the mockup over its own theme surface.
|
|
163
|
+
const buffer = await page.screenshot({
|
|
164
|
+
type: "png",
|
|
165
|
+
encoding: "binary",
|
|
166
|
+
omitBackground: true,
|
|
167
|
+
});
|
|
152
168
|
return new Uint8Array(buffer as Buffer);
|
|
153
169
|
},
|
|
154
170
|
async elementAt(cssX, cssY) {
|
package/src/run.ts
CHANGED
|
@@ -10,15 +10,21 @@ export interface RunClientOptions {
|
|
|
10
10
|
home?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** OSC background query budget: brief so a terminal that never answers falls back to dark. */
|
|
14
|
+
const THEME_QUERY_TIMEOUT_MS = 200;
|
|
15
|
+
|
|
13
16
|
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
14
17
|
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
15
18
|
// so the renderer's native selection is the drag driver
|
|
16
19
|
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
20
|
+
const appearance =
|
|
21
|
+
(await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
17
22
|
return new Promise<number>((resolve) => {
|
|
18
23
|
createRoot(renderer).render(
|
|
19
24
|
React.createElement(App, {
|
|
20
25
|
home: options.home,
|
|
21
26
|
sessionId: options.sessionId,
|
|
27
|
+
appearance,
|
|
22
28
|
onExit: (code: number) => {
|
|
23
29
|
renderer.destroy();
|
|
24
30
|
resolve(code);
|
package/src/serve.ts
CHANGED
|
@@ -22,6 +22,9 @@ import { createServer } from "@opentui/ssh";
|
|
|
22
22
|
import { cueloopHome } from "@cueloop/daemon";
|
|
23
23
|
import { App } from "./App";
|
|
24
24
|
|
|
25
|
+
/** OSC background query budget: brief so a terminal that never answers falls back to dark. */
|
|
26
|
+
const THEME_QUERY_TIMEOUT_MS = 200;
|
|
27
|
+
|
|
25
28
|
export interface ServeOptions {
|
|
26
29
|
/** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
|
|
27
30
|
port?: number;
|
|
@@ -56,13 +59,18 @@ export async function serveClient(options: ServeOptions = {}): Promise<ServeHand
|
|
|
56
59
|
startupBanner: options.banner ?? true,
|
|
57
60
|
idleTimeout: "2h",
|
|
58
61
|
onError: options.onError,
|
|
59
|
-
}).serve((session) => {
|
|
62
|
+
}).serve(async (session) => {
|
|
63
|
+
// the observer's terminal reports its own background, so a light-terminal
|
|
64
|
+
// watcher gets the readable variant too
|
|
65
|
+
const appearance =
|
|
66
|
+
(await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
60
67
|
const root = createRoot(session.renderer);
|
|
61
68
|
root.render(
|
|
62
69
|
React.createElement(App, {
|
|
63
70
|
home,
|
|
64
71
|
sessionId: options.sessionId,
|
|
65
72
|
readOnly: true,
|
|
73
|
+
appearance,
|
|
66
74
|
// q disconnects only this observer, never the server
|
|
67
75
|
onExit: () => session.end(),
|
|
68
76
|
}),
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The branded transparent theme adapts to the terminal background - a light terminal gets dark text instead of light-on-light - while the opaque palette presets ignore appearance. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { DARK, LIGHT } from "./theme";
|
|
5
|
+
import { composeTheme, themeForName } from "./theme-presets";
|
|
6
|
+
|
|
7
|
+
describe("themeForName appearance", () => {
|
|
8
|
+
test("the branded cueloop theme picks the light variant on a light terminal", () => {
|
|
9
|
+
// Assert
|
|
10
|
+
expect(themeForName("cueloop", "dark")).toBe(DARK);
|
|
11
|
+
expect(themeForName("cueloop", "light")).toBe(LIGHT);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("defaults to the dark variant when appearance is unspecified", () => {
|
|
15
|
+
// Assert
|
|
16
|
+
expect(themeForName("cueloop")).toBe(DARK);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("an unknown name falls back to the branded theme, still appearance-aware", () => {
|
|
20
|
+
// Assert
|
|
21
|
+
expect(themeForName("nope", "light")).toBe(LIGHT);
|
|
22
|
+
expect(themeForName("nope", "dark")).toBe(DARK);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("an explicit opaque preset ignores appearance (it paints its own background)", () => {
|
|
26
|
+
// Assert
|
|
27
|
+
expect(themeForName("nord", "light")).toBe(themeForName("nord", "dark"));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("the branded variants", () => {
|
|
32
|
+
test("both keep the transparent background but the light variant darkens its text", () => {
|
|
33
|
+
// Assert
|
|
34
|
+
expect(DARK.background).toBe("transparent");
|
|
35
|
+
expect(LIGHT.background).toBe("transparent");
|
|
36
|
+
expect(LIGHT.text).toBe("#1c1d24");
|
|
37
|
+
expect(LIGHT.text).not.toBe(DARK.text);
|
|
38
|
+
expect(LIGHT.elevated).not.toBe(DARK.elevated);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("composeTheme", () => {
|
|
43
|
+
test("resolves the appearance base, then layers per-token overrides on top", () => {
|
|
44
|
+
// Act
|
|
45
|
+
const themed = composeTheme("cueloop", { accent: "#123456" }, "light");
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(themed.text).toBe(LIGHT.text);
|
|
49
|
+
expect(themed.accent).toBe("#123456");
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/theme-presets.ts
CHANGED
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
* by that palette's own convention.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { DARK, type Theme } from "./theme";
|
|
13
|
+
import { DARK, LIGHT, type Theme } from "./theme";
|
|
14
|
+
|
|
15
|
+
/** Terminal background appearance, reported by the terminal (OSC) or assumed dark. */
|
|
16
|
+
export type Appearance = "dark" | "light";
|
|
17
|
+
|
|
18
|
+
/** The branded transparent theme's variant for the detected terminal background. */
|
|
19
|
+
function brandedTheme(appearance: Appearance): Theme {
|
|
20
|
+
return appearance === "light" ? LIGHT : DARK;
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
/** Selectable theme name; the persisted `[ui] theme` value and cycle key. */
|
|
16
24
|
export type ThemeName =
|
|
@@ -152,14 +160,24 @@ export const THEME_LABELS: Record<ThemeName, string> = {
|
|
|
152
160
|
nord: "Nord",
|
|
153
161
|
};
|
|
154
162
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Resolve a persisted theme name to its token set. The branded "cueloop" theme
|
|
165
|
+
* (and any unknown name that falls back to it) adapts to the terminal
|
|
166
|
+
* background; the explicit palette presets paint their own opaque background, so
|
|
167
|
+
* they read the same on a light or dark terminal and ignore `appearance`.
|
|
168
|
+
*/
|
|
169
|
+
export function themeForName(name: string, appearance: Appearance = "dark"): Theme {
|
|
170
|
+
if (name === "cueloop") return brandedTheme(appearance);
|
|
171
|
+
return THEME_PRESETS[name as ThemeName] ?? brandedTheme(appearance);
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
|
|
161
|
-
export function composeTheme(
|
|
162
|
-
|
|
175
|
+
export function composeTheme(
|
|
176
|
+
name: string,
|
|
177
|
+
overrides: Partial<Theme>,
|
|
178
|
+
appearance: Appearance = "dark",
|
|
179
|
+
): Theme {
|
|
180
|
+
return { ...themeForName(name, appearance), ...overrides };
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
/** True when the name is a known preset (guards a persisted `[ui] theme` value). */
|
package/src/theme.ts
CHANGED
|
@@ -65,3 +65,29 @@ export const DARK: Theme = {
|
|
|
65
65
|
deletedForeground: "#ff6b6b",
|
|
66
66
|
backdrop: "transparent",
|
|
67
67
|
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The branded transparent theme for a LIGHT terminal: same unpainted
|
|
71
|
+
* background, but every reading color darkens so text is legible on a white
|
|
72
|
+
* terminal instead of light-on-light. Chosen when the terminal reports a light
|
|
73
|
+
* background; the dark variant above is the default and the fallback.
|
|
74
|
+
*/
|
|
75
|
+
export const LIGHT: Theme = {
|
|
76
|
+
background: "transparent",
|
|
77
|
+
panel: "transparent",
|
|
78
|
+
elevated: "#ececf2",
|
|
79
|
+
border: "#c7c7d2",
|
|
80
|
+
text: "#1c1d24",
|
|
81
|
+
textMuted: "#4b5162",
|
|
82
|
+
textDim: "#868c9c",
|
|
83
|
+
accent: "#b5495b",
|
|
84
|
+
accentInk: "#ffffff",
|
|
85
|
+
green: "#2f8a3e",
|
|
86
|
+
red: "#c0392b",
|
|
87
|
+
blue: "#2f6fb0",
|
|
88
|
+
cursorBackground: "#e6e2ea",
|
|
89
|
+
markCommentBackground: "#f2e2e8",
|
|
90
|
+
insertedForeground: "#2f8a3e",
|
|
91
|
+
deletedForeground: "#c0392b",
|
|
92
|
+
backdrop: "transparent",
|
|
93
|
+
};
|