diff --git a/apps/web/package.json b/apps/web/package.json index bb6894ed..6f545fe0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@corbits-builder/client": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits/react-ui": "0.5.1", + "@corbits/slidedeck": "workspace:*", "@radix-ui/react-dialog": "1.3.1", "@radix-ui/react-dropdown-menu": "2.2.1", "@radix-ui/react-slot": "1.5.0", diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 6b8b2982..e8ab5be7 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -634,7 +634,9 @@ export const wording = { `Slide ${String(index)} of ${String(total)}`, pageCount: (index: number, total: number) => `Page ${String(index)} of ${String(total)}`, - noSlides: "This approval package contains no slides.", + loadingSlides: "Loading slides…", + slidesUnavailable: + "The slides could not load. Reload the page to try again.", speakerNotes: "Speaker notes", downloadPdf: "Download PDF", openPdf: "Open PDF", diff --git a/apps/web/src/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx index 1619adde..37e64cca 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.test.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; +import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact"; import { wording } from "../../config.ts"; import { @@ -9,6 +11,12 @@ import { parseApprovalPackage, } from "./approval-package-preview.tsx"; +// Waits for the lazily loaded slides, as a browser would. +async function render(node: ReactNode) { + const { prelude } = await prerender(node); + return new Response(prelude).text(); +} + const sampleBundle: ApprovalPackageBundle = { manifest: { audience: "Executive steering committee", @@ -97,8 +105,8 @@ describe("approval package preview", () => { expect(APPROVAL_PACKAGE_MEDIA_TYPE).toContain("approval-package+json"); }); - test("renders slides view with theme colors, comparison columns, and speaker notes", () => { - const html = renderToStaticMarkup( + test("renders slides view with theme colors, comparison columns, and speaker notes", async () => { + const html = await render( { expect(html).toContain("#1e293b"); }); - test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", () => { - const html = renderToStaticMarkup( + test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", async () => { + const html = await render( { expect(html).toContain("Customer Portal Approval Package"); }); - test("a PDF whose base64 does not decode offers no download and says it could not be read", () => { - const html = renderToStaticMarkup( + test("a PDF whose base64 does not decode offers no download and says it could not be read", async () => { + const html = await render( { expect(html).toContain("Page 1 of 2"); }); - test("enforces high-contrast text on dark-background slide decks", () => { + test("a deck that is not a valid slide deck says it could not be read in place of its slides and pages, and keeps the PDF download and sources", async () => { + const content = JSON.stringify({ + ...sampleBundle, + deck: { + theme: { colors: { background: "#ffffff" } }, + slides: [{ layout: "title", title: "Half a deck" }], + }, + }); + const views = await Promise.all( + (["slides", "pdf", "sources"] as const).map((tab) => + render( + , + ), + ), + ); + const [slides, pdf, sources] = views; + expect(slides).toContain(wording.approvalPackage.unreadable); + expect(slides).not.toContain("Half a deck"); + expect(pdf).toContain(wording.approvalPackage.unreadable); + expect(pdf).toContain(wording.approvalPackage.downloadPdf); + expect(pdf).not.toContain("Half a deck"); + expect(sources).toContain("Customer Portal Brief"); + }); + + test("draws text inside groups on free slides, and nested list items are numbered like their parent unless they say otherwise", async () => { + const html = await render( + , + ); + expect(html).toContain("

Grouped headline

"); + expect(html).toContain("Pilot
  1. Pick regions
"); + expect(html).toContain("Launch
  • Announce
"); + }); + + test("enforces high-contrast text on dark-background slide decks", async () => { const darkDeckBundle: ApprovalPackageBundle = { ...sampleBundle, deck: { @@ -161,6 +249,7 @@ describe("approval package preview", () => { text: "#f8fafc", muted: "#64748b", }, + fonts: { heading: "Inter", body: "Inter" }, }, slides: [ { @@ -172,7 +261,7 @@ describe("approval package preview", () => { ], }, }; - const html = renderToStaticMarkup( + const html = await render( { - return typeof value === "object" && value !== null && !Array.isArray(value); +function SlidesUnavailable() { + return

{text.slidesUnavailable}

; } -function normalizeHex(color: string | undefined): string | undefined { - if (color === undefined || color.trim() === "") return undefined; - const trimmed = color.trim(); - if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`; - return trimmed; -} - -function parseHexRgb(hex: string | undefined): [number, number, number] | null { - const normalized = normalizeHex(hex); - if (normalized === undefined) return null; - const raw = normalized.startsWith("#") ? normalized.slice(1) : normalized; - if (/^[0-9a-fA-F]{3}$/.test(raw)) { - const r = Number.parseInt(raw.charAt(0) + raw.charAt(0), 16); - const g = Number.parseInt(raw.charAt(1) + raw.charAt(1), 16); - const b = Number.parseInt(raw.charAt(2) + raw.charAt(2), 16); - return [r, g, b]; - } - if (/^[0-9a-fA-F]{6}$/.test(raw)) { - const r = Number.parseInt(raw.slice(0, 2), 16); - const g = Number.parseInt(raw.slice(2, 4), 16); - const b = Number.parseInt(raw.slice(4, 6), 16); - return [r, g, b]; +// The slide deck schema is costly to build, so it loads only once a package's slides are shown. +// A chunk that fails to load (a redeploy dropped its hash, the network, a blocker) would throw +// while rendering and take the whole app down, so it shows a line in the slides' place instead. +async function slidesModule() { + try { + return await import("./approval-package-slides.tsx"); + } catch (cause) { + if (!(cause instanceof TypeError)) throw cause; + console.warn("The approval package slides could not load.", cause); + return null; } - return null; -} - -function channelLuminance(channel: number): number { - const srgb = channel / 255; - return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; -} - -function relativeLuminance(hex: string | undefined): number | null { - const rgb = parseHexRgb(hex); - if (rgb === null) return null; - return ( - 0.2126 * channelLuminance(rgb[0]) + - 0.7152 * channelLuminance(rgb[1]) + - 0.0722 * channelLuminance(rgb[2]) - ); -} - -function pickLightOnDark( - color: string | undefined, - minLum: number, - fallback: string, -): string { - if (color === undefined) return fallback; - const lum = relativeLuminance(color); - return lum !== null && lum >= minLum ? color : fallback; } +const DeckSlides = lazy(async () => ({ + default: (await slidesModule())?.DeckSlides ?? SlidesUnavailable, +})); +const DeckPages = lazy(async () => ({ + default: (await slidesModule())?.DeckPages ?? SlidesUnavailable, +})); -function pickDarkOnLight( - color: string | undefined, - maxLum: number, - fallback: string, -): string { - if (color === undefined) return fallback; - const lum = relativeLuminance(color); - return lum !== null && lum <= maxLum ? color : fallback; -} - -// Dark-background decks can still declare dark brand tokens for primary/secondary; -// clamping by relative luminance keeps headings and subtitles legible on the stage. -function resolveSlidePalette(theme: DeckTheme): ResolvedSlidePalette { - const colors = theme.colors; - const rawBg = normalizeHex(colors?.background); - const bgLum = relativeLuminance(rawBg); - const isDark = bgLum !== null && bgLum < 0.25; - - const primary = normalizeHex(colors?.primary); - const secondary = normalizeHex(colors?.secondary ?? colors?.muted); - const accent = normalizeHex(colors?.accent ?? colors?.primary); - const rawText = normalizeHex(colors?.text); - - if (isDark) { - const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8"); - return { - isDark: true, - background: rawBg === undefined ? "#0f172a" : rawBg, - text: pickLightOnDark(rawText, 0.55, "#f8fafc"), - heading: pickLightOnDark(primary, 0.55, "#f8fafc"), - subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"), - accentBar: pickLightOnDark(accent, 0.25, brightPrimary), - columnHeading: pickLightOnDark(accent, 0.35, brightPrimary), - columnBg: "rgba(255, 255, 255, 0.055)", - columnBorder: "rgba(255, 255, 255, 0.14)", - }; - } - - return { - isDark: false, - background: rawBg === undefined ? "#ffffff" : rawBg, - text: pickDarkOnLight(rawText, 0.35, "#0f172a"), - heading: pickDarkOnLight(primary, 0.45, "#0f172a"), - subtitle: pickDarkOnLight(secondary, 0.45, "#475569"), - accentBar: primary ?? accent ?? "#0284c7", - columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"), - columnBg: "rgba(15, 23, 42, 0.035)", - columnBorder: "rgba(15, 23, 42, 0.1)", - }; -} +const loadingSlides =

{text.loadingSlides}

; export function parseApprovalPackage( content: string, @@ -221,348 +80,11 @@ export function approvalPackagePdfFile( return new File([bytes], `${stem}.pdf`, { type: "application/pdf" }); } -function readDeckTheme(deck: Record): DeckTheme { - const rawTheme = deck["theme"]; - if (!isRecord(rawTheme)) { - return { colors: undefined, fonts: undefined }; - } - const colors = isRecord(rawTheme["colors"]) - ? (rawTheme["colors"] as DeckThemeColors) - : undefined; - const fonts = isRecord(rawTheme["fonts"]) - ? (rawTheme["fonts"] as DeckTheme["fonts"]) - : undefined; - return { colors, fonts }; -} - -function readDeckSlides(deck: Record): DeckSlide[] { - const rawSlides = deck["slides"]; - if (!Array.isArray(rawSlides)) return []; - return rawSlides.filter(isRecord) as DeckSlide[]; -} - -function slideStageStyle( - theme: DeckTheme, - palette: ResolvedSlidePalette, -): CSSProperties { - const style: Record = { - backgroundColor: palette.background, - color: palette.text, - "--slide-bg": palette.background, - "--slide-fg": palette.text, - "--slide-heading": palette.heading, - "--slide-subtitle": palette.subtitle, - "--slide-accent": palette.accentBar, - "--slide-column-bg": palette.columnBg, - "--slide-column-border": palette.columnBorder, - }; - if (theme.fonts?.body !== undefined) { - style["fontFamily"] = theme.fonts.body; - } - return style as CSSProperties; -} - -function renderRichRun(run: unknown, key: number): ReactNode { - if (typeof run === "string") return run; - if (!isRecord(run) || typeof run["text"] !== "string") return null; - let node: ReactNode = run["text"]; - if (run["bold"] === true || Number(run["weight"]) >= 600) { - node = {node}; - } - if (run["italic"] === true) { - node = {node}; - } - if (run["underline"] === true) { - node = {node}; - } - if (run["strike"] === true) { - node = {node}; - } - return {node}; -} - -function renderRichText(value: unknown): ReactNode { - if (typeof value === "string") return value; - if (Array.isArray(value)) { - return value.map((run, index) => renderRichRun(run, index)); - } - return null; -} - -function renderNestedList(nestedItems: unknown[], ordered: boolean): ReactNode { - if (nestedItems.length === 0) return null; - if (ordered) { - return
    {renderListItems(nestedItems)}
; - } - return
    {renderListItems(nestedItems)}
; -} - -function renderListItems(items: unknown[]) { - return items.map((item, index) => { - if (isRecord(item) && "text" in item) { - const nestedOrdered = item["ordered"] === true; - return ( -
  • - {renderRichText(item["text"])} - {Array.isArray(item["items"]) - ? renderNestedList(item["items"], nestedOrdered) - : null} -
  • - ); - } - return
  • {renderRichText(item)}
  • ; - }); -} - -function SlideBodyBlockView({ block }: { block: SlideBodyBlock }) { - if (typeof block === "string") return

    {block}

    ; - const blockType = block.type ?? block.kind; - if (blockType === "paragraph") { - const rendered = renderRichText(block.text); - return rendered === null ? null :

    {rendered}

    ; - } - if (blockType === "list" && Array.isArray(block.items)) { - if (block.ordered === true) { - return
      {renderListItems(block.items)}
    ; - } - return
      {renderListItems(block.items)}
    ; - } - const fallback = renderRichText(block.text); - return fallback === null ? null :

    {fallback}

    ; -} - -function SlideBodyContent({ body }: { body: unknown }) { - if (typeof body === "string") { - return ( -
    -

    {body}

    -
    - ); - } - if (!Array.isArray(body) || body.length === 0) return null; - return ( -
    - {body.map((block, index) => ( - - ))} -
    - ); -} - -function SlideColumns({ - slide, - headingColor, -}: { - slide: DeckSlide; - headingColor: string; -}) { - if (slide.layout === "two-column") { - return ( -
    -
    - -
    -
    - -
    -
    - ); - } - if (slide.layout === "comparison") { - const leftSide = isRecord(slide.left) ? slide.left : undefined; - const rightSide = isRecord(slide.right) ? slide.right : undefined; - const leftHeading = - typeof leftSide?.["heading"] === "string" - ? leftSide["heading"] - : undefined; - const rightHeading = - typeof rightSide?.["heading"] === "string" - ? rightSide["heading"] - : undefined; - return ( -
    -
    - {leftHeading === undefined ? null : ( -

    - {leftHeading} -

    - )} - -
    -
    - {rightHeading === undefined ? null : ( -

    - {rightHeading} -

    - )} - -
    -
    - ); - } - return null; -} - -function collectFreeParagraphs(elements: unknown[]): ReactNode[] { - const out: ReactNode[] = []; - for (const [elIdx, el] of elements.entries()) { - if (!isRecord(el)) continue; - if (el["type"] === "group" && Array.isArray(el["children"])) { - out.push(...collectFreeParagraphs(el["children"])); - continue; - } - if (el["type"] === "text" && Array.isArray(el["paragraphs"])) { - for (const [pIdx, para] of el["paragraphs"].entries()) { - if (!isRecord(para) || !Array.isArray(para["runs"])) continue; - out.push( -

    - {renderRichText(para["runs"])} -

    , - ); - } - } - } - return out; -} - -function SlideCanvas({ - slide, - index, - total, - theme, - pageFooter, -}: { - slide: DeckSlide; - index: number; - total: number; - theme: DeckTheme; - pageFooter?: string; -}) { - const palette = resolveSlidePalette(theme); - const layout = slide.layout ?? "content"; - const titleStyle: CSSProperties = { color: palette.heading }; - if (theme.fonts?.heading !== undefined) { - titleStyle.fontFamily = theme.fonts.heading; - } - const freeParagraphs = - layout === "free" && Array.isArray(slide.elements) - ? collectFreeParagraphs(slide.elements) - : []; - return ( -
    - - ); -} - -function SlideCard({ - slide, - index, - total, - theme, -}: { - slide: DeckSlide; - index: number; - total: number; - theme: DeckTheme; -}) { - return ( -
    -
    - - {text.slideCount(index + 1, total)} - - {typeof slide.layout === "string" ? ( - {slide.layout} - ) : null} -
    - -
    - ); -} - export function SlidesView({ deck }: { deck: Record }) { - const theme = readDeckTheme(deck); - const slides = readDeckSlides(deck); - if (slides.length === 0) { - return

    {text.noSlides}

    ; - } return ( -
    - {slides.map((slide, index) => ( - - ))} -
    + + + ); } @@ -602,9 +124,6 @@ function PdfView({ : bundle.pdfBase64; const pdfHref = blobUrl ?? fallbackHref; - const theme = readDeckTheme(bundle.deck); - const slides = readDeckSlides(bundle.deck); - return (
    @@ -638,23 +157,9 @@ function PdfView({
    )}
    - {slides.length === 0 ? ( -

    {text.noSlides}

    - ) : ( -
    - {slides.map((slide, index) => ( -
    - -
    - ))} -
    - )} + + +
    ); } @@ -785,7 +290,7 @@ export function ApprovalPackagePreview({ }) { const [activeTab, setActiveTab] = useState(initialTab); - const bundle = parseApprovalPackage(content); + const bundle = useMemo(() => parseApprovalPackage(content), [content]); if (bundle === null) { return

    {text.unreadable}

    ; } diff --git a/apps/web/src/pages/workspace/approval-package-slides.tsx b/apps/web/src/pages/workspace/approval-package-slides.tsx new file mode 100644 index 00000000..259d2c7a --- /dev/null +++ b/apps/web/src/pages/workspace/approval-package-slides.tsx @@ -0,0 +1,443 @@ +import { Badge } from "@corbits/react-ui"; +import { + freeElements, + validateDeck, + type Block, + type Deck, + type FreeElement, + type FreeRun, + type ListItem, + type RichText, + type Slide, + type Theme, +} from "@corbits/slidedeck/deck"; +import { useMemo, type CSSProperties, type ReactNode } from "react"; +import { wording } from "../../config.ts"; + +const text = wording.approvalPackage; + +interface ResolvedSlidePalette { + isDark: boolean; + background: string; + text: string; + heading: string; + subtitle: string; + accentBar: string; + columnHeading: string; + columnBg: string; + columnBorder: string; +} + +function normalizeHex(color: string): string { + return color.startsWith("#") ? color : `#${color}`; +} + +function channelLuminance(channel: number): number { + const srgb = channel / 255; + return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(hex: string): number { + const raw = hex.slice(1); + return ( + 0.2126 * channelLuminance(Number.parseInt(raw.slice(0, 2), 16)) + + 0.7152 * channelLuminance(Number.parseInt(raw.slice(2, 4), 16)) + + 0.0722 * channelLuminance(Number.parseInt(raw.slice(4, 6), 16)) + ); +} + +function pickLightOnDark( + color: string, + minLum: number, + fallback: string, +): string { + return relativeLuminance(color) >= minLum ? color : fallback; +} + +function pickDarkOnLight( + color: string, + maxLum: number, + fallback: string, +): string { + return relativeLuminance(color) <= maxLum ? color : fallback; +} + +// Dark-background decks can still declare dark brand tokens for primary/secondary; +// clamping by relative luminance keeps headings and subtitles legible on the stage. +function resolveSlidePalette(theme: Theme): ResolvedSlidePalette { + const background = normalizeHex(theme.colors.background); + const primary = normalizeHex(theme.colors.primary); + const secondary = normalizeHex(theme.colors.secondary); + const accent = normalizeHex(theme.colors.accent); + const rawText = normalizeHex(theme.colors.text); + + if (relativeLuminance(background) < 0.25) { + const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8"); + return { + isDark: true, + background, + text: pickLightOnDark(rawText, 0.55, "#f8fafc"), + heading: pickLightOnDark(primary, 0.55, "#f8fafc"), + subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"), + accentBar: pickLightOnDark(accent, 0.25, brightPrimary), + columnHeading: pickLightOnDark(accent, 0.35, brightPrimary), + columnBg: "rgba(255, 255, 255, 0.055)", + columnBorder: "rgba(255, 255, 255, 0.14)", + }; + } + + return { + isDark: false, + background, + text: pickDarkOnLight(rawText, 0.35, "#0f172a"), + heading: pickDarkOnLight(primary, 0.45, "#0f172a"), + subtitle: pickDarkOnLight(secondary, 0.45, "#475569"), + accentBar: primary, + columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"), + columnBg: "rgba(15, 23, 42, 0.035)", + columnBorder: "rgba(15, 23, 42, 0.1)", + }; +} + +function readDeck(value: unknown): Deck | null { + const validated = validateDeck(value); + return validated.ok ? validated.deck : null; +} + +function slideStageStyle( + theme: Theme, + palette: ResolvedSlidePalette, +): CSSProperties { + const style: Record = { + backgroundColor: palette.background, + color: palette.text, + fontFamily: theme.fonts.body, + "--slide-bg": palette.background, + "--slide-fg": palette.text, + "--slide-heading": palette.heading, + "--slide-subtitle": palette.subtitle, + "--slide-accent": palette.accentBar, + "--slide-column-bg": palette.columnBg, + "--slide-column-border": palette.columnBorder, + }; + return style as CSSProperties; +} + +interface Emphasis { + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; +} + +function renderEmphasis(text: string, emphasis: Emphasis, key: number) { + let node: ReactNode = text; + if (emphasis.bold) node = {node}; + if (emphasis.italic) node = {node}; + if (emphasis.underline) node = {node}; + if (emphasis.strike) node = {node}; + return {node}; +} + +function renderRichText(value: RichText): ReactNode { + if (typeof value === "string") return value; + return value.map((run, index) => + typeof run === "string" + ? run + : renderEmphasis( + run.text, + { + bold: run.bold === true, + italic: run.italic === true, + underline: run.underline === true, + strike: run.strike === true, + }, + index, + ), + ); +} + +function renderFreeRuns(runs: readonly (string | FreeRun)[]): ReactNode { + return runs.map((run, index) => + typeof run === "string" + ? run + : renderEmphasis( + run.text, + { + bold: run.weight !== undefined && run.weight >= 600, + italic: run.italic === true, + underline: run.underline === true, + strike: run.strike === true, + }, + index, + ), + ); +} + +function renderList(items: readonly ListItem[], ordered: boolean): ReactNode { + const rendered = items.map((item, index) => { + if (typeof item === "string" || Array.isArray(item)) { + return
  • {renderRichText(item)}
  • ; + } + return ( +
  • + {renderRichText(item.text)} + {renderList(item.items, item.ordered ?? ordered)} +
  • + ); + }); + return ordered ?
      {rendered}
    :
      {rendered}
    ; +} + +function SlideBlock({ block }: { block: Block }) { + switch (block.type) { + case "paragraph": + return

    {renderRichText(block.text)}

    ; + case "list": + return renderList(block.items, block.ordered); + default: + return null; + } +} + +function SlideBlocks({ blocks }: { blocks: readonly Block[] }) { + if (blocks.length === 0) return null; + return ( +
    + {blocks.map((block, index) => ( + + ))} +
    + ); +} + +function SlideColumn({ + heading, + blocks, + headingColor, +}: { + heading?: string; + blocks: readonly Block[]; + headingColor: string; +}) { + return ( +
    + {heading === undefined ? null : ( +

    + {heading} +

    + )} + +
    + ); +} + +function FreeText({ elements }: { elements: readonly FreeElement[] }) { + const paragraphs: ReactNode[] = []; + for (const [element, path] of freeElements(elements, [])) { + if (element.type !== "text") continue; + for (const [index, paragraph] of element.paragraphs.entries()) { + paragraphs.push( +

    + {renderFreeRuns(paragraph.runs)} +

    , + ); + } + } + if (paragraphs.length === 0) return null; + return
    {paragraphs}
    ; +} + +function SlideContent({ + slide, + headingColor, +}: { + slide: Slide; + headingColor: string; +}) { + switch (slide.layout) { + case "content": + case "blank": + return ; + case "two-column": + return ( +
    + + +
    + ); + case "comparison": + return ( +
    + + +
    + ); + case "free": + return ; + default: + return null; + } +} + +function SlideCanvas({ + slide, + index, + total, + theme, + pageFooter, +}: { + slide: Slide; + index: number; + total: number; + theme: Theme; + pageFooter?: string; +}) { + const palette = resolveSlidePalette(theme); + const title = "title" in slide ? slide.title : undefined; + const subtitle = "subtitle" in slide ? slide.subtitle : undefined; + const caption = slide.layout === "image" ? slide.caption : undefined; + return ( +
    + + ); +} + +function SlideCard({ + slide, + index, + total, + theme, +}: { + slide: Slide; + index: number; + total: number; + theme: Theme; +}) { + return ( +
    +
    + + {text.slideCount(index + 1, total)} + + {slide.layout} +
    + +
    + ); +} + +/** The package's slides as cards, or the unreadable line when the deck is not a valid slide deck. */ +export function DeckSlides({ deck }: { deck: Record }) { + const readable = useMemo(() => readDeck(deck), [deck]); + if (readable === null) { + return

    {text.unreadable}

    ; + } + return ( +
    + {readable.slides.map((slide, index) => ( + + ))} +
    + ); +} + +/** The package's slides as print sheets, or the unreadable line when the deck is not a valid slide deck. */ +export function DeckPages({ deck }: { deck: Record }) { + const readable = useMemo(() => readDeck(deck), [deck]); + if (readable === null) { + return

    {text.unreadable}

    ; + } + return ( +
    + {readable.slides.map((slide, index) => ( +
    + +
    + ))} +
    + ); +} diff --git a/apps/web/src/pages/workspace/approval-rounds.test.tsx b/apps/web/src/pages/workspace/approval-rounds.test.tsx index a1b30aff..7367506d 100644 --- a/apps/web/src/pages/workspace/approval-rounds.test.tsx +++ b/apps/web/src/pages/workspace/approval-rounds.test.tsx @@ -1,6 +1,7 @@ -import { describe, expect, mock, test } from "bun:test"; +import { beforeAll, describe, expect, mock, test } from "bun:test"; import React, { isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; import { ThemeProvider } from "@corbits/react-ui"; import { QueryClientProvider } from "@tanstack/react-query"; import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact"; @@ -212,6 +213,7 @@ mock.module("../../api/artifacts.ts", () => ({ const { ApprovalRoundsPanel, SharePackageDialog } = await import("./approval-rounds.tsx"); +const { SlidesView } = await import("./approval-package-preview.tsx"); const openRound: ApprovalRound = { roundId: "rnd_1", @@ -274,6 +276,17 @@ const sampleBundle: ApprovalPackageBundle = { ], }, deck: { + theme: { + colors: { + primary: "#1e293b", + secondary: "#334155", + accent: "#2563eb", + background: "#ffffff", + text: "#0f172a", + muted: "#64748b", + }, + fonts: { heading: "Inter", body: "Inter" }, + }, slides: [ { layout: "title", @@ -606,6 +619,11 @@ function createStatefulHarness(renderRoot: () => ReactNode) { } describe("approval rounds panel", () => { + // Loads the lazily split slides once, as a browser would, so synchronous renders draw them. + beforeAll(async () => { + await prerender(); + }); + test("renders open round with reviewer states, Add new reviewer button, per-person Download package buttons, inline feedback triage with Incorporate and Dismiss (no Reply), and disabled settle button while a reviewer is pending", () => { const html = renderToStaticMarkup( { decisionNeeded: "Approve Q3 executive budget", }, deck: { + ...sampleBundle.deck, slides: [ { layout: "title", @@ -687,6 +706,7 @@ describe("approval rounds panel", () => { decisionNeeded: "Approve technical architecture", }, deck: { + ...sampleBundle.deck, slides: [ { layout: "title", diff --git a/apps/web/src/pages/workspace/approval-rounds.tsx b/apps/web/src/pages/workspace/approval-rounds.tsx index ee0376ae..f6e987ae 100644 --- a/apps/web/src/pages/workspace/approval-rounds.tsx +++ b/apps/web/src/pages/workspace/approval-rounds.tsx @@ -891,7 +891,7 @@ function PaneSlideDeckView({
    {bundle === null ? ( -

    {wording.approvalPackage.noSlides}

    +

    {wording.approvalPackage.unreadable}

    ) : ( )} @@ -902,7 +902,9 @@ function PaneSlideDeckView({
    {bundle === null ? ( -

    {wording.approvalPackage.noSlides}

    +

    + {wording.approvalPackage.unreadable} +

    ) : ( )} diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx index 1b25798a..7bec9c06 100644 --- a/apps/web/src/pages/workspace/artifact-pane.test.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; +import { prerender } from "react-dom/static"; import type { ProjectArtifact } from "@corbits-builder/client"; import { ArtifactPane, @@ -8,7 +9,10 @@ import { defaultTab, resolveSelection, } from "./artifact-pane.tsx"; -import { APPROVAL_PACKAGE_MEDIA_TYPE } from "./approval-package-preview.tsx"; +import { + APPROVAL_PACKAGE_MEDIA_TYPE, + SlidesView, +} from "./approval-package-preview.tsx"; import type { DeliverableState } from "./deliverable-block.tsx"; import { writeWorkspaceApprovalsEnabled } from "../../workspace-approvals-setting.ts"; @@ -55,6 +59,11 @@ function render( } describe("persistent artifact pane", () => { + // Loads the lazily split slides once, as a browser would, so synchronous renders draw them. + beforeAll(async () => { + await prerender(); + }); + test("exists before artifacts without modal or Expand controls", () => { const html = render(null); expect(html).toContain('aria-label="Artifacts"'); @@ -160,6 +169,17 @@ describe("persistent artifact pane", () => { ], }, deck: { + theme: { + colors: { + primary: "#1e293b", + secondary: "#334155", + accent: "#2563eb", + background: "#ffffff", + text: "#0f172a", + muted: "#64748b", + }, + fonts: { heading: "Inter", body: "Inter" }, + }, slides: [ { layout: "title", diff --git a/bun.lock b/bun.lock index b49d7df0..0c3bd480 100644 --- a/bun.lock +++ b/bun.lock @@ -124,6 +124,7 @@ "@corbits-builder/client": "workspace:*", "@corbits-builder/schemas": "workspace:*", "@corbits/react-ui": "0.5.1", + "@corbits/slidedeck": "workspace:*", "@radix-ui/react-dialog": "1.3.1", "@radix-ui/react-dropdown-menu": "2.2.1", "@radix-ui/react-slot": "1.5.0", diff --git a/packages/slidedeck/package.json b/packages/slidedeck/package.json index 25be8b68..15a61897 100644 --- a/packages/slidedeck/package.json +++ b/packages/slidedeck/package.json @@ -6,7 +6,8 @@ "author": "Sawyer Cutler ", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./deck": "./src/deck.ts" }, "dependencies": { "@corbits/pdf": "workspace:*",