From ec84e1c94604b5ad138d8da707e7db0f574d3309 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 13:25:07 -0800 Subject: [PATCH 1/3] fix(web): read approval-package decks with the slidedeck schema --- apps/web/package.json | 1 + .../approval-package-preview.test.tsx | 19 + .../workspace/approval-package-preview.tsx | 563 ++++++++---------- .../pages/workspace/approval-rounds.test.tsx | 13 + .../pages/workspace/artifact-pane.test.tsx | 11 + bun.lock | 1 + packages/slidedeck/package.json | 3 +- 7 files changed, 284 insertions(+), 327 deletions(-) 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/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx index 1619adde..02359e4b 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.test.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx @@ -148,6 +148,24 @@ describe("approval package preview", () => { expect(html).toContain("Page 1 of 2"); }); + test("a deck that is not a valid slide deck says it could not be read instead of drawing slides", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain(wording.approvalPackage.unreadable); + expect(html).not.toContain("Half a deck"); + }); + test("enforces high-contrast text on dark-background slide decks", () => { const darkDeckBundle: ApprovalPackageBundle = { ...sampleBundle, @@ -161,6 +179,7 @@ describe("approval package preview", () => { text: "#f8fafc", muted: "#64748b", }, + fonts: { heading: "Inter", body: "Inter" }, }, slides: [ { diff --git a/apps/web/src/pages/workspace/approval-package-preview.tsx b/apps/web/src/pages/workspace/approval-package-preview.tsx index b5add97b..942cc3f5 100644 --- a/apps/web/src/pages/workspace/approval-package-preview.tsx +++ b/apps/web/src/pages/workspace/approval-package-preview.tsx @@ -3,6 +3,18 @@ import { ApprovalPackageBundle, type ApprovalPackageSource, } from "@corbits-builder/schemas/artifact"; +import { + freeElements, + validateDeck, + type Block, + type Deck, + type FreeElement, + type FreeRun, + type ListItem, + type RichText, + type Slide, + type Theme, +} from "@corbits/slidedeck/deck"; import { type } from "arktype"; import { useEffect, @@ -21,25 +33,6 @@ const text = wording.approvalPackage; export type ApprovalPackageViewTab = "slides" | "pdf" | "sources"; -interface DeckThemeColors { - primary?: string; - secondary?: string; - accent?: string; - background?: string; - text?: string; - muted?: string; -} - -interface DeckTheme { - colors?: DeckThemeColors | undefined; - fonts?: - | { - heading?: string; - body?: string; - } - | undefined; -} - interface ResolvedSlidePalette { isDark: boolean; background: string; @@ -52,57 +45,8 @@ interface ResolvedSlidePalette { columnBorder: string; } -type SlideBodyBlock = - | string - | { - type?: string; - kind?: string; - text?: unknown; - ordered?: boolean; - items?: unknown[]; - }; - -interface DeckSlide { - layout?: string; - title?: string; - subtitle?: string; - body?: string | SlideBodyBlock[]; - left?: unknown; - right?: unknown; - elements?: unknown[]; - caption?: string; - notes?: string; - speakerNotes?: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -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]; - } - return null; +function normalizeHex(color: string): string { + return color.startsWith("#") ? color : `#${color}`; } function channelLuminance(channel: number): number { @@ -110,54 +54,45 @@ function channelLuminance(channel: number): number { 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; +function relativeLuminance(hex: string): number { + const raw = hex.slice(1); return ( - 0.2126 * channelLuminance(rgb[0]) + - 0.7152 * channelLuminance(rgb[1]) + - 0.0722 * channelLuminance(rgb[2]) + 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 | undefined, + color: string, minLum: number, fallback: string, ): string { - if (color === undefined) return fallback; - const lum = relativeLuminance(color); - return lum !== null && lum >= minLum ? color : fallback; + return relativeLuminance(color) >= minLum ? color : fallback; } function pickDarkOnLight( - color: string | undefined, + color: string, maxLum: number, fallback: string, ): string { - if (color === undefined) return fallback; - const lum = relativeLuminance(color); - return lum !== null && lum <= maxLum ? color : fallback; + 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: 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) { +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: rawBg === undefined ? "#0f172a" : rawBg, + background, text: pickLightOnDark(rawText, 0.55, "#f8fafc"), heading: pickLightOnDark(primary, 0.55, "#f8fafc"), subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"), @@ -170,11 +105,11 @@ function resolveSlidePalette(theme: DeckTheme): ResolvedSlidePalette { return { isDark: false, - background: rawBg === undefined ? "#ffffff" : rawBg, + background, text: pickDarkOnLight(rawText, 0.35, "#0f172a"), heading: pickDarkOnLight(primary, 0.45, "#0f172a"), subtitle: pickDarkOnLight(secondary, 0.45, "#475569"), - accentBar: primary ?? accent ?? "#0284c7", + accentBar: primary, columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"), columnBg: "rgba(15, 23, 42, 0.035)", columnBorder: "rgba(15, 23, 42, 0.1)", @@ -221,33 +156,19 @@ 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 readDeck(value: unknown): Deck | null { + const validated = validateDeck(value); + return validated.ok ? validated.deck : null; } function slideStageStyle( - theme: DeckTheme, + 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, @@ -256,179 +177,175 @@ function slideStageStyle( "--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}; - } +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: unknown): ReactNode { +function renderRichText(value: RichText): ReactNode { if (typeof value === "string") return value; - if (Array.isArray(value)) { - return value.map((run, index) => renderRichRun(run, index)); - } - return null; + 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 renderNestedList(nestedItems: unknown[], ordered: boolean): ReactNode { - if (nestedItems.length === 0) return null; - if (ordered) { - return
    {renderListItems(nestedItems)}
; - } - return
    {renderListItems(nestedItems)}
; +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 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} -
  • - ); +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)}
  • ; + return ( +
  • + {renderRichText(item.text)} + {renderList(item.items, item.ordered === true)} +
  • + ); }); + return ordered ?
      {rendered}
    :
      {rendered}
    ; } -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}

    ; +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; } - 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; +function SlideBlocks({ blocks }: { blocks: readonly Block[] }) { + if (blocks.length === 0) return null; return (
    - {body.map((block, index) => ( - + {blocks.map((block, index) => ( + ))}
    ); } -function SlideColumns({ - slide, +function SlideColumn({ + heading, + blocks, headingColor, }: { - slide: DeckSlide; + heading?: string; + blocks: readonly Block[]; 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; + return ( +
    + {heading === undefined ? null : ( +

    + {heading} +

    + )} + +
    + ); } -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"])} -

    , - ); - } +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)} +

    , + ); } } - return out; + 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({ @@ -438,27 +355,21 @@ function SlideCanvas({ theme, pageFooter, }: { - slide: DeckSlide; + slide: Slide; index: number; total: number; - theme: DeckTheme; + theme: Theme; 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) - : []; + 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 (