feat: render presentation stage shell
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
import { presentationBeats, type BeatId } from "./beats.js";
|
||||||
|
|
||||||
|
type BeatRailProps = {
|
||||||
|
readonly activeBeat: BeatId;
|
||||||
|
readonly jump: (beat: BeatId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
|
||||||
|
<nav className="beat-rail" aria-label="presentation beat rail">
|
||||||
|
{presentationBeats.map((beat) => (
|
||||||
|
<button
|
||||||
|
key={beat.id}
|
||||||
|
type="button"
|
||||||
|
data-active={beat.id === activeBeat}
|
||||||
|
onClick={() => jump(beat.id)}
|
||||||
|
>
|
||||||
|
<span>{beat.lifecycleStep}</span>
|
||||||
|
<small>{beat.title}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
|
|
||||||
|
type OperatorChatProps = {
|
||||||
|
readonly state: PresentationState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OperatorChat = ({ state }: OperatorChatProps) => (
|
||||||
|
<aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
|
||||||
|
<div className="chat-message chat-message--operator">
|
||||||
|
<strong>Operator</strong>
|
||||||
|
<p>Prepare the thesis readiness report.</p>
|
||||||
|
</div>
|
||||||
|
<div className="chat-message chat-message--system">
|
||||||
|
<strong>lda.chat</strong>
|
||||||
|
<p>Found prepared workflow recipe: <code>lda_report_case_study</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div className="chat-message chat-message--system">
|
||||||
|
<strong>lda.chat</strong>
|
||||||
|
<p>Replay mode is active. Live execution is available when connected.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
@@ -21,4 +21,12 @@ describe("PresentationRoute", () => {
|
|||||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
|
||||||
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
|
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders replay-first chat, beat rail, and stage caption", () => {
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Prepare the thesis readiness report.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/presentation beat rail/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Replay/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useReducer } from "react";
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
|
import type { EvidenceRecord } from "../app/state.js";
|
||||||
|
import { useDemoTimeline } from "../demo/useDemoTimeline.js";
|
||||||
import { hashForBeat, presentationBeats } from "./beats.js";
|
import { hashForBeat, presentationBeats } from "./beats.js";
|
||||||
|
import { PresentationStage } from "./PresentationStage.js";
|
||||||
import {
|
import {
|
||||||
initialPresentationState,
|
initialPresentationState,
|
||||||
presentationReducer,
|
presentationReducer,
|
||||||
} from "./presentation-state.js";
|
} from "./presentation-state.js";
|
||||||
|
import "./presentation.css";
|
||||||
|
|
||||||
export const PresentationRoute = () => {
|
export const PresentationRoute = () => {
|
||||||
const [state, dispatch] = useReducer(
|
const [state, dispatch] = useReducer(
|
||||||
@@ -12,6 +16,12 @@ export const PresentationRoute = () => {
|
|||||||
(initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }),
|
(initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [_evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]);
|
||||||
|
const recordEvidence = useCallback((record: EvidenceRecord) => {
|
||||||
|
setEvidence((records) => [...records, record]);
|
||||||
|
}, []);
|
||||||
|
const demo = useDemoTimeline(null, recordEvidence);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = hashForBeat(state.beat);
|
const hash = hashForBeat(state.beat);
|
||||||
if (window.location.hash !== hash) {
|
if (window.location.hash !== hash) {
|
||||||
@@ -35,11 +45,13 @@ export const PresentationRoute = () => {
|
|||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||||
<p>{beat.caption}</p>
|
<PresentationStage
|
||||||
|
state={state}
|
||||||
|
demo={demo}
|
||||||
|
jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { presentationBeats, type BeatId } from "./beats.js";
|
||||||
|
import { BeatRail } from "./BeatRail.js";
|
||||||
|
import { OperatorChat } from "./OperatorChat.js";
|
||||||
|
import { StageCaption } from "./StageCaption.js";
|
||||||
|
import type { PresentationState } from "./presentation-state.js";
|
||||||
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
|
|
||||||
|
type PresentationStageProps = {
|
||||||
|
readonly state: PresentationState;
|
||||||
|
readonly demo: DemoTimelineController;
|
||||||
|
readonly jump: (beat: BeatId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PresentationStage = ({ state, demo, jump }: PresentationStageProps) => {
|
||||||
|
const beat = presentationBeats.find((candidate) => candidate.id === state.beat) ?? presentationBeats[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="presentation-stage" data-beat={state.beat}>
|
||||||
|
<OperatorChat state={state} />
|
||||||
|
<section className="presentation-stage__main">
|
||||||
|
<StageCaption eyebrow="lda.chat defense" title={beat.title}>
|
||||||
|
<p>{beat.caption}</p>
|
||||||
|
</StageCaption>
|
||||||
|
<p className="presentation-stage__mode">
|
||||||
|
{demo.state.mode === "replay" ? "Replay" : "Live"} · {demo.state.phase}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<BeatRail activeBeat={state.beat} jump={jump} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type StageCaptionProps = {
|
||||||
|
readonly eyebrow: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StageCaption = ({ eyebrow, title, children }: StageCaptionProps) => (
|
||||||
|
<section className="stage-caption" aria-label={title}>
|
||||||
|
<p className="stage-caption__eyebrow">{eyebrow}</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.presentation-route {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: oklch(0.12 0.025 250);
|
||||||
|
color: oklch(0.96 0.01 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-stage {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr auto;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presentation-stage__main {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-chat {
|
||||||
|
max-width: 48rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-chat[data-mode="rail"] {
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
border: 1px solid oklch(0.34 0.035 250);
|
||||||
|
background: oklch(0.18 0.025 250);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-rail {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-rail button {
|
||||||
|
min-width: 8rem;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid oklch(0.36 0.04 250);
|
||||||
|
background: oklch(0.18 0.025 250);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beat-rail button[data-active="true"] {
|
||||||
|
background: oklch(0.7 0.16 195);
|
||||||
|
color: oklch(0.12 0.025 250);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user