Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -979,12 +979,21 @@ def _latest_compose_key(prefix: str, keys: set) -> Optional[str]:
pass
specs["artDirection"] = art_direction_content

# Confirmed template name from deck.json (written by the agent at Art Direction)
template = None
try:
dj_resp = s3_client.get_object(Bucket=BUCKET_NAME, Key=f"decks/{deck_id}/deck.json")
template = json.loads(dj_resp["Body"].read()).get("template") or None
except Exception:
pass

return {
"deckId": deck_id,
"name": deck.get("name", "Untitled"),
"slideCount": len(slides),
"slides": slides,
"specs": specs,
"template": template,
"defsUrl": preview_url(defs_key) if has_defs else None,
"pptxUrl": (_cf_signed_url(pptx_key) or presigned_url(s3_client, BUCKET_NAME, pptx_key)) if pptx_key else None,
"updatedAt": deck.get("updatedAt", ""),
Expand Down
7 changes: 7 additions & 0 deletions web-ui/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -407,5 +407,12 @@
"pinAria": "Pin {name}",
"unpinAria": "Unpin {name}",
"custom": "Custom"
},
"templatePicker": {
"sectionTitle": "Template",
"useTemplate": "Use this template",
"useTemplateAria": "Use the {name} template",
"currentLabel": "In use: {name}",
"custom": "Custom"
}
}
7 changes: 7 additions & 0 deletions web-ui/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -407,5 +407,12 @@
"pinAria": "{name} をピン留め",
"unpinAria": "{name} のピン留めを解除",
"custom": "カスタム"
},
"templatePicker": {
"sectionTitle": "テンプレート",
"useTemplate": "このテンプレートを使う",
"useTemplateAria": "テンプレート {name} を使う",
"currentLabel": "使用中: {name}",
"custom": "カスタム"
}
}
11 changes: 11 additions & 0 deletions web-ui/src/app/(authenticated)/decks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ export default function DecksPage() {
chatRef.current?.insertAtCursor(msg)
}, [ws.deck?.specs?.artDirection])

/** Handle inline template selection — insert message into chat input.
* isChange is true when deck.json already has a confirmed template. */
const handleTemplateSelect = useCallback((name: string, isChange: boolean) => {
const msg = isChange
? `I want to change the template to "${name}". `
: `I'll use the "${name}" template. `
chatRef.current?.insertAtCursor(msg)
}, [])

