@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.77
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.focus.test.ts +147 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.test.tsx +11 -8
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +648 -221
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +181 -123
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +12 -2
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +12 -1
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +12 -2
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +45 -1
- package/src/diff-intraline.ts +26 -5
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/share.ts +10 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +18 -3
- package/src/use-annotation-surface.tsx +32 -2
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +33 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
2
|
import { ManualClock } from "@opentui/core/testing";
|
|
3
|
-
import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
|
|
3
|
+
import { SCHEMA_VERSION, type Annotation, type ShareAccess, type Thread } from "@cueloop/schema";
|
|
4
4
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
5
5
|
import {
|
|
6
6
|
createReviewController,
|
|
@@ -14,7 +14,11 @@ const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", cop
|
|
|
14
14
|
let remote: Thread;
|
|
15
15
|
const pullShare = mock(async () => remote);
|
|
16
16
|
const pushShare = mock(
|
|
17
|
-
async (
|
|
17
|
+
async (
|
|
18
|
+
_shareId: string,
|
|
19
|
+
_annotations: Array<Omit<Annotation, "createdAt">>,
|
|
20
|
+
_access?: ShareAccess,
|
|
21
|
+
) => {},
|
|
18
22
|
);
|
|
19
23
|
|
|
20
24
|
const shareTransport: ShareTransport = {
|
|
@@ -73,6 +77,7 @@ function fakeClient(session: Thread): FakeSessionClient {
|
|
|
73
77
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
78
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
79
|
sessionCurate: unimplemented("sessionCurate"),
|
|
80
|
+
sessionSetAccess: mock(async () => session),
|
|
76
81
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
82
|
sessionBranch: unimplemented("sessionBranch"),
|
|
78
83
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -149,6 +154,35 @@ describe("share", () => {
|
|
|
149
154
|
});
|
|
150
155
|
});
|
|
151
156
|
|
|
157
|
+
describe("setShareAccess", () => {
|
|
158
|
+
test("pushes the allowlist up to the gateway when the plan is already shared", async () => {
|
|
159
|
+
// Arrange
|
|
160
|
+
pushShare.mockClear();
|
|
161
|
+
const { controller } = await connectedController(sessionFixture({ shareId: "p_abc123xy" }));
|
|
162
|
+
|
|
163
|
+
// Act
|
|
164
|
+
controller.setShareAccess(["octocat"]);
|
|
165
|
+
await tick();
|
|
166
|
+
|
|
167
|
+
// Assert - the existing link must enforce the new access, so it rides the push
|
|
168
|
+
expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
|
|
169
|
+
expect(pushShare.mock.calls[0]?.[2]).toEqual({ githubLogins: ["octocat"] });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("does not push when the plan was never shared", async () => {
|
|
173
|
+
// Arrange
|
|
174
|
+
pushShare.mockClear();
|
|
175
|
+
const { controller } = await connectedController(sessionFixture());
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
controller.setShareAccess(["octocat"]);
|
|
179
|
+
await tick();
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
152
186
|
describe("pullShared", () => {
|
|
153
187
|
test("unions collaborator notes and identities in when the plan was shared", async () => {
|
|
154
188
|
// Arrange
|
|
@@ -82,6 +82,7 @@ function fakeClient(session: Thread) {
|
|
|
82
82
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
83
83
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
84
84
|
sessionCurate: unimplemented("sessionCurate"),
|
|
85
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
85
86
|
sessionNavigate: mock(pending),
|
|
86
87
|
sessionBranch: mock(pending),
|
|
87
88
|
sessionSwitch: mock(pending),
|
package/src/thread-controller.ts
CHANGED
|
@@ -309,6 +309,8 @@ export interface ReviewController {
|
|
|
309
309
|
submit(verdict: VerdictKind, summary: string): void;
|
|
310
310
|
/** Publish the current session as a share; the ssh line lands on the clipboard. */
|
|
311
311
|
share(): void;
|
|
312
|
+
/** Replace the private-share allowlist of GitHub logins for the current session. Owner only. */
|
|
313
|
+
setShareAccess(githubLogins: string[]): void;
|
|
312
314
|
/** The session tree as rows for the rail's Tree tab, cached per session identity. */
|
|
313
315
|
treeRows(): TreeRow[];
|
|
314
316
|
/**
|
|
@@ -648,9 +650,10 @@ class Controller implements ReviewController {
|
|
|
648
650
|
// eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
|
|
649
651
|
if (this.client?.repoDiff !== undefined) {
|
|
650
652
|
const diff = await this.client.repoDiff(this.sidebarRepoRoot());
|
|
651
|
-
// a thread switch during the request would let this response overwrite the new
|
|
652
|
-
//
|
|
653
|
-
|
|
653
|
+
// a thread switch during the request would let this response overwrite the new thread's diff,
|
|
654
|
+
// showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
|
|
655
|
+
// re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
|
|
656
|
+
if (this.snapshot.session?.id !== session?.id) return diff.files;
|
|
654
657
|
this.liveDiff = diff;
|
|
655
658
|
// rows() derive from the fresh patch; re-render so an open diff tab repaints
|
|
656
659
|
this.update({});
|
|
@@ -881,6 +884,18 @@ class Controller implements ReviewController {
|
|
|
881
884
|
this.applyOptimistic(expected, this.client!.sessionCurate(session.id, rejections));
|
|
882
885
|
}
|
|
883
886
|
|
|
887
|
+
setShareAccess(githubLogins: string[]): void {
|
|
888
|
+
const session = this.snapshot.session;
|
|
889
|
+
|
|
890
|
+
if (!session) return;
|
|
891
|
+
const access = { githubLogins };
|
|
892
|
+
const expected: Thread = { ...session, access };
|
|
893
|
+
|
|
894
|
+
this.applyOptimistic(expected, this.client!.sessionSetAccess(session.id, githubLogins));
|
|
895
|
+
// an already-shared link must enforce the new access, so push it up to the gateway blob
|
|
896
|
+
if (session.shareId) void this.shareTransport.push(session.shareId, [], access).catch(() => {});
|
|
897
|
+
}
|
|
898
|
+
|
|
884
899
|
rejectedRows(): Set<number> {
|
|
885
900
|
this.ensureDerived();
|
|
886
901
|
if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
16
16
|
import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
|
|
17
17
|
import { useKeyboard } from "@opentui/react";
|
|
18
|
-
import type { Thread } from "@cueloop/schema";
|
|
18
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
19
19
|
import type { Mark } from "./view-plan";
|
|
20
20
|
import type { QuickAction } from "./config";
|
|
21
21
|
import type { Theme } from "./theme";
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "./thread-selection";
|
|
38
38
|
import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
|
|
39
39
|
import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
|
|
40
|
+
import { matchesLeader } from "./thread-chords";
|
|
40
41
|
import {
|
|
41
42
|
activeSlashToken,
|
|
42
43
|
isStandaloneSlashQuery,
|
|
@@ -103,6 +104,10 @@ export interface AnnotationSurfaceOptions {
|
|
|
103
104
|
onAnnotate: (span: TextSpan, body: string) => void;
|
|
104
105
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
105
106
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
107
|
+
/** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
|
|
108
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
109
|
+
leaderCombos?: readonly string[];
|
|
110
|
+
onLeaderCommand?: (key: KeyEvent) => void;
|
|
106
111
|
onExit: () => void;
|
|
107
112
|
}
|
|
108
113
|
|
|
@@ -176,6 +181,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
176
181
|
onAnnotate,
|
|
177
182
|
onReply,
|
|
178
183
|
onUpdateAnnotation,
|
|
184
|
+
resolveAuthorLabel,
|
|
185
|
+
leaderCombos,
|
|
186
|
+
onLeaderCommand,
|
|
179
187
|
onExit,
|
|
180
188
|
} = options;
|
|
181
189
|
const palette = annotationPaletteFor(tokens);
|
|
@@ -190,6 +198,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
190
198
|
return { head: start, anchor: start };
|
|
191
199
|
});
|
|
192
200
|
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
201
|
+
const leaderPending = useRef(false);
|
|
193
202
|
|
|
194
203
|
useEffect(() => {
|
|
195
204
|
onComposingChange?.(compose !== null);
|
|
@@ -657,10 +666,26 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
657
666
|
|
|
658
667
|
if (activeCompose) return handleComposeKey(key, activeCompose);
|
|
659
668
|
if (key.name === "escape") {
|
|
669
|
+
if (leaderPending.current) {
|
|
670
|
+
leaderPending.current = false;
|
|
671
|
+
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
660
674
|
if (focusedDiscussion !== null) return setFocusedDiscussion(null);
|
|
661
675
|
|
|
662
676
|
return collapseCaret();
|
|
663
677
|
}
|
|
678
|
+
if (leaderPending.current) {
|
|
679
|
+
leaderPending.current = false;
|
|
680
|
+
onLeaderCommand?.(key);
|
|
681
|
+
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (leaderCombos && matchesLeader(key, leaderCombos)) {
|
|
685
|
+
leaderPending.current = true;
|
|
686
|
+
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
664
689
|
if (handleDiscussionVerb(key)) return;
|
|
665
690
|
if (handleCaretKey(key)) return;
|
|
666
691
|
startTyping(key);
|
|
@@ -713,7 +738,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
|
|
|
713
738
|
node: editingHere ? (
|
|
714
739
|
composerNode
|
|
715
740
|
) : (
|
|
716
|
-
<CommentRow
|
|
741
|
+
<CommentRow
|
|
742
|
+
key={annotation.id}
|
|
743
|
+
annotation={annotation}
|
|
744
|
+
tokens={tokens}
|
|
745
|
+
authorLabel={resolveAuthorLabel?.(annotation)}
|
|
746
|
+
/>
|
|
717
747
|
),
|
|
718
748
|
};
|
|
719
749
|
});
|
package/src/use-frame-measure.ts
CHANGED
|
@@ -12,11 +12,14 @@ export function useFrameMeasure<T>(
|
|
|
12
12
|
read: () => T,
|
|
13
13
|
isEqual: (left: T, right: T) => boolean,
|
|
14
14
|
initial: T,
|
|
15
|
+
active = true,
|
|
15
16
|
): T {
|
|
16
17
|
const renderer = useRenderer();
|
|
17
18
|
const [value, setValue] = useState(initial);
|
|
18
19
|
|
|
19
20
|
useEffect(() => {
|
|
21
|
+
// a measurement only needed while a popover is open subscribes no per-frame listener when closed
|
|
22
|
+
if (!active) return;
|
|
20
23
|
const measure = (): void => {
|
|
21
24
|
const next = read();
|
|
22
25
|
|
|
@@ -32,7 +35,7 @@ export function useFrameMeasure<T>(
|
|
|
32
35
|
renderer?.off("resize", measure);
|
|
33
36
|
};
|
|
34
37
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
-
}, [renderer]);
|
|
38
|
+
}, [renderer, active]);
|
|
36
39
|
|
|
37
40
|
return value;
|
|
38
41
|
}
|
|
@@ -83,6 +83,10 @@ export function useSettingsDialog(params: {
|
|
|
83
83
|
quickActions: QuickAction[];
|
|
84
84
|
setQuickActions: Dispatch<SetStateAction<QuickAction[]>>;
|
|
85
85
|
setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
|
|
86
|
+
identityName?: string;
|
|
87
|
+
identityProvider: "typed" | "github";
|
|
88
|
+
onSyncGithubIdentity: () => void;
|
|
89
|
+
onRenameDisplayName: () => void;
|
|
86
90
|
}): SettingsDialogModel {
|
|
87
91
|
const {
|
|
88
92
|
theme,
|
|
@@ -98,6 +102,10 @@ export function useSettingsDialog(params: {
|
|
|
98
102
|
quickActions,
|
|
99
103
|
setQuickActions,
|
|
100
104
|
setMenuDialog,
|
|
105
|
+
identityName,
|
|
106
|
+
identityProvider,
|
|
107
|
+
onSyncGithubIdentity,
|
|
108
|
+
onRenameDisplayName,
|
|
101
109
|
} = params;
|
|
102
110
|
|
|
103
111
|
const [settingsNav, setSettingsNav] = useState<SettingsNav>({
|
|
@@ -106,6 +114,7 @@ export function useSettingsDialog(params: {
|
|
|
106
114
|
zone: "body",
|
|
107
115
|
});
|
|
108
116
|
const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
|
|
117
|
+
const [actionsExpandedField, setActionsExpandedField] = useState<"prompt" | "metadata">("prompt");
|
|
109
118
|
|
|
110
119
|
const commitActions = (next: QuickAction[]): void => {
|
|
111
120
|
setQuickActions(next);
|
|
@@ -141,7 +150,6 @@ export function useSettingsDialog(params: {
|
|
|
141
150
|
{
|
|
142
151
|
id: "general",
|
|
143
152
|
name: "General",
|
|
144
|
-
description: "submission behaviour",
|
|
145
153
|
rows: [
|
|
146
154
|
{
|
|
147
155
|
key: "autoClose",
|
|
@@ -151,16 +159,24 @@ export function useSettingsDialog(params: {
|
|
|
151
159
|
},
|
|
152
160
|
{
|
|
153
161
|
key: "diffView",
|
|
154
|
-
label: "Diff view
|
|
162
|
+
label: "Diff view",
|
|
155
163
|
kind: "cycle",
|
|
156
164
|
options: ["Split", "Stacked"],
|
|
157
165
|
},
|
|
158
166
|
],
|
|
159
167
|
},
|
|
168
|
+
{
|
|
169
|
+
id: "account",
|
|
170
|
+
name: "Account",
|
|
171
|
+
rows: [
|
|
172
|
+
{ key: "displayName", label: "Display name", kind: "text" },
|
|
173
|
+
{ key: "identitySource", label: "Source", kind: "text" },
|
|
174
|
+
{ key: "syncGithub", label: "Sync from GitHub", kind: "text" },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
160
177
|
{
|
|
161
178
|
id: "appearance",
|
|
162
179
|
name: "Appearance",
|
|
163
|
-
description: "the color theme",
|
|
164
180
|
rows: [
|
|
165
181
|
{
|
|
166
182
|
key: "theme",
|
|
@@ -173,15 +189,16 @@ export function useSettingsDialog(params: {
|
|
|
173
189
|
{
|
|
174
190
|
id: "actions",
|
|
175
191
|
name: "Actions",
|
|
176
|
-
description: "quick-action comments",
|
|
177
192
|
rows: [],
|
|
178
193
|
customBody: (
|
|
179
194
|
<QuickActionsEditor
|
|
180
195
|
actions={quickActions}
|
|
181
196
|
selectedIndex={settingsNav.categoryId === "actions" ? settingsNav.rowIndex : -1}
|
|
182
197
|
expandedIndex={actionsExpandedIndex}
|
|
183
|
-
|
|
198
|
+
expandedField={actionsExpandedField}
|
|
199
|
+
onToggleExpand={(index, field) => {
|
|
184
200
|
setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: index }));
|
|
201
|
+
setActionsExpandedField(field);
|
|
185
202
|
setActionsExpandedIndex((current) => (current === index ? null : index));
|
|
186
203
|
}}
|
|
187
204
|
onEditPrompt={editActionPrompt}
|
|
@@ -195,7 +212,6 @@ export function useSettingsDialog(params: {
|
|
|
195
212
|
{
|
|
196
213
|
id: "keybinds",
|
|
197
214
|
name: "Keybinds",
|
|
198
|
-
description: "keyboard reference",
|
|
199
215
|
rows: [],
|
|
200
216
|
},
|
|
201
217
|
];
|
|
@@ -203,6 +219,9 @@ export function useSettingsDialog(params: {
|
|
|
203
219
|
autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
|
|
204
220
|
diffView: diffView === "split" ? "Split" : "Stacked",
|
|
205
221
|
theme: THEME_LABELS[themeName],
|
|
222
|
+
displayName: identityName ?? "-- not set --",
|
|
223
|
+
identitySource: identityProvider === "github" ? "GitHub" : "typed",
|
|
224
|
+
syncGithub: "enter to sync",
|
|
206
225
|
};
|
|
207
226
|
const cycleSetting = (rowKey: string): void => {
|
|
208
227
|
if (rowKey === "autoClose") {
|
|
@@ -221,6 +240,10 @@ export function useSettingsDialog(params: {
|
|
|
221
240
|
setThemeName(next);
|
|
222
241
|
setTheme(composeTheme(next, themeOverrides, appearance));
|
|
223
242
|
persistTheme(next);
|
|
243
|
+
} else if (rowKey === "syncGithub") {
|
|
244
|
+
onSyncGithubIdentity();
|
|
245
|
+
} else if (rowKey === "displayName") {
|
|
246
|
+
onRenameDisplayName();
|
|
224
247
|
}
|
|
225
248
|
};
|
|
226
249
|
|
|
@@ -258,7 +281,10 @@ export function useSettingsDialog(params: {
|
|
|
258
281
|
if (moved) return void setSettingsNav(moved);
|
|
259
282
|
if (ACTIVATE_KEYS.has(name)) {
|
|
260
283
|
if (settingsNav.rowIndex === quickActions.length) addAction();
|
|
261
|
-
else
|
|
284
|
+
else {
|
|
285
|
+
setActionsExpandedField("prompt");
|
|
286
|
+
setActionsExpandedIndex(settingsNav.rowIndex);
|
|
287
|
+
}
|
|
262
288
|
}
|
|
263
289
|
|
|
264
290
|
return;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { themeForName } from "../theme-presets";
|
|
3
|
+
import { contrastRatio, relativeLuminance, rgbColorFromHex } from "./contrast";
|
|
4
|
+
|
|
5
|
+
test("relativeLuminance ranks black below white", () => {
|
|
6
|
+
expect(relativeLuminance([0, 0, 0])).toBe(0);
|
|
7
|
+
expect(relativeLuminance([255, 255, 255])).toBeCloseTo(1, 5);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("contrastRatio is symmetric and peaks at black on white", () => {
|
|
11
|
+
expect(contrastRatio([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 0);
|
|
12
|
+
expect(contrastRatio([255, 255, 255], [0, 0, 0])).toBeCloseTo(21, 0);
|
|
13
|
+
expect(contrastRatio([100, 100, 100], [100, 100, 100])).toBeCloseTo(1, 5);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// The branded theme is terminal-through, so text sits on a real terminal background, not pure black or
|
|
17
|
+
// white; gate each theme against a representative one so a low-contrast token cannot hide behind an extreme.
|
|
18
|
+
const TERMINAL_BACKGROUND = {
|
|
19
|
+
dark: rgbColorFromHex("#1e1e2e"),
|
|
20
|
+
light: rgbColorFromHex("#f0f0f0"),
|
|
21
|
+
} as const;
|
|
22
|
+
const MIN_NORMAL_TEXT_CONTRAST = 4.5;
|
|
23
|
+
|
|
24
|
+
for (const mode of ["dark", "light"] as const) {
|
|
25
|
+
test(`${mode} theme keeps text, muted text, and accent legible on its terminal`, () => {
|
|
26
|
+
const theme = themeForName("cueloop", mode);
|
|
27
|
+
const background = TERMINAL_BACKGROUND[mode];
|
|
28
|
+
|
|
29
|
+
for (const token of [theme.text, theme.textMuted, theme.accent]) {
|
|
30
|
+
expect(contrastRatio(rgbColorFromHex(token), background)).toBeGreaterThanOrEqual(
|
|
31
|
+
MIN_NORMAL_TEXT_CONTRAST,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** WCAG relative luminance and contrast ratio for the visual-regression accessibility gate. */
|
|
2
|
+
|
|
3
|
+
export type RgbColor = readonly [number, number, number];
|
|
4
|
+
|
|
5
|
+
/** Parse a `#rrggbb` hexadecimal colour into an sRGB triple (channels 0-255). */
|
|
6
|
+
export function rgbColorFromHex(hex: string): RgbColor {
|
|
7
|
+
const value = hex.replace("#", "");
|
|
8
|
+
|
|
9
|
+
return [
|
|
10
|
+
Number.parseInt(value.slice(0, 2), 16),
|
|
11
|
+
Number.parseInt(value.slice(2, 4), 16),
|
|
12
|
+
Number.parseInt(value.slice(4, 6), 16),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** WCAG relative luminance of an sRGB colour, 0 (black) to 1 (white). */
|
|
17
|
+
export function relativeLuminance(rgb: RgbColor): number {
|
|
18
|
+
const linear = rgb.map((channel) => {
|
|
19
|
+
const ratio = channel / 255;
|
|
20
|
+
|
|
21
|
+
return ratio <= 0.03928 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return 0.2126 * linear[0]! + 0.7152 * linear[1]! + 0.0722 * linear[2]!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** WCAG contrast ratio between two sRGB colours, 1 (identical) to 21 (black on white). */
|
|
28
|
+
export function contrastRatio(foreground: RgbColor, background: RgbColor): number {
|
|
29
|
+
const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background));
|
|
30
|
+
const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background));
|
|
31
|
+
|
|
32
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
33
|
+
}
|