@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.
Files changed (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.identity.test.tsx +78 -0
  4. package/src/App.regression.test.tsx +94 -0
  5. package/src/App.share-choice.test.tsx +105 -0
  6. package/src/App.test.tsx +11 -8
  7. package/src/App.toast.test.tsx +3 -1
  8. package/src/App.tsx +648 -221
  9. package/src/App.visual.test.tsx +57 -0
  10. package/src/App.workbench.test.tsx +41 -22
  11. package/src/app-screens.tsx +181 -123
  12. package/src/app-view-model.ts +1 -0
  13. package/src/components/AnnotatableFileView.tsx +9 -1
  14. package/src/components/AnnotationCards.tsx +22 -4
  15. package/src/components/AppShell.brand.test.tsx +1 -1
  16. package/src/components/AppShell.tsx +14 -1
  17. package/src/components/ChangesColumn.tsx +40 -12
  18. package/src/components/ChangesFileTree.test.tsx +61 -0
  19. package/src/components/ConfirmDialog.tsx +8 -1
  20. package/src/components/DiffContentView.tsx +12 -2
  21. package/src/components/DiscussionMarkerRail.tsx +2 -2
  22. package/src/components/EditorGrid.tsx +84 -26
  23. package/src/components/GridTabContent.tsx +12 -1
  24. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  25. package/src/components/ManageAccessDialog.test.tsx +94 -0
  26. package/src/components/ManageAccessDialog.tsx +120 -0
  27. package/src/components/PanelColumn.tsx +3 -0
  28. package/src/components/ProjectTreeView.test.tsx +39 -12
  29. package/src/components/ProjectTreeView.tsx +38 -10
  30. package/src/components/PromptDialog.test.tsx +61 -0
  31. package/src/components/PromptDialog.tsx +16 -3
  32. package/src/components/RootOverlay.stories.tsx +1 -0
  33. package/src/components/RootOverlay.tsx +19 -6
  34. package/src/components/SettingsDialog.stories.tsx +3 -3
  35. package/src/components/SettingsDialog.tsx +3 -4
  36. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  37. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  38. package/src/components/ShareChoiceDialog.tsx +82 -0
  39. package/src/components/ThreadFooter.test.tsx +12 -7
  40. package/src/components/ThreadFooter.tsx +18 -4
  41. package/src/components/ThreadTree.tsx +79 -36
  42. package/src/components/ThreadView.slash.test.tsx +36 -0
  43. package/src/components/ThreadView.tsx +12 -2
  44. package/src/components/WelcomePlayground.tsx +2 -0
  45. package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
  46. package/src/components/menu-control.stories.tsx +35 -0
  47. package/src/components/menu-control.tsx +43 -0
  48. package/src/components/primitives/Dialog.test.tsx +37 -0
  49. package/src/components/primitives/Dialog.tsx +5 -0
  50. package/src/components/primitives/icons.ts +1 -1
  51. package/src/components/quick-actions-editor.stories.tsx +2 -0
  52. package/src/components/quick-actions-editor.test.tsx +35 -1
  53. package/src/components/quick-actions-editor.tsx +23 -22
  54. package/src/components/session-tree.test.ts +3 -3
  55. package/src/components/session-tree.ts +1 -1
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +45 -1
  58. package/src/diff-intraline.ts +26 -5
  59. package/src/github-identity.test.ts +58 -0
  60. package/src/github-identity.ts +41 -0
  61. package/src/intent-dispatch.test.ts +17 -2
  62. package/src/intent-dispatch.ts +7 -1
  63. package/src/keymap.ts +1 -0
  64. package/src/perf/otlp-trace.test.ts +116 -0
  65. package/src/perf/otlp-trace.ts +130 -0
  66. package/src/perf/perf-report.ts +17 -0
  67. package/src/perf/perf-timings.test.ts +18 -0
  68. package/src/perf/perf-timings.ts +38 -0
  69. package/src/run.ts +22 -2
  70. package/src/share.ts +10 -2
  71. package/src/thread-chords.test.ts +33 -1
  72. package/src/thread-chords.ts +114 -29
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.live-diff.test.ts +1 -0
  75. package/src/thread-controller.share.test.ts +36 -2
  76. package/src/thread-controller.tree.test.ts +1 -0
  77. package/src/thread-controller.ts +18 -3
  78. package/src/use-annotation-surface.tsx +32 -2
  79. package/src/use-frame-measure.ts +4 -1
  80. package/src/use-settings-dialog.tsx +33 -7
  81. package/src/visual/contrast.test.ts +35 -0
  82. 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 (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
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),
@@ -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
- // thread's diff, showing changes from the wrong repo; drop it when the session moved on
653
- if (this.snapshot.session !== session) return diff.files;
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 key={annotation.id} annotation={annotation} tokens={tokens} />
741
+ <CommentRow
742
+ key={annotation.id}
743
+ annotation={annotation}
744
+ tokens={tokens}
745
+ authorLabel={resolveAuthorLabel?.(annotation)}
746
+ />
717
747
  ),
718
748
  };
719
749
  });
@@ -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 (when wide)",
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
- onToggleExpand={(index) => {
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 setActionsExpandedIndex(settingsNav.rowIndex);
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
+ }