/* ── Render ── */
return (
<AppShell
Expand Down Expand Up @@ -144,6 +153,8 @@ export default function DecksPage() {
specs={ws.deck?.specs}
workflowPhase={workflowPhase}
onStyleSelect={handleStyleSelect}
onTemplateSelect={handleTemplateSelect}
currentTemplate={ws.deck?.template}
idToken={idToken}
ownerAlias={!ws.isOwner ? ws.deck?.ownerAlias : undefined}
headerActions={
Expand Down
1 change: 1 addition & 0 deletions web-ui/src/app/api/decks/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
name: deckJson.name || deckId,
slideOrder: deckJson.slideOrder || [],
slides,
template: deckJson.template || null,
defsUrl: defsFilename ? `/api/preview/${deckId}/compose/${defsFilename}` : null,
pptxUrl: fs.existsSync(pptxPath) ? `/api/preview/${deckId}/output.pptx` : null,
specs,
Expand Down
18 changes: 9 additions & 9 deletions web-ui/src/components/deck/SlideCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@
import { useState, useEffect, useRef, useCallback } from "react"
import { SlidePreview } from "@/services/deckService"
import type { SpecFiles } from "@/services/deckService"
import { Download, Layers, Loader2, LayoutGrid, Rows3, FolderOpen } from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { Download, Layers, LayoutGrid, Rows3, FolderOpen } from "lucide-react"
import { usePreferences } from "@/hooks/usePreferences"
import { SpecStepNav, SpecMarkdownPreview } from "@/components/deck/SpecStepNav"
import type { SpecTab } from "@/components/deck/SpecStepNav"
import { SlideThumbnail } from "@/components/deck/SlideThumbnail"
import { AnimatedSlidePreview } from "@/components/deck/AnimatedSlidePreview"
import { CloudOnly, LocalOnly, IS_LOCAL } from "@/lib/mode"
import { IS_LOCAL } from "@/lib/mode"
import { notifyError } from "@/lib/errors"
import { useTranslations } from "next-intl"

Expand All @@ -46,29 +45,28 @@ interface SlideCarouselProps {
workflowPhase?: string | null
/** Callback when user selects a style inline. */
onStyleSelect?: (name: string) => void
/** Callback when user selects a template inline (isChange = template already confirmed). */
onTemplateSelect?: (name: string, isChange: boolean) => void
/** Confirmed template from deck.json (raw value, e.g. "corporate.pptx"). */
currentTemplate?: string | null
/** Cognito ID token for style API calls. */
idToken?: string
}

export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, idToken }: SlideCarouselProps) {
export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLoading, onSlideClick, scrollToSlide, onScrollComplete, headerActions, ownerAlias, specs, workflowPhase, onStyleSelect, onTemplateSelect, currentTemplate, idToken }: SlideCarouselProps) {
const t = useTranslations("carousel")
const slidesWithPreview = slides.filter((s) => s.previewUrl || s.composeUrl)
// eslint-disable-next-line no-console
const slugs = slides.map(s => s.slug)
// eslint-disable-next-line no-console
if (new Set(slugs).size !== slugs.length) console.warn("[SlideCarousel] duplicate slugs:", slugs)
// Check compose URL duplicates across different slugs
// eslint-disable-next-line no-console
const urlBySlug: Record<string,string> = {}
const dupUrls: string[] = []
for (const s of slidesWithPreview) {
const u = s.composeUrl?.split("?")[0] || ""
if (u && Object.values(urlBySlug).includes(u)) dupUrls.push(`${s.slug}→${u}`)
if (u) urlBySlug[s.slug] = u
}
// eslint-disable-next-line no-console
if (dupUrls.length) console.warn("[SlideCarousel] same composeUrl used for multiple slides:", dupUrls, urlBySlug)
const auth = useAuth()
const { viewMode, setViewMode } = usePreferences()
const containerRef = useRef<HTMLDivElement>(null)

Expand Down Expand Up @@ -476,6 +474,8 @@ export function SlideCarousel({ slides, defsUrl, deckId, deckName, pptxUrl, isLo
specName={specTab.charAt(0).toUpperCase() + specTab.slice(1)}
specKey={specTab}
onStyleSelect={specTab === "artDirection" ? onStyleSelect : undefined}
onTemplateSelect={specTab === "artDirection" ? onTemplateSelect : undefined}
currentTemplate={specTab === "artDirection" ? currentTemplate : undefined}
idToken={specTab === "artDirection" ? idToken : undefined}
/>
)}
Expand Down
36 changes: 25 additions & 11 deletions web-ui/src/components/deck/SpecMarkdownPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
* SpecMarkdownPreview — Renders spec markdown content with editorial styling.
* Outline uses the dedicated OutlineView timeline component.
* Brief uses react-markdown with HEX color swatches.
* Art Direction renders HTML via sandboxed iframe with an inline
* style gallery (gallery → preview → result states).
* Art Direction renders a persistent template picker section (header-like,
* non-sticky) above an inline style gallery (gallery → preview → result
* states). Both sections share one scroll container but keep independent
* display states.
*
* @param props.content - Markdown or HTML string to render
* @param props.specName - Name of the spec (for empty state)
Expand All @@ -23,6 +25,7 @@ import { fetchStyles, fetchStyleHtml, pinStyle, type StyleEntry } from "@/servic
import { OutlineView } from "./OutlineView"
import { StyleSlidePreview } from "@/components/StyleSlidePreview"
import { StyleCard } from "./StyleCard"
import { TemplatePickerSection } from "./TemplatePickerSection"
import { BriefWaiting, OutlineWaiting, ArtDirectionWaiting } from "./SpecWaiting"
import { useTranslations } from "next-intl"

Expand Down Expand Up @@ -86,7 +89,7 @@ const specComponents = {
},
}

export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, idToken }: { content: string | null; specName: string; specKey?: string; onStyleSelect?: (name: string) => void; idToken?: string }) {
export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect, onTemplateSelect, currentTemplate, idToken }: { content: string | null; specName: string; specKey?: string; onStyleSelect?: (name: string) => void; onTemplateSelect?: (name: string, isChange: boolean) => void; currentTemplate?: string | null; idToken?: string }) {
const t = useTranslations("stylePicker")
// Hooks must be called unconditionally — before any early returns.

Expand Down Expand Up @@ -169,11 +172,22 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect,
return <div className="content-enter flex-1"><OutlineView content={content} /></div>
}

// Art Direction: 3-state inline view
// Art Direction: template section (persistent) + style section (3-state)
// sharing one scroll container. The template picker is header-like and
// independent of the style state machine — it stays visible in all states.
if (specKey === "artDirection") {
const wrap = (body: React.ReactNode) => (
<div ref={galleryContainerRef} className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
{onTemplateSelect && (
<TemplatePickerSection idToken={idToken} currentTemplate={currentTemplate} onTemplateSelect={onTemplateSelect} />
)}
<div className="flex-1 flex flex-col min-h-0">{body}</div>
</div>
)

// Waiting state (no content, not browsing styles)
if (!content && adMode === "result") {
return (
return wrap(
<div className="flex-1 flex flex-col items-center justify-center text-center px-6 py-20">
<ArtDirectionWaiting />
</div>
Expand All @@ -199,8 +213,8 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect,
const hasPins = pinnedStyles.length > 0
const unpinnedStyles = styles.filter(s => !s.pinned)

return (
<div ref={galleryContainerRef} className="flex-1 overflow-y-auto">
return wrap(
<div>
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-white/[0.06]">
<div>
Expand Down Expand Up @@ -283,8 +297,8 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect,
else { setPreview(null); setAdMode("gallery") }
}

return (
<div className="flex-1 overflow-y-auto">
return wrap(
<div>
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -324,8 +338,8 @@ export function SpecMarkdownPreview({ content, specName, specKey, onStyleSelect,
}

// RESULT state (default when content exists)
return (
<div className="flex-1 overflow-y-auto overflow-x-hidden">
return wrap(
<div>
{onStyleSelect && (
<div className="flex justify-end px-4 py-2">
<button
Expand Down
140 changes: 140 additions & 0 deletions web-ui/src/components/deck/TemplatePickerSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT-0
/**
* TemplatePickerSection tests — rendering, ordering, current-template
* indication, and selection callback arguments.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { screen, waitFor, fireEvent, cleanup } from "@testing-library/react"
import { renderWithIntl } from "@/test/renderWithIntl"
import { TemplatePickerSection } from "./TemplatePickerSection"
import type { TemplateEntry } from "@/services/deckService"

vi.mock("@/services/deckService", () => ({
fetchTemplates: vi.fn(),
}))

import { fetchTemplates } from "@/services/deckService"

const TEMPLATES: TemplateEntry[] = [
{
name: "builtin-a",
source: "builtin",
description: "Builtin A",
theme_colors: { background: "#ffffff", text: "#111111", accent1: "#ff0000" },
fonts: { halfwidth: "Arial", fullwidth: null },
layout_count: 10,
},
{
name: "my-brand",
source: "user",
description: "Company brand",
theme_colors: { background: "#001122", text: "#eeeeee" },
fonts: {},
layout_count: 5,
},
{
name: "corporate",
source: "builtin",
description: "",
theme_colors: {},
fonts: {},
layout_count: 8,
},
]

describe("TemplatePickerSection", () => {
beforeEach(() => {
vi.mocked(fetchTemplates).mockResolvedValue(TEMPLATES)
})

afterEach(() => {
cleanup()
vi.clearAllMocks()
})

it("renders all templates after loading", async () => {
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate={null} onTemplateSelect={() => {}} />
)
expect(await screen.findByText("my-brand")).toBeTruthy()
expect(screen.getByText("builtin-a")).toBeTruthy()
expect(screen.getByText("corporate")).toBeTruthy()
expect(screen.getByText("Template")).toBeTruthy()
})

it("orders user templates before builtin when nothing is confirmed", async () => {
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate={null} onTemplateSelect={() => {}} />
)
await screen.findByText("my-brand")
const cards = screen.getAllByRole("button")
const names = cards.map((c) => c.getAttribute("aria-label"))
expect(names).toEqual([
"Use the my-brand template",
"Use the builtin-a template",
"Use the corporate template",
])
})

it("marks the confirmed template with data-current without reordering", async () => {
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate="corporate.pptx" onTemplateSelect={() => {}} />
)
await screen.findByText("my-brand")
await waitFor(() => {
const card = screen.getByRole("button", { name: "Use the corporate template" })
expect(card.getAttribute("data-current")).toBe("true")
})
// Order stays user → builtin — the current card does NOT jump to the front
const names = screen.getAllByRole("button").map((c) => c.getAttribute("aria-label"))
expect(names).toEqual([
"Use the my-brand template",
"Use the builtin-a template",
"Use the corporate template",
])
// Header shows the labelled current template name
expect(screen.getByText("In use: corporate")).toBeTruthy()
})

it("normalizes template values with directory prefixes", async () => {
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate="templates/my-brand.pptx" onTemplateSelect={() => {}} />
)
await screen.findByText("builtin-a")
expect(
screen.getByRole("button", { name: "Use the my-brand template" }).getAttribute("data-current")
).toBe("true")
expect(screen.getByText("In use: my-brand")).toBeTruthy()
})

it("calls onTemplateSelect with isChange=false when unconfirmed", async () => {
const onSelect = vi.fn()
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate={null} onTemplateSelect={onSelect} />
)
await screen.findByText("my-brand")
fireEvent.click(screen.getByRole("button", { name: "Use the my-brand template" }))
expect(onSelect).toHaveBeenCalledWith("my-brand", false)
})

it("calls onTemplateSelect with isChange=true when a template is confirmed", async () => {
const onSelect = vi.fn()
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate="corporate.pptx" onTemplateSelect={onSelect} />
)
await screen.findByText("my-brand")
// The current card is NOT disabled — re-asserting the same template is allowed
fireEvent.click(screen.getByRole("button", { name: "Use the corporate template" }))
expect(onSelect).toHaveBeenCalledWith("corporate", true)
})

it("shows the custom badge only for user templates", async () => {
renderWithIntl(
<TemplatePickerSection idToken="tok" currentTemplate={null} onTemplateSelect={() => {}} />
)
await screen.findByText("my-brand")
expect(screen.getAllByText("Custom")).toHaveLength(1)
})
})
Loading
Loading