feat: synchronize audience presentation route
This commit is contained in:
@@ -2,10 +2,20 @@ import { cleanup, render, screen, within } from "@testing-library/react";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { EvidenceRecord } from "../app/state.js";
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||||
|
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
|
||||||
import { PresentationFooter } from "./PresentationFooter.js";
|
import { PresentationFooter } from "./PresentationFooter.js";
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const standaloneSyncController = (): PresentationSyncController => ({
|
||||||
|
state: { kind: "standalone" },
|
||||||
|
startSession: vi.fn(async () => {}),
|
||||||
|
joinSession: vi.fn(async () => {}),
|
||||||
|
retry: vi.fn(),
|
||||||
|
leaveSession: vi.fn(),
|
||||||
|
endSession: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
describe("PresentationFooter", () => {
|
describe("PresentationFooter", () => {
|
||||||
it("combines scene progress and evidence provenance", () => {
|
it("combines scene progress and evidence provenance", () => {
|
||||||
const evidence: EvidenceRecord = {
|
const evidence: EvidenceRecord = {
|
||||||
@@ -30,6 +40,7 @@ describe("PresentationFooter", () => {
|
|||||||
}}
|
}}
|
||||||
evidence={[evidence]}
|
evidence={[evidence]}
|
||||||
demoRail={hiddenRail}
|
demoRail={hiddenRail}
|
||||||
|
syncController={standaloneSyncController()}
|
||||||
retryHealth={vi.fn()}
|
retryHealth={vi.fn()}
|
||||||
showEvidenceReceipt
|
showEvidenceReceipt
|
||||||
inspectEvidence={vi.fn()}
|
inspectEvidence={vi.fn()}
|
||||||
@@ -52,6 +63,7 @@ describe("PresentationFooter", () => {
|
|||||||
}}
|
}}
|
||||||
evidence={[]}
|
evidence={[]}
|
||||||
demoRail={{ kind: "hidden" }}
|
demoRail={{ kind: "hidden" }}
|
||||||
|
syncController={standaloneSyncController()}
|
||||||
retryHealth={vi.fn()}
|
retryHealth={vi.fn()}
|
||||||
showEvidenceReceipt={false}
|
showEvidenceReceipt={false}
|
||||||
inspectEvidence={vi.fn()}
|
inspectEvidence={vi.fn()}
|
||||||
@@ -80,6 +92,7 @@ describe("PresentationFooter", () => {
|
|||||||
canRun: true,
|
canRun: true,
|
||||||
canRetry: true,
|
canRetry: true,
|
||||||
}}
|
}}
|
||||||
|
syncController={standaloneSyncController()}
|
||||||
retryHealth={vi.fn()}
|
retryHealth={vi.fn()}
|
||||||
showEvidenceReceipt={false}
|
showEvidenceReceipt={false}
|
||||||
inspectEvidence={vi.fn()}
|
inspectEvidence={vi.fn()}
|
||||||
@@ -89,4 +102,38 @@ describe("PresentationFooter", () => {
|
|||||||
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
|
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
|
||||||
expect(screen.getByText("Live target ready")).toBeInTheDocument();
|
expect(screen.getByText("Live target ready")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps audience pairing in the non-demo utility area beside one demo rail", () => {
|
||||||
|
render(
|
||||||
|
<PresentationFooter
|
||||||
|
location={{ kind: "main", sceneId: "agent-handoff", beatId: "request", focusPath: [] }}
|
||||||
|
evidence={[]}
|
||||||
|
demoRail={{
|
||||||
|
kind: "action",
|
||||||
|
mode: "live",
|
||||||
|
label: "Run prepared workflow",
|
||||||
|
status: {
|
||||||
|
kind: "ready",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Live target ready",
|
||||||
|
detail: "127.0.0.1:8765",
|
||||||
|
},
|
||||||
|
canRun: true,
|
||||||
|
canRetry: true,
|
||||||
|
}}
|
||||||
|
syncController={standaloneSyncController()}
|
||||||
|
retryHealth={vi.fn()}
|
||||||
|
showEvidenceReceipt={false}
|
||||||
|
inspectEvidence={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = screen.getByRole("contentinfo", { name: /presentation footer/i });
|
||||||
|
const utility = footer.querySelector<HTMLElement>(".presentation-footer__utility");
|
||||||
|
expect(utility).not.toBeNull();
|
||||||
|
if (utility === null) throw new Error("presentation utility area is missing");
|
||||||
|
expect(within(utility).getByRole("complementary", { name: "Presentation pairing" }))
|
||||||
|
.toHaveAttribute("data-role", "audience");
|
||||||
|
expect(screen.getAllByTestId("presentation-demo-rail")).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { EvidenceReceipt } from "./evidence/EvidenceReceipt.js";
|
|||||||
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
import type { DemoChromePresentation } from "./presentation-demo-chrome.js";
|
||||||
import { PresentationDemoRail } from "./PresentationDemoRail.js";
|
import { PresentationDemoRail } from "./PresentationDemoRail.js";
|
||||||
import type { MainLocation } from "./storyboard.js";
|
import type { MainLocation } from "./storyboard.js";
|
||||||
|
import { PresentationPairingPanel } from "./sync/PresentationPairingPanel.js";
|
||||||
|
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
|
||||||
|
|
||||||
type PresentationFooterProps = {
|
type PresentationFooterProps = {
|
||||||
readonly location: MainLocation;
|
readonly location: MainLocation;
|
||||||
@@ -14,6 +16,7 @@ type PresentationFooterProps = {
|
|||||||
readonly retryHealth: () => void;
|
readonly retryHealth: () => void;
|
||||||
readonly showEvidenceReceipt: boolean;
|
readonly showEvidenceReceipt: boolean;
|
||||||
readonly inspectEvidence: () => void;
|
readonly inspectEvidence: () => void;
|
||||||
|
readonly syncController: PresentationSyncController;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PresentationFooter = ({
|
export const PresentationFooter = ({
|
||||||
@@ -24,6 +27,7 @@ export const PresentationFooter = ({
|
|||||||
retryHealth,
|
retryHealth,
|
||||||
showEvidenceReceipt,
|
showEvidenceReceipt,
|
||||||
inspectEvidence,
|
inspectEvidence,
|
||||||
|
syncController,
|
||||||
}: PresentationFooterProps) => (
|
}: PresentationFooterProps) => (
|
||||||
<footer className="presentation-footer" aria-label="presentation footer">
|
<footer className="presentation-footer" aria-label="presentation footer">
|
||||||
<SceneProgress location={location} />
|
<SceneProgress location={location} />
|
||||||
@@ -32,10 +36,13 @@ export const PresentationFooter = ({
|
|||||||
runPreparedWorkflow={runPreparedWorkflow}
|
runPreparedWorkflow={runPreparedWorkflow}
|
||||||
retryHealth={retryHealth}
|
retryHealth={retryHealth}
|
||||||
/>
|
/>
|
||||||
<EvidenceReceipt
|
<div className="presentation-footer__utility">
|
||||||
records={evidence}
|
<PresentationPairingPanel role="audience" controller={syncController} />
|
||||||
visible={showEvidenceReceipt}
|
<EvidenceReceipt
|
||||||
onInspect={inspectEvidence}
|
records={evidence}
|
||||||
/>
|
visible={showEvidenceReceipt}
|
||||||
|
onInspect={inspectEvidence}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,104 @@ const healthyResponse = {
|
|||||||
durationMs: 2,
|
durationMs: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncGrant = {
|
||||||
|
sessionId: "session-route",
|
||||||
|
code: "ABC123",
|
||||||
|
connectionToken: "token-route",
|
||||||
|
websocketPath: "/api/presentation-sync/ws" as const,
|
||||||
|
snapshot: { hash: "#scene/thesis/title", revision: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
class SyncTestSocket {
|
||||||
|
static readonly OPEN = 1;
|
||||||
|
static readonly CLOSED = 3;
|
||||||
|
readonly sent: string[] = [];
|
||||||
|
readonly url: string;
|
||||||
|
readyState = 0;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onclose: ((event: { readonly code: number; readonly reason: string }) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(url: string, private readonly sockets: SyncTestSocket[]) {
|
||||||
|
this.url = url;
|
||||||
|
sockets.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: string): void {
|
||||||
|
this.sent.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
this.readyState = SyncTestSocket.OPEN;
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
serverMessage(message: unknown): void {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(message) });
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code = 1000, reason = "closed"): void {
|
||||||
|
this.readyState = SyncTestSocket.CLOSED;
|
||||||
|
this.onclose?.({ code, reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installSyncTransport = (serverAvailable = true) => {
|
||||||
|
const sockets: SyncTestSocket[] = [];
|
||||||
|
const fetch = vi.fn<typeof globalThis.fetch>();
|
||||||
|
if (serverAvailable) {
|
||||||
|
fetch.mockResolvedValue(new Response(JSON.stringify(syncGrant), { status: 201 }));
|
||||||
|
} else {
|
||||||
|
fetch.mockRejectedValue(new Error("The pairing server is unavailable."));
|
||||||
|
}
|
||||||
|
vi.stubGlobal("fetch", fetch);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"WebSocket",
|
||||||
|
class extends SyncTestSocket {
|
||||||
|
constructor(url: string) {
|
||||||
|
super(url, sockets);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { fetch, sockets };
|
||||||
|
};
|
||||||
|
|
||||||
|
const locationSnapshot = (hash: string, revision: number) => ({
|
||||||
|
type: "location.snapshot",
|
||||||
|
snapshot: { hash, revision },
|
||||||
|
originatingMessageId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presenceSnapshot = {
|
||||||
|
type: "presence.snapshot",
|
||||||
|
presence: { presenters: 1, audience: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishedMessages = (socket: SyncTestSocket | undefined) =>
|
||||||
|
socket?.sent
|
||||||
|
.map((message) => JSON.parse(message) as { readonly type?: string; readonly hash?: string })
|
||||||
|
.filter((message) => message.type === "location.publish") ?? [];
|
||||||
|
|
||||||
|
const connectAudience = async (
|
||||||
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
|
sockets: SyncTestSocket[],
|
||||||
|
initialHash = window.location.hash,
|
||||||
|
): Promise<SyncTestSocket> => {
|
||||||
|
await user.click(screen.getByRole("button", { name: "Pair presentation" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Start session" }));
|
||||||
|
await waitFor(() => expect(sockets).toHaveLength(1));
|
||||||
|
const socket = sockets[0];
|
||||||
|
if (socket === undefined) throw new Error("sync test socket was not created");
|
||||||
|
await act(async () => {
|
||||||
|
socket.open();
|
||||||
|
socket.serverMessage(locationSnapshot(initialHash, 0));
|
||||||
|
socket.serverMessage(presenceSnapshot);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.getByRole("status", { name: "Connected" })).toBeInTheDocument());
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockedCallOperation.mockResolvedValue(healthyResponse);
|
mockedCallOperation.mockResolvedValue(healthyResponse);
|
||||||
});
|
});
|
||||||
@@ -66,6 +164,7 @@ const graphNodeByLabel = (label: RegExp): HTMLElement => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
mockedCallOperation.mockReset();
|
mockedCallOperation.mockReset();
|
||||||
mockedCallOperation.mockResolvedValue(healthyResponse);
|
mockedCallOperation.mockResolvedValue(healthyResponse);
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
@@ -73,6 +172,97 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PresentationRoute", () => {
|
describe("PresentationRoute", () => {
|
||||||
|
it("renders the uniform audience pairing panel on /present", { timeout: 15000 }, async () => {
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
const panel = screen.getByRole("complementary", { name: "Presentation pairing" });
|
||||||
|
expect(panel).toHaveAttribute("data-role", "audience");
|
||||||
|
expect(within(panel).getByRole("button", { name: "Pair presentation" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes one complete hash for local ArrowRight navigation", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { sockets } = installSyncTransport();
|
||||||
|
window.location.hash = "#scene/thesis/title";
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
const socket = await connectAudience(user, sockets);
|
||||||
|
fireEvent.keyDown(document.body, { key: "ArrowRight" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(window.location.hash).toBe("#scene/thesis/substrate"));
|
||||||
|
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(1));
|
||||||
|
expect(publishedMessages(socket)[0]).toMatchObject({
|
||||||
|
type: "location.publish",
|
||||||
|
hash: "#scene/thesis/substrate",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes direct hashes and figure focus changes with their complete focus paths", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { sockets } = installSyncTransport();
|
||||||
|
window.location.hash = "#scene/architecture/overview";
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
const socket = await connectAudience(user, sockets);
|
||||||
|
window.location.hash = "#scene/architecture/runtime/focus/runtime-providers/configured-providers";
|
||||||
|
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(1));
|
||||||
|
expect(publishedMessages(socket)[0]?.hash)
|
||||||
|
.toBe("#scene/architecture/runtime/focus/runtime-providers/configured-providers");
|
||||||
|
|
||||||
|
window.location.hash = "#scene/architecture/overview";
|
||||||
|
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(2));
|
||||||
|
await waitFor(() => expect(
|
||||||
|
Array.from(document.querySelectorAll<HTMLButtonElement>(".figure-node"))
|
||||||
|
.some((node) => /Capability inventory/.test(node.getAttribute("aria-label") ?? "")),
|
||||||
|
).toBe(true));
|
||||||
|
const capabilityInventoryNode = Array.from(
|
||||||
|
document.querySelectorAll<HTMLButtonElement>(".figure-node"),
|
||||||
|
).find((node) => /Capability inventory/.test(node.getAttribute("aria-label") ?? ""));
|
||||||
|
if (capabilityInventoryNode === undefined) throw new Error("capability inventory figure node is missing");
|
||||||
|
fireEvent.click(capabilityInventoryNode);
|
||||||
|
await waitFor(() => expect(publishedMessages(socket)).toHaveLength(3));
|
||||||
|
expect(publishedMessages(socket)[2]?.hash)
|
||||||
|
.toBe("#scene/architecture/overview/focus/runtime-providers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a remote discussion hash through the existing discussion panel without echoing it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { sockets } = installSyncTransport();
|
||||||
|
window.location.hash = "#scene/thesis/title";
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
const socket = await connectAudience(user, sockets);
|
||||||
|
await act(async () => {
|
||||||
|
socket.serverMessage(locationSnapshot("#discuss/hosted-automation", 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: /return to positioning/i })).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(window.location.hash).toBe("#discuss/hosted-automation"));
|
||||||
|
expect(publishedMessages(socket)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps local Space and ArrowRight navigation working when pairing fails", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { fetch } = installSyncTransport(false);
|
||||||
|
window.location.hash = "#scene/thesis/title";
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Pair presentation" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Start session" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("The pairing server is unavailable.");
|
||||||
|
expect(fetch).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
fireEvent.keyDown(document.body, { key: "ArrowRight" });
|
||||||
|
await waitFor(() => expect(window.location.hash).toBe("#scene/thesis/substrate"));
|
||||||
|
fireEvent.keyDown(document.body, { key: " " });
|
||||||
|
await waitFor(() => expect(window.location.hash).toBe("#scene/problem/direct-actions"));
|
||||||
|
});
|
||||||
|
|
||||||
const directHashRoutes = [
|
const directHashRoutes = [
|
||||||
{ hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", hasDemoChrome: false },
|
{ hash: "#scene/thesis/title", heading: "Design and Implementation of lda.chat", hasDemoChrome: false },
|
||||||
{ hash: "#scene/architecture/overview", heading: "Architecture Zoom", hasDemoChrome: false },
|
{ hash: "#scene/architecture/overview", heading: "Architecture Zoom", hasDemoChrome: false },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { hashForLocation, nextMainLocation } from "./storyboard-navigation.js";
|
import { hashForLocation, nextMainLocation } from "./storyboard-navigation.js";
|
||||||
import type { MainLocation } from "./storyboard.js";
|
import type { MainLocation } from "./storyboard.js";
|
||||||
import type { DemoApprovalActions, DemoApprovalUiState } from "./demo-approval-actions.js";
|
import type { DemoApprovalActions, DemoApprovalUiState } from "./demo-approval-actions.js";
|
||||||
|
import { usePresentationSync } from "./sync/usePresentationSync.js";
|
||||||
import "./presentation.css";
|
import "./presentation.css";
|
||||||
import "./styles/demo-workflow.css";
|
import "./styles/demo-workflow.css";
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ export const PresentationRoute = () => {
|
|||||||
{ type: "jump_hash", hash: initialHash },
|
{ type: "jump_hash", hash: initialHash },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const canonicalHash = hashForLocation(state.location);
|
||||||
|
const presentationSync = usePresentationSync({
|
||||||
|
role: "audience",
|
||||||
|
currentHash: canonicalHash,
|
||||||
|
applyRemoteHash: (hash) => dispatch({ type: "jump_hash", hash }),
|
||||||
|
});
|
||||||
const [presentationReady, setPresentationReady] = useState(false);
|
const [presentationReady, setPresentationReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -306,6 +313,7 @@ export const PresentationRoute = () => {
|
|||||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||||
openDiscussion={handleOpenDiscussion}
|
openDiscussion={handleOpenDiscussion}
|
||||||
closeDiscussion={handleCloseDiscussion}
|
closeDiscussion={handleCloseDiscussion}
|
||||||
|
syncController={presentationSync}
|
||||||
/>
|
/>
|
||||||
</PresentationCanvas>
|
</PresentationCanvas>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PresentationFooter } from "./PresentationFooter.js";
|
|||||||
import type { PresentationState } from "./presentation-state.js";
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
import { compositionForState } from "./presentation-state.js";
|
import { compositionForState } from "./presentation-state.js";
|
||||||
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
|
import type { PresentationSyncController } from "./sync/presentation-sync-state.js";
|
||||||
import { demoChromeFor } from "./presentation-demo-chrome.js";
|
import { demoChromeFor } from "./presentation-demo-chrome.js";
|
||||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
import { findScene, type MainLocation } from "./storyboard.js";
|
import { findScene, type MainLocation } from "./storyboard.js";
|
||||||
@@ -34,6 +35,7 @@ type PresentationStageProps = {
|
|||||||
readonly closeOverlay: () => void;
|
readonly closeOverlay: () => void;
|
||||||
readonly openDiscussion: (branchId: string) => void;
|
readonly openDiscussion: (branchId: string) => void;
|
||||||
readonly closeDiscussion: () => void;
|
readonly closeDiscussion: () => void;
|
||||||
|
readonly syncController: PresentationSyncController;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PresentationStage = ({
|
export const PresentationStage = ({
|
||||||
@@ -55,6 +57,7 @@ export const PresentationStage = ({
|
|||||||
closeOverlay,
|
closeOverlay,
|
||||||
openDiscussion,
|
openDiscussion,
|
||||||
closeDiscussion,
|
closeDiscussion,
|
||||||
|
syncController,
|
||||||
}: PresentationStageProps) => {
|
}: PresentationStageProps) => {
|
||||||
const composition = compositionForState(state);
|
const composition = compositionForState(state);
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ export const PresentationStage = ({
|
|||||||
retryHealth={retryHealth}
|
retryHealth={retryHealth}
|
||||||
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
showEvidenceReceipt={composition.evidencePresentation !== "hidden"}
|
||||||
inspectEvidence={openEvidence}
|
inspectEvidence={openEvidence}
|
||||||
|
syncController={syncController}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EvidenceInspector
|
<EvidenceInspector
|
||||||
|
|||||||
@@ -1111,6 +1111,14 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presentation-footer__utility {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-receipt {
|
.evidence-receipt {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1217,6 +1225,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.presentation-footer__utility {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 52%;
|
||||||
|
}
|
||||||
|
|
||||||
.presentation-demo-rail {
|
.presentation-demo-rail {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user