feat: add fixed editorial presentation canvas
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "3.0.0",
|
||||
"@fontsource-variable/newsreader": "5.2.10",
|
||||
"@fontsource-variable/source-sans-3": "5.2.9",
|
||||
"@fontsource/barlow-condensed": "5.2.8",
|
||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||
@@ -22,6 +23,7 @@
|
||||
"valibot": "1.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.3.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
@@ -29,6 +31,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"jsdom": "29.1.1",
|
||||
"tailwindcss": "4.3.2",
|
||||
"vite": "8.1.2",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -1,3 +1,4 @@
|
||||
declare module "@fontsource-variable/newsreader/wght.css";
|
||||
declare module "@fontsource-variable/source-sans-3";
|
||||
declare module "@fontsource/barlow-condensed/600.css";
|
||||
declare module "@fontsource/barlow-condensed/700.css";
|
||||
|
||||
@@ -2,8 +2,10 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "@fontsource/barlow-condensed/600.css";
|
||||
import "@fontsource/barlow-condensed/700.css";
|
||||
import "@fontsource-variable/newsreader/wght.css";
|
||||
import "@fontsource-variable/source-sans-3";
|
||||
import "@fontsource/ibm-plex-mono/400.css";
|
||||
import "./presentation/styles/editorial.css";
|
||||
import "./styles/global.css";
|
||||
import { App } from "./app/App.js";
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PRESENTATION_HEIGHT, PRESENTATION_WIDTH } from "./canvas-fit.js";
|
||||
import { PresentationCanvas } from "./PresentationCanvas.js";
|
||||
|
||||
const setViewport = (width: number, height: number) => {
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("PresentationCanvas", () => {
|
||||
it("renders one fixed 1280x720 audience canvas", () => {
|
||||
setViewport(1280, 720);
|
||||
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
|
||||
const canvas = screen.getByTestId("presentation-canvas");
|
||||
expect(canvas).toHaveStyle({
|
||||
width: `${PRESENTATION_WIDTH}px`,
|
||||
height: `${PRESENTATION_HEIGHT}px`,
|
||||
transform: "scale(1)",
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
});
|
||||
|
||||
it("recomputes letterboxing after viewport resize", () => {
|
||||
setViewport(1280, 720);
|
||||
render(<PresentationCanvas><div>Scene</div></PresentationCanvas>);
|
||||
setViewport(1024, 768);
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
expect(screen.getByTestId("presentation-canvas")).toHaveStyle({
|
||||
transform: "scale(0.8)",
|
||||
left: "0px",
|
||||
top: "96px",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
fitPresentationCanvas,
|
||||
PRESENTATION_HEIGHT,
|
||||
PRESENTATION_WIDTH,
|
||||
type ViewportSize,
|
||||
} from "./canvas-fit.js";
|
||||
|
||||
type PresentationCanvasProps = { readonly children: ReactNode };
|
||||
|
||||
const readViewport = (): ViewportSize => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
// The fixed canvas deliberately prevents slide reflow; scenes always render
|
||||
// inside a 1280×720 coordinate system and the viewport scales proportionally.
|
||||
export const PresentationCanvas = ({ children }: PresentationCanvasProps) => {
|
||||
const [viewport, setViewport] = useState(readViewport);
|
||||
useEffect(() => {
|
||||
const resize = () => setViewport(readViewport());
|
||||
window.addEventListener("resize", resize);
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, []);
|
||||
const fit = fitPresentationCanvas(viewport);
|
||||
return (
|
||||
<div className="presentation-viewport">
|
||||
<div
|
||||
className="presentation-canvas"
|
||||
data-testid="presentation-canvas"
|
||||
style={{
|
||||
width: PRESENTATION_WIDTH,
|
||||
height: PRESENTATION_HEIGHT,
|
||||
left: fit.offsetX,
|
||||
top: fit.offsetY,
|
||||
transform: `scale(${fit.scale})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedR
|
||||
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
|
||||
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
|
||||
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||
import { PresentationCanvas } from "./PresentationCanvas.js";
|
||||
import { PresentationStage } from "./PresentationStage.js";
|
||||
import { PresenterControls } from "./PresenterControls.js";
|
||||
import { ChatDock } from "./ChatDock.js";
|
||||
@@ -185,21 +186,23 @@ export const PresentationRoute = () => {
|
||||
|
||||
return (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation" data-motion={state.motionDisabled ? "disabled" : "enabled"}>
|
||||
<PresentationStage
|
||||
state={state}
|
||||
demo={demo}
|
||||
evidence={evidence}
|
||||
messages={agent.messages}
|
||||
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
|
||||
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
|
||||
jump={handleJump}
|
||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||
openDiscussion={handleOpenDiscussion}
|
||||
closeDiscussion={handleCloseDiscussion}
|
||||
toggleDiscussionIndex={() => dispatch({ type: "toggle_discussion_index" })}
|
||||
/>
|
||||
<PresentationCanvas>
|
||||
<PresentationStage
|
||||
state={state}
|
||||
demo={demo}
|
||||
evidence={evidence}
|
||||
messages={agent.messages}
|
||||
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
|
||||
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
|
||||
jump={handleJump}
|
||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||
closeOverlay={() => dispatch({ type: "close_overlay" })}
|
||||
openDiscussion={handleOpenDiscussion}
|
||||
closeDiscussion={handleCloseDiscussion}
|
||||
toggleDiscussionIndex={() => dispatch({ type: "toggle_discussion_index" })}
|
||||
/>
|
||||
</PresentationCanvas>
|
||||
{showChatDock && <ChatDock openChat={() => dispatch({ type: "set_chat_mode", mode: "rail" })} />}
|
||||
{state.controlsOpen && (
|
||||
<PresenterControls
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fitPresentationCanvas } from "./canvas-fit.js";
|
||||
|
||||
describe("fitPresentationCanvas", () => {
|
||||
it.each([
|
||||
[{ width: 1280, height: 720 }, { scale: 1, offsetX: 0, offsetY: 0 }],
|
||||
[{ width: 1920, height: 1080 }, { scale: 1.5, offsetX: 0, offsetY: 0 }],
|
||||
[{ width: 1024, height: 768 }, { scale: 0.8, offsetX: 0, offsetY: 96 }],
|
||||
[{ width: 1600, height: 900 }, { scale: 1.25, offsetX: 0, offsetY: 0 }],
|
||||
])("fits %o without reflow", (viewport, expected) => {
|
||||
expect(fitPresentationCanvas(viewport)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns a bounded zero fit for an unavailable viewport", () => {
|
||||
expect(fitPresentationCanvas({ width: 0, height: 0 })).toEqual({
|
||||
scale: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export const PRESENTATION_WIDTH = 1280;
|
||||
export const PRESENTATION_HEIGHT = 720;
|
||||
|
||||
export type ViewportSize = {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
|
||||
export type CanvasFit = {
|
||||
readonly scale: number;
|
||||
readonly offsetX: number;
|
||||
readonly offsetY: number;
|
||||
};
|
||||
|
||||
export const fitPresentationCanvas = (viewport: ViewportSize): CanvasFit => {
|
||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
||||
return { scale: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
const scale = Math.min(
|
||||
viewport.width / PRESENTATION_WIDTH,
|
||||
viewport.height / PRESENTATION_HEIGHT,
|
||||
);
|
||||
return {
|
||||
scale,
|
||||
offsetX: (viewport.width - PRESENTATION_WIDTH * scale) / 2,
|
||||
offsetY: (viewport.height - PRESENTATION_HEIGHT * scale) / 2,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
@theme {
|
||||
--font-editorial: "Newsreader Variable", Georgia, serif;
|
||||
--font-interface: "Source Sans 3", sans-serif;
|
||||
--font-evidence: "IBM Plex Mono", monospace;
|
||||
--color-editorial-paper: oklch(0.975 0.012 82);
|
||||
--color-editorial-ink: oklch(0.19 0.015 65);
|
||||
--color-editorial-muted: oklch(0.48 0.025 65);
|
||||
--color-intent: oklch(0.53 0.17 250);
|
||||
--color-runtime: oklch(0.55 0.14 150);
|
||||
--color-human: oklch(0.68 0.17 55);
|
||||
}
|
||||
|
||||
.presentation-viewport {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: oklch(0.13 0.01 65);
|
||||
}
|
||||
|
||||
.presentation-canvas {
|
||||
position: absolute;
|
||||
transform-origin: top left;
|
||||
overflow: hidden;
|
||||
background: var(--color-editorial-paper);
|
||||
color: var(--color-editorial-ink);
|
||||
font-family: var(--font-interface);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const css = readFileSync(
|
||||
join(import.meta.dirname, "editorial.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("editorial Tailwind integration", () => {
|
||||
it("loads theme and utilities without Preflight", () => {
|
||||
expect(css).toContain('tailwindcss/theme.css');
|
||||
expect(css).toContain('tailwindcss/utilities.css');
|
||||
expect(css).not.toContain('tailwindcss/preflight.css');
|
||||
expect(css).not.toContain('@import "tailwindcss"');
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
"types": ["node", "vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const backendPort = process.env.WEB_PORT ?? "8787";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
// Server must listen on this port (set via WEB_PORT env or default 8787)
|
||||
|
||||
Reference in New Issue
Block a user