@ksp-gonogo/kerbcast-react 1.6.3 → 1.8.0

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 (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +19 -0
  3. package/dist/CameraFeed.d.ts +63 -19
  4. package/dist/CameraFeed.js +329 -202
  5. package/dist/FeedActionBar.d.ts +133 -0
  6. package/dist/FeedActionBar.js +225 -0
  7. package/dist/KerbalFaceFeed.d.ts +82 -0
  8. package/dist/KerbalFaceFeed.js +111 -0
  9. package/dist/RecordingsTray.d.ts +37 -0
  10. package/dist/RecordingsTray.js +279 -0
  11. package/dist/context.d.ts +27 -1
  12. package/dist/context.js +68 -2
  13. package/dist/hooks/useRecordings.d.ts +35 -0
  14. package/dist/hooks/useRecordings.js +33 -0
  15. package/dist/hooks/useReportDisplaySize.d.ts +23 -0
  16. package/dist/hooks/useReportDisplaySize.js +71 -0
  17. package/dist/index.d.ts +12 -2
  18. package/dist/index.js +13 -1
  19. package/dist/menuPositioning.d.ts +20 -0
  20. package/dist/menuPositioning.js +98 -0
  21. package/dist/timing.d.ts +4 -0
  22. package/dist/timing.js +19 -0
  23. package/package.json +2 -2
  24. package/src/CameraFeed.test.tsx +719 -10
  25. package/src/CameraFeed.tsx +586 -334
  26. package/src/FeedActionBar.test.ts +196 -0
  27. package/src/FeedActionBar.test.tsx +215 -0
  28. package/src/FeedActionBar.tsx +391 -0
  29. package/src/KerbalFaceFeed.test.tsx +203 -0
  30. package/src/KerbalFaceFeed.tsx +214 -0
  31. package/src/RecordingsTray.test.tsx +288 -0
  32. package/src/RecordingsTray.tsx +475 -0
  33. package/src/context.test.tsx +94 -0
  34. package/src/context.tsx +98 -1
  35. package/src/hooks/useRecordings.sharedState.test.tsx +117 -0
  36. package/src/hooks/useRecordings.test.tsx +217 -0
  37. package/src/hooks/useRecordings.ts +110 -0
  38. package/src/hooks/useReportDisplaySize.test.tsx +166 -0
  39. package/src/hooks/useReportDisplaySize.ts +91 -0
  40. package/src/index.ts +27 -1
  41. package/src/menuPositioning.ts +126 -0
  42. package/src/timing.test.ts +34 -0
  43. package/src/timing.ts +21 -0
@@ -0,0 +1,391 @@
1
+ /**
2
+ * FeedActionBar: the shared top-right action row rendered by BOTH
3
+ * `CameraFeed` and `KerbalFaceFeed`, so a single overflow engine serves every
4
+ * feed surface (part cams, crew faces, and any future host).
5
+ *
6
+ * Partition rule (issue #6 UI spec): stateful toggles (their at-rest state
7
+ * must stay visible, e.g. auto-track, the coming REC) are PRIMARY and always
8
+ * inline. Non-stateful actions (quality, PiP, fullscreen, plain consumer
9
+ * actions) are overflow-eligible: they collapse into a ⋮ menu ONLY once the
10
+ * row is genuinely crowded (total enabled >= 4 AND >= 2 non-stateful
11
+ * eligible): a single item never collapses, since that would cost a click
12
+ * to reach the one control that exists. Order is stable (an explicit `order`
13
+ * wins, otherwise entries keep the relative position their caller gave them)
14
+ * so chips don't reflow as buttons enable/disable around them.
15
+ *
16
+ * Close/remove is a THIRD category: `pinnedTrailing`. It is never
17
+ * overflow-eligible, never counted toward the overflow threshold, and always
18
+ * renders last (to the right of the ⋮ trigger itself), so dismissing a
19
+ * feed always stays a single, persistent, rightmost click. Bar order is:
20
+ * [stateful primaries] … [⋮ overflow, non-stateful] [pinned trailing close].
21
+ *
22
+ * Deliberately minimal: an entry is just (id, label, stateful?, order?,
23
+ * pinnedTrailing?, render). The interactive node itself (icon, aria
24
+ * attributes, onClick, any submenu it opens) is built by the caller
25
+ * (CameraFeed / KerbalFaceFeed) exactly as before; this component only
26
+ * decides WHERE it goes.
27
+ */
28
+
29
+ import { Fragment, useId } from "react";
30
+ import { createPortal } from "react-dom";
31
+ import styled from "styled-components";
32
+ import { usePortalMenu } from "./menuPositioning";
33
+
34
+ /**
35
+ * A consumer-supplied action rendered into a feed's action bar. Lets a host
36
+ * page (e.g. the sidecar's spotlight toggle) inject controls without the
37
+ * library knowing what they do.
38
+ */
39
+ export interface FeedAction {
40
+ /** Stable identity for the React key. */
41
+ id: string;
42
+ /** Accessible label; used for aria-label, the native tooltip, and the overflow-menu row's visible text. */
43
+ label: string;
44
+ /** Icon node, sized by the action bar (~14px). */
45
+ icon: React.ReactNode;
46
+ /** Toggle state: renders the button highlighted and sets aria-pressed. */
47
+ active?: boolean;
48
+ onClick: () => void;
49
+ /**
50
+ * Whether this action is a stateful toggle: stays PRIMARY / always
51
+ * inline so its at-rest state is visible, never collapses into the ⋮
52
+ * overflow. Defaults to `active !== undefined`. Override when a
53
+ * tri-state/menu-driven action's "active" isn't a plain boolean, or to force
54
+ * a boolean toggle to be overflow-eligible anyway.
55
+ */
56
+ stateful?: boolean;
57
+ /**
58
+ * Stable sort key for the action bar (lower sorts first). Actions without
59
+ * an explicit order keep their natural (array) position; this exists so a
60
+ * consumer can pin an action to a specific slot instead. Chips never
61
+ * reflow as buttons enable/disable; this is what keeps that stable.
62
+ */
63
+ order?: number;
64
+ /**
65
+ * Marks this as the bar's pinned-trailing (close/remove) slot: it is NEVER
66
+ * overflow-eligible, is excluded entirely from the overflow-threshold count
67
+ * (both the total and the non-stateful-eligible count), and always renders
68
+ * last (to the right of the ⋮ trigger), so dismissing a feed is always a
69
+ * single, persistent, rightmost click. `CameraFeed`'s `trailingActions`
70
+ * prop sets this automatically; a `KerbalFaceFeed`/`actions`-only consumer
71
+ * sets it explicitly on its close action.
72
+ */
73
+ pinnedTrailing?: boolean;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Partition logic (pure, unit-tested independently of any rendering)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const OVERFLOW_MIN_TOTAL = 4;
81
+ const OVERFLOW_MIN_NON_STATEFUL = 2;
82
+
83
+ interface Partitionable {
84
+ stateful?: boolean;
85
+ order?: number;
86
+ pinnedTrailing?: boolean;
87
+ }
88
+
89
+ /**
90
+ * Sort a list of action-bar entries into (primary, overflow, pinnedTrailing).
91
+ * `entries` must already be filtered down to the currently-enabled set:
92
+ * this function has no notion of "disabled", only "present".
93
+ *
94
+ * `pinnedTrailing` entries (close/remove) are pulled out FIRST and excluded
95
+ * entirely from the overflow-threshold count. Both the total and the
96
+ * non-stateful-eligible count run only over the remaining entries, then
97
+ * always returned as their own group, to be rendered last (see
98
+ * `FeedActionBar`, which places them after the ⋮ trigger).
99
+ */
100
+ export function partitionActionBarEntries<T extends Partitionable>(
101
+ entries: readonly T[],
102
+ ): { primary: T[]; overflow: T[]; pinnedTrailing: T[] } {
103
+ const sorted = [...entries].sort(
104
+ (a, b) => (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY),
105
+ );
106
+ const pinnedTrailing = sorted.filter((e) => e.pinnedTrailing);
107
+ const overflowable = sorted.filter((e) => !e.pinnedTrailing);
108
+ const nonStateful = overflowable.filter((e) => !e.stateful);
109
+ const overflowActive =
110
+ overflowable.length >= OVERFLOW_MIN_TOTAL && nonStateful.length >= OVERFLOW_MIN_NON_STATEFUL;
111
+
112
+ if (!overflowActive) {
113
+ return { primary: overflowable, overflow: [], pinnedTrailing };
114
+ }
115
+ return {
116
+ primary: overflowable.filter((e) => e.stateful),
117
+ overflow: nonStateful,
118
+ pinnedTrailing,
119
+ };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Entry shape + the FeedAction -> entry adapter
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * One slot in the bar. `render` produces the actual interactive element
128
+ * (unchanged whether it lands inline or inside the overflow menu) so callers
129
+ * keep full control of refs / aria attributes / submenus; FeedActionBar only
130
+ * decides placement and, in the overflow menu, adds the visible label.
131
+ */
132
+ export interface FeedActionBarEntry {
133
+ id: string;
134
+ label: string;
135
+ stateful?: boolean;
136
+ order?: number;
137
+ pinnedTrailing?: boolean;
138
+ render: () => React.ReactNode;
139
+ }
140
+
141
+ /**
142
+ * Adapt a plain consumer/public `FeedAction` (id/label/icon/active/onClick,
143
+ * plus the optional stateful?/order?/pinnedTrailing? flags) into a bar entry,
144
+ * rendered as the standard icon button. `stateful` defaults to
145
+ * `active !== undefined` per the #6 spec (stateful toggles, meaning
146
+ * `active !== undefined`, stay primary).
147
+ */
148
+ export function feedActionToEntry(action: FeedAction): FeedActionBarEntry {
149
+ return {
150
+ id: action.id,
151
+ label: action.label,
152
+ stateful: action.stateful ?? action.active !== undefined,
153
+ order: action.order,
154
+ pinnedTrailing: action.pinnedTrailing,
155
+ render: () => (
156
+ <OverlayIconButton
157
+ key={action.id}
158
+ type="button"
159
+ aria-label={action.label}
160
+ aria-pressed={action.active ?? undefined}
161
+ title={action.label}
162
+ $active={action.active ?? false}
163
+ onClick={action.onClick}
164
+ >
165
+ {action.icon}
166
+ </OverlayIconButton>
167
+ ),
168
+ };
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // The bar itself
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const OVERFLOW_MENU_MAX_WIDTH = 200;
176
+ const OVERFLOW_MENU_MAX_HEIGHT = 240;
177
+
178
+ export interface FeedActionBarProps {
179
+ /** Non-partitioned content rendered before the partitioned entries (e.g. the camera step buttons). */
180
+ leading?: React.ReactNode;
181
+ entries: FeedActionBarEntry[];
182
+ /** Forwarded to the root row; lets a host `styled(FeedActionBar)` override layout (e.g. KerbalFaceFeed's smaller, always-visible bar). */
183
+ className?: string;
184
+ }
185
+
186
+ /**
187
+ * Shared top-right action row. Renders `leading`, then the partitioned
188
+ * entries: stateful ones always inline, non-stateful ones inline until the
189
+ * row is crowded enough to warrant a ⋮ overflow menu, then the pinned
190
+ * trailing (close/remove) entries: always last, to the right of the ⋮.
191
+ */
192
+ export function FeedActionBar({ leading, entries, className }: FeedActionBarProps) {
193
+ const { primary, overflow, pinnedTrailing } = partitionActionBarEntries(entries);
194
+ const menuId = useId();
195
+ const overflowMenu = usePortalMenu({
196
+ maxWidth: OVERFLOW_MENU_MAX_WIDTH,
197
+ maxHeight: OVERFLOW_MENU_MAX_HEIGHT,
198
+ align: "end",
199
+ });
200
+ const closeOverflowMenu = overflowMenu.close;
201
+
202
+ if (!leading && entries.length === 0) return null;
203
+
204
+ return (
205
+ <ActionBarRow className={className}>
206
+ {leading}
207
+ {primary.map((e) => (
208
+ <Fragment key={e.id}>{e.render()}</Fragment>
209
+ ))}
210
+ {overflow.length > 0 && (
211
+ <OverlayIconButton
212
+ ref={overflowMenu.triggerRef}
213
+ type="button"
214
+ aria-label="More actions"
215
+ aria-haspopup="menu"
216
+ aria-expanded={overflowMenu.open}
217
+ aria-controls={menuId}
218
+ title="More actions"
219
+ $active={overflowMenu.open}
220
+ onClick={overflowMenu.toggle}
221
+ >
222
+ <MoreIcon />
223
+ </OverlayIconButton>
224
+ )}
225
+ {overflow.length > 0 &&
226
+ overflowMenu.open &&
227
+ overflowMenu.position &&
228
+ createPortal(
229
+ <OverflowMenu
230
+ ref={overflowMenu.menuRef}
231
+ id={menuId}
232
+ role="menu"
233
+ aria-label="More actions"
234
+ style={overflowMenu.position}
235
+ >
236
+ {overflow.map((e) => (
237
+ // The wrapping row's onClick fires in the BUBBLE phase, after
238
+ // the entry's own button click handler, so this closes the
239
+ // menu once the action has actually fired rather than
240
+ // pre-empting it.
241
+ <OverflowMenuItem key={e.id} onClick={closeOverflowMenu}>
242
+ {e.render()}
243
+ <OverflowMenuItemLabel aria-hidden="true">{e.label}</OverflowMenuItemLabel>
244
+ </OverflowMenuItem>
245
+ ))}
246
+ </OverflowMenu>,
247
+ document.body,
248
+ )}
249
+ {pinnedTrailing.map((e) => (
250
+ <Fragment key={e.id}>{e.render()}</Fragment>
251
+ ))}
252
+ </ActionBarRow>
253
+ );
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Icons
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /* Overflow trigger: vertical ellipsis (three dots). */
261
+ function MoreIcon() {
262
+ return (
263
+ <svg
264
+ viewBox="0 0 16 16"
265
+ width={14}
266
+ height={14}
267
+ fill="currentColor"
268
+ stroke="none"
269
+ aria-hidden="true"
270
+ >
271
+ <circle cx="8" cy="3" r="1.4" />
272
+ <circle cx="8" cy="8" r="1.4" />
273
+ <circle cx="8" cy="13" r="1.4" />
274
+ </svg>
275
+ );
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Styled components
280
+ // ---------------------------------------------------------------------------
281
+
282
+ /** Icon-button shared by every action-bar entry (built-in or consumer). */
283
+ export const OverlayIconButton = styled.button<{ $active?: boolean }>`
284
+ position: relative;
285
+ width: 24px;
286
+ height: 24px;
287
+ display: flex;
288
+ align-items: center;
289
+ justify-content: center;
290
+ padding: 0;
291
+ /* Active-toggle fill (quality / tracking / PiP / fullscreen / custom actions
292
+ all share this button). Its own token so a consumer can recolour just the
293
+ action-row highlight without touching the general accent; defaults to
294
+ --kerbcast-accent, so the sidecar web page (which maps its accent onto that)
295
+ is unchanged. */
296
+ background: ${(p) =>
297
+ p.$active
298
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
299
+ : "rgba(0, 0, 0, 0.5)"};
300
+ border: 1px solid
301
+ ${(p) =>
302
+ p.$active
303
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
304
+ : "rgba(255, 255, 255, 0.3)"};
305
+ border-radius: 3px;
306
+ color: ${(p) => (p.$active ? "#000" : "#fff")};
307
+ cursor: pointer;
308
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
309
+
310
+ @media (hover: hover) {
311
+ &:hover {
312
+ background: ${(p) =>
313
+ p.$active
314
+ ? "var(--kerbcast-action-active, var(--kerbcast-accent, #00ff88))"
315
+ : "rgba(0, 0, 0, 0.7)"};
316
+ border-color: rgba(255, 255, 255, 0.6);
317
+ }
318
+ }
319
+
320
+ &:focus-visible {
321
+ outline: 2px solid var(--kerbcast-accent, #00ff88);
322
+ outline-offset: 2px;
323
+ }
324
+
325
+ &:disabled {
326
+ opacity: 0.4;
327
+ }
328
+
329
+ @media (prefers-reduced-motion: reduce) {
330
+ transition: none;
331
+ }
332
+ `;
333
+
334
+ /* Top-right cluster of overlay controls (custom actions + built-ins + overflow). */
335
+ export const ActionBarRow = styled.div`
336
+ position: absolute;
337
+ top: 6px;
338
+ right: 8px;
339
+ z-index: 3;
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 4px;
343
+ opacity: 0;
344
+ pointer-events: none;
345
+ transition: opacity 0.15s;
346
+
347
+ @media (prefers-reduced-motion: reduce) {
348
+ transition: none;
349
+ }
350
+ `;
351
+
352
+ /* The ⋮ overflow dropdown: same portal/anchor contract as CameraFeed's other
353
+ menus (fixed position set inline from the trigger rect) so tile clipping
354
+ cannot cut it off. */
355
+ const OverflowMenu = styled.div`
356
+ position: fixed;
357
+ z-index: 1000;
358
+ min-width: 140px;
359
+ max-width: min(200px, calc(100vw - 16px));
360
+ max-height: min(40vh, 240px);
361
+ display: flex;
362
+ flex-direction: column;
363
+ padding: 4px;
364
+ gap: 2px;
365
+ background: rgba(0, 0, 0, 0.85);
366
+ border: 1px solid rgba(255, 255, 255, 0.3);
367
+ border-radius: 4px;
368
+ overflow-x: hidden;
369
+ overflow-y: auto;
370
+ `;
371
+
372
+ const OverflowMenuItem = styled.div`
373
+ display: flex;
374
+ align-items: center;
375
+ gap: 8px;
376
+ padding: 2px 4px;
377
+ border-radius: 3px;
378
+
379
+ @media (hover: hover) {
380
+ &:hover {
381
+ background: rgba(255, 255, 255, 0.1);
382
+ }
383
+ }
384
+ `;
385
+
386
+ const OverflowMenuItemLabel = styled.span`
387
+ font-size: 11px;
388
+ letter-spacing: 0.04em;
389
+ color: #fff;
390
+ white-space: nowrap;
391
+ `;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tests for the `KerbalFaceFeed` primitive. Drives the real `KerbcastClient`
3
+ * through the SDK's canonical `MockSidecar` (only the WebRTC transport is
4
+ * faked — jsdom can't produce a real `MediaStream`, so a mounted feed sits in
5
+ * the no-signal state, which is exactly what the standby/never-remount
6
+ * assertions need).
7
+ */
8
+
9
+ import { KerbcastClient } from "@ksp-gonogo/kerbcast";
10
+ import { MockSidecar } from "@ksp-gonogo/kerbcast/testing";
11
+ import { act, cleanup, render } from "@testing-library/react";
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
13
+
14
+ import { KerbalFaceFeed, type KerbalFeedState } from "./KerbalFaceFeed";
15
+ import { KerbcastProvider, type KerbcastDisplaySizes } from "./context";
16
+
17
+ const KERBAL_ID = 0x8000_002a; // a kerbal wire-id (top bit set)
18
+
19
+ async function buildConnected(): Promise<KerbcastClient> {
20
+ const sidecar = new MockSidecar();
21
+ sidecar.addCamera({ flightId: KERBAL_ID, cameraName: "Jebediah Kerman" });
22
+ const client = new KerbcastClient(
23
+ { host: "h", port: 1, negotiate: (o) => sidecar.negotiate(o) },
24
+ sidecar.createTransport(),
25
+ );
26
+ await act(async () => {
27
+ await client.connect([], { slots: 4 });
28
+ });
29
+ await act(async () => {
30
+ sidecar.open();
31
+ sidecar.setConnectionState("connected");
32
+ });
33
+ return client;
34
+ }
35
+
36
+ afterEach(() => {
37
+ cleanup();
38
+ });
39
+
40
+ describe("KerbalFaceFeed", () => {
41
+ it("renders the square frame with a persistent video element", async () => {
42
+ const client = await buildConnected();
43
+ const { getByTestId, container } = render(
44
+ <KerbcastProvider client={client}>
45
+ <KerbalFaceFeed flightId={KERBAL_ID} size={96} />
46
+ </KerbcastProvider>,
47
+ );
48
+ // The square frame primitive is present...
49
+ expect(getByTestId("kerbal-face-feed")).toBeTruthy();
50
+ // ...and it owns exactly one <video> for the face stream.
51
+ expect(container.querySelectorAll("video")).toHaveLength(1);
52
+ });
53
+
54
+ it("shows the standby glyph and reports no-signal when there is no stream", async () => {
55
+ const client = await buildConnected();
56
+ const states: KerbalFeedState[] = [];
57
+ const { getByTestId, container } = render(
58
+ <KerbcastProvider client={client}>
59
+ <KerbalFaceFeed
60
+ flightId={KERBAL_ID}
61
+ size={96}
62
+ onFeedStateChange={(s) => states.push(s)}
63
+ />
64
+ </KerbcastProvider>,
65
+ );
66
+ // jsdom never yields a MediaStream, so the feed is out of signal.
67
+ expect(getByTestId("kerbal-face-feed").getAttribute("data-feed-state")).toBe(
68
+ "no-signal",
69
+ );
70
+ // The minimal standby glyph (an aria-hidden svg) is shown.
71
+ expect(container.querySelector("svg[aria-hidden='true']")).toBeTruthy();
72
+ // The state signal fired for the wrapper.
73
+ expect(states).toContain("no-signal");
74
+ });
75
+
76
+ it("does not draw the standby glyph when showStandby is false", async () => {
77
+ const client = await buildConnected();
78
+ const { container } = render(
79
+ <KerbcastProvider client={client}>
80
+ <KerbalFaceFeed flightId={KERBAL_ID} size={96} showStandby={false} />
81
+ </KerbcastProvider>,
82
+ );
83
+ expect(container.querySelector("svg[aria-hidden='true']")).toBeNull();
84
+ // The video element is still mounted (never conditionally swapped out).
85
+ expect(container.querySelectorAll("video")).toHaveLength(1);
86
+ });
87
+
88
+ it("never remounts the live video element across a prop change", async () => {
89
+ const client = await buildConnected();
90
+ const { container, rerender } = render(
91
+ <KerbcastProvider client={client}>
92
+ <KerbalFaceFeed flightId={KERBAL_ID} size={96} />
93
+ </KerbcastProvider>,
94
+ );
95
+ const before = container.querySelector("video");
96
+ expect(before).toBeTruthy();
97
+
98
+ // A re-layout (size change) must NOT remount the <video> — same DOM node.
99
+ rerender(
100
+ <KerbcastProvider client={client}>
101
+ <KerbalFaceFeed flightId={KERBAL_ID} size={220} />
102
+ </KerbcastProvider>,
103
+ );
104
+ const after = container.querySelector("video");
105
+ expect(after).toBe(before);
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Display-size reporting + showActions
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function recordingRegistry() {
114
+ const reports: Array<{ flightId: number; w: number; h: number }> = [];
115
+ const clears: Array<{ flightId: number }> = [];
116
+ const displaySizes: KerbcastDisplaySizes = {
117
+ report: (flightId, _id, w, h) => reports.push({ flightId, w, h }),
118
+ clear: (flightId) => clears.push({ flightId }),
119
+ };
120
+ return { displaySizes, reports, clears };
121
+ }
122
+
123
+ describe("KerbalFaceFeed - reporting + showActions", () => {
124
+ let resizeCallback:
125
+ | ((entries: ResizeObserverEntry[], observer: ResizeObserver) => void)
126
+ | undefined;
127
+ const originalResizeObserver = globalThis.ResizeObserver;
128
+
129
+ beforeEach(() => {
130
+ globalThis.ResizeObserver = class {
131
+ constructor(cb: (entries: ResizeObserverEntry[], observer: ResizeObserver) => void) {
132
+ resizeCallback = cb;
133
+ }
134
+ observe() {}
135
+ unobserve() {}
136
+ disconnect() {}
137
+ } as unknown as typeof ResizeObserver;
138
+ });
139
+
140
+ afterEach(() => {
141
+ cleanup();
142
+ globalThis.ResizeObserver = originalResizeObserver;
143
+ resizeCallback = undefined;
144
+ });
145
+
146
+ it("reports a SQUARE self-measured display size for the face", async () => {
147
+ const client = await buildConnected();
148
+ const { displaySizes, reports } = recordingRegistry();
149
+ render(
150
+ <KerbcastProvider client={client} displaySizes={displaySizes}>
151
+ <KerbalFaceFeed flightId={KERBAL_ID} />
152
+ </KerbcastProvider>,
153
+ );
154
+ // A non-square measured box still reports square (min edge, bucketed): the
155
+ // face capture is square, so 50x40 -> min 40 -> 48x48.
156
+ act(() => {
157
+ resizeCallback?.(
158
+ [{ contentRect: { width: 50, height: 40 } }] as ResizeObserverEntry[],
159
+ {} as ResizeObserver,
160
+ );
161
+ });
162
+ expect(reports.at(-1)).toEqual({ flightId: KERBAL_ID, w: 48, h: 48 });
163
+ });
164
+
165
+ it("does not report when reportSize is false", async () => {
166
+ const client = await buildConnected();
167
+ const { displaySizes, reports } = recordingRegistry();
168
+ render(
169
+ <KerbcastProvider client={client} displaySizes={displaySizes}>
170
+ <KerbalFaceFeed flightId={KERBAL_ID} reportSize={false} />
171
+ </KerbcastProvider>,
172
+ );
173
+ expect(resizeCallback).toBeUndefined();
174
+ expect(reports).toEqual([]);
175
+ });
176
+
177
+ it("renders supplied actions by default", async () => {
178
+ const client = await buildConnected();
179
+ const { getByLabelText } = render(
180
+ <KerbcastProvider client={client}>
181
+ <KerbalFaceFeed
182
+ flightId={KERBAL_ID}
183
+ actions={[{ id: "s", label: "Spotlight", icon: null, onClick: () => {} }]}
184
+ />
185
+ </KerbcastProvider>,
186
+ );
187
+ expect(getByLabelText("Spotlight")).toBeTruthy();
188
+ });
189
+
190
+ it("hides the action bar when showActions is false", async () => {
191
+ const client = await buildConnected();
192
+ const { queryByLabelText } = render(
193
+ <KerbcastProvider client={client}>
194
+ <KerbalFaceFeed
195
+ flightId={KERBAL_ID}
196
+ showActions={false}
197
+ actions={[{ id: "s", label: "Spotlight", icon: null, onClick: () => {} }]}
198
+ />
199
+ </KerbcastProvider>,
200
+ );
201
+ expect(queryByLabelText("Spotlight")).toBeNull();
202
+ });
203
+ });