From 499853adabd50bb24731a5b56cca534977009cd1 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:19:25 +0800 Subject: [PATCH 1/5] feat(theme): add visual theme registry and selection contract --- apps/api/src/hcs_api/models.py | 118 ++++++ apps/api/src/hcs_api/presentation_theme.py | 440 +++++++++++++++++++-- apps/api/src/hcs_api/providers.py | 8 +- 3 files changed, 528 insertions(+), 38 deletions(-) diff --git a/apps/api/src/hcs_api/models.py b/apps/api/src/hcs_api/models.py index 7f01505..3869dc7 100644 --- a/apps/api/src/hcs_api/models.py +++ b/apps/api/src/hcs_api/models.py @@ -283,6 +283,14 @@ class LessonBlueprint(BaseModel): ThemeDecisionSource = Literal[ "ppt_master_auto", "teacher_selected", "inherited_from_existing_assets", ] +VisualThemeId = Literal[ + "classroom-clear", + "active-learning", + "warm-story", + "eastern-elegance", + "future-exploration", +] +VisualThemeMode = Literal["auto", "manual"] class ThemeTypography(BaseModel): @@ -349,6 +357,19 @@ class ThemeImageTreatment(BaseModel): prohibited_traits: list[str] = Field(default_factory=list) +class ThemeVideoTreatment(BaseModel): + """Provider-neutral direction retained even when video is unavailable.""" + + model_config = ConfigDict(extra="forbid") + + visual_style: str = "clear educational footage" + color_grade: str = "balanced classroom colour" + lighting: str = "soft, readable lighting" + motion_style: str = "stable, restrained camera movement" + subtitle_direction: str = "high-contrast lower-third subtitles within title-safe margins" + prohibited_traits: list[str] = Field(default_factory=list) + + class PresentationTheme(BaseModel): """Master-derived design decisions; never a provider or pedagogy contract.""" @@ -364,6 +385,102 @@ class PresentationTheme(BaseModel): shapes: ThemeShapeLanguage = Field(default_factory=ThemeShapeLanguage) layout: ThemeLayout = Field(default_factory=ThemeLayout) image_treatment: ThemeImageTreatment + # Default keeps presentation_theme.v1 files from older projects readable. + video_treatment: ThemeVideoTreatment = Field(default_factory=ThemeVideoTreatment) + + +class VisualThemeSelection(BaseModel): + """Persisted teacher decision. ``auto`` resolves to one real preset.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.visual_theme_selection.v1", alias="schema") + mode: VisualThemeMode = "auto" + selected_theme_id: VisualThemeId = "classroom-clear" + recommended_theme_id: VisualThemeId | None = None + recommendation_reason: str | None = None + theme_version: str = "1" + + +class VisualThemeSelectionUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: VisualThemeMode + selected_theme_id: VisualThemeId | None = None + + +class VisualThemePreview(BaseModel): + model_config = ConfigDict(extra="forbid") + + background: str + surface: str + primary: str + accent: str + text: str + motif: str + + +class VisualThemePresetSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + theme_id: VisualThemeId + version: str + name_key: str + description_key: str + preview: VisualThemePreview + + +class VisualThemeCatalog(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.visual_theme_catalog.v1", alias="schema") + theme_version: str = "1" + presets: list[VisualThemePresetSummary] = Field(default_factory=list) + + +class ThemeCapabilitySupport(BaseModel): + model_config = ConfigDict(extra="forbid") + + capability: Literal["presentation", "image", "video"] + provider_id: str | None = None + state: Literal["supported", "unsupported", "unavailable", "not_configured"] + theme_metadata_preserved: bool = True + reason: str | None = None + + +class VisualThemeState(BaseModel): + model_config = ConfigDict(extra="forbid") + + selection: VisualThemeSelection + effective_theme_id: VisualThemeId + effective_theme_version: str + media_state: Literal["not_generated", "current", "mixed"] = "not_generated" + mismatched_media_count: int = 0 + mismatched_media_ids: list[str] = Field(default_factory=list) + provider_support: list[ThemeCapabilitySupport] = Field(default_factory=list) + regeneration_available: bool = False + + +class VideoGenerationRequest(BaseModel): + """Auditable request contract; it does not imply a provider executed it.""" + + model_config = ConfigDict(extra="forbid") + + id: str + prompt: str + provider_id: str | None = None + theme_id: VisualThemeId + theme_version: str + theme_direction: ThemeVideoTreatment + theme_application_state: Literal["supported", "unsupported", "unavailable", "not_configured"] + theme_application_reason: str | None = None + + +class VideoGenerationRequestPlan(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True, serialize_by_alias=True) + + schema_: str = Field(default="hanclassstudio.video_generation_requests.v1", alias="schema") + requests: list[VideoGenerationRequest] = Field(default_factory=list) class PresentationThemeDecision(BaseModel): @@ -688,6 +805,7 @@ class ProjectState(BaseModel): artifacts: dict[str, Any] = Field(default_factory=dict) stale_state: StaleState = Field(default_factory=StaleState) provider_readiness: list[ProviderCapabilityDescriptor] = Field(default_factory=list) + visual_theme: VisualThemeState | None = None last_updated_at: str | None = None quality_state: QualityState | None = None source_material: SourceMaterial | None = None diff --git a/apps/api/src/hcs_api/presentation_theme.py b/apps/api/src/hcs_api/presentation_theme.py index e2fe808..6cad0a8 100644 --- a/apps/api/src/hcs_api/presentation_theme.py +++ b/apps/api/src/hcs_api/presentation_theme.py @@ -9,7 +9,6 @@ import colorsys import json -from copy import deepcopy from pathlib import Path from typing import Any @@ -17,28 +16,63 @@ from .models import ( AssetManifest, + LessonBlueprint, + LessonProfile, PresentationTheme, PresentationThemeDecision, + ProviderCapabilityDescriptor, + ProviderSettings, + ThemeCapabilitySupport, ThemeImageTreatment, ThemeLayout, ThemePalette, ThemeShapeLanguage, ThemeTypography, + ThemeVideoTreatment, + VideoGenerationRequest, + VisualThemeCatalog, + VisualThemeId, + VisualThemePresetSummary, + VisualThemePreview, + VisualThemeSelection, + VisualThemeState, ) -MASTER_THEME_SOURCE = "ppt-master:第1课 教学课件 中文 七年级 第一学期.pptx" -DEFAULT_THEME_ID = "ppt_master_blue_classroom_v1" -WARM_THEME_ID = "ppt_master_warm_classroom_v1" +MASTER_THEME_SOURCE = "hanclassstudio:visual-theme-registry.v1" +THEME_VERSION = "1" +DEFAULT_THEME_ID: VisualThemeId = "classroom-clear" +WARM_THEME_ID: VisualThemeId = "warm-story" THEME_SELECTION_PATH = Path("presentation/theme_selection.json") THEME_DECISION_PATH = Path("presentation/presentation_theme.json") +VIDEO_REQUEST_PATH = Path("assets/data/video_generation_requests.json") + +LEGACY_THEME_ALIASES: dict[str, VisualThemeId] = { + "ppt_master_blue_classroom_v1": "classroom-clear", + "ppt_master_warm_classroom_v1": "warm-story", +} -def _theme(theme_id: str, *, background: str, surface: str, primary: str, secondary: str, - accent: str, text: str, muted: str, line: str, image_palette: list[str], mood: str) -> PresentationTheme: +def _theme( + theme_id: VisualThemeId, + *, + background: str, + surface: str, + primary: str, + secondary: str, + accent: str, + text: str, + muted: str, + line: str, + image_palette: list[str], + mood: str, + illustration_style: str, + video_grade: str, + video_motion: str, +) -> PresentationTheme: return PresentationTheme( theme_id=theme_id, - version="1", + version=THEME_VERSION, source=MASTER_THEME_SOURCE, audience_profile="beginner Chinese classroom", visual_mood=mood, @@ -60,33 +94,73 @@ def _theme(theme_id: str, *, background: str, surface: str, primary: str, second shapes=ThemeShapeLanguage(corner_radius=0.12, border_weight=1.2, shadow="subtle", card_treatment="soft_surface"), layout=ThemeLayout(safe_margin_inches=0.68, grid_columns=12, whitespace="generous", image_text_ratio="5:4", max_content_items=6), image_treatment=ThemeImageTreatment( - illustration_style="soft_flat_educational_v1", + illustration_style=illustration_style, palette_descriptors=image_palette, palette_anchors=[f"#{value}" for value in (primary, secondary, accent, surface)], saturation="soft_distinct", contrast="clear_subject_background", background_complexity="low", framing="rounded_cover", prohibited_traits=["embedded words", "watermark", "poster layout", "UI/infographic layout", "neon", "heavy shadow"], ), + video_treatment=ThemeVideoTreatment( + visual_style=f"{mood}; clear educational sequence", + color_grade=video_grade, + lighting="even subject lighting with readable faces and teaching objects", + motion_style=video_motion, + subtitle_direction="high-contrast support-language subtitles within title-safe lower-third margins", + prohibited_traits=["rapid cuts", "flashing transitions", "decorative subtitles", "watermark", "unreadable text"], + ), ) -# Both definitions use the observable reference-master palette: its blue -# headings and cyan details, its white/light-blue surfaces, and its peach card -# treatment. The warm variant simply makes the existing peach treatment the -# dominant surface when accepted illustrations are warm and low-saturation. -THEMES: dict[str, PresentationTheme] = { +THEMES: dict[VisualThemeId, PresentationTheme] = { DEFAULT_THEME_ID: _theme( DEFAULT_THEME_ID, background="F5FAFE", surface="FFFFFF", primary="5B9BD5", - secondary="EAF4FC", accent="00B0F0", text="26374A", muted="6E747A", line="B9D5EC", - image_palette=["pale blue classroom surfaces", "clean cyan detail", "soft peach support cards"], - mood="clear, airy blue classroom", + secondary="EAF4FC", accent="D96B4B", text="26374A", muted="626D75", line="B9D5EC", + image_palette=["pale blue classroom surfaces", "restrained terracotta accents", "clean white teaching space"], + mood="clear, bright, restrained classroom", + illustration_style="soft_flat_educational_v1", video_grade="neutral daylight with restrained blue and terracotta accents", + video_motion="locked or gently guided classroom camera", + ), + "active-learning": _theme( + "active-learning", background="F4FBF8", surface="FFFFFF", primary="167A72", + secondary="DDF4EA", accent="E5684A", text="203C39", muted="60726E", line="A9D9CF", + image_palette=["fresh teal activity zones", "coral action accents", "bright natural classroom light"], + mood="energetic, participatory, age-neutral", + illustration_style="soft_flat_educational_v1", video_grade="fresh teal-and-coral classroom colour with natural skin tones", + video_motion="stable medium shots with restrained activity-focused cuts", ), WARM_THEME_ID: _theme( - WARM_THEME_ID, background="FCF8F3", surface="FFFFFF", primary="2A71AA", - secondary="FCEDD3", accent="5B9BD5", text="26374A", muted="6E747A", line="D7C6B7", - image_palette=["warm peach and cream surfaces", "muted blue structure", "soft natural classroom colour"], - mood="warm, calm classroom with restrained blue structure", + WARM_THEME_ID, background="FCF7F0", surface="FFFDF9", primary="9A5139", + secondary="F7E6D2", accent="C98752", text="3D302B", muted="786B64", line="D9C6B7", + image_palette=["warm peach and cream surfaces", "terracotta structure", "soft natural light"], + mood="warm, calm, gently narrative", + illustration_style="soft_flat_educational_v1", video_grade="warm natural light with cream and terracotta anchors", + video_motion="calm observational shots with slow, purposeful transitions", + ), + "eastern-elegance": _theme( + "eastern-elegance", background="F7F4EC", surface="FFFEFA", primary="263D36", + secondary="E9E4D7", accent="A54232", text="252A27", muted="686C66", line="C9C3B5", + image_palette=["paper white and ink", "restrained cinnabar accent", "muted jade detail"], + mood="modern eastern restraint with generous whitespace", + illustration_style="soft_flat_educational_v1", video_grade="paper-neutral highlights, ink shadows, restrained cinnabar and jade accents", + video_motion="composed static frames with deliberate, minimal movement", ), + "future-exploration": _theme( + "future-exploration", background="101B2D", surface="18273C", primary="69D2E7", + secondary="213754", accent="A98AF2", text="F2F7FC", muted="B8C5D3", line="3B5875", + image_palette=["deep navy structure", "clear cyan light", "restrained violet signals"], + mood="structured, exploratory, projection-safe technology", + illustration_style="soft_flat_educational_v1", video_grade="deep navy environment with controlled cyan and violet highlights", + video_motion="precise grid-led movement and slow technical reveals", + ), +} + +THEME_METADATA: dict[VisualThemeId, tuple[str, str, str]] = { + "classroom-clear": ("visualTheme.preset.classroom-clear.name", "visualTheme.preset.classroom-clear.description", "clarity"), + "active-learning": ("visualTheme.preset.active-learning.name", "visualTheme.preset.active-learning.description", "activity"), + "warm-story": ("visualTheme.preset.warm-story.name", "visualTheme.preset.warm-story.description", "story"), + "eastern-elegance": ("visualTheme.preset.eastern-elegance.name", "visualTheme.preset.eastern-elegance.description", "paper"), + "future-exploration": ("visualTheme.preset.future-exploration.name", "visualTheme.preset.future-exploration.description", "grid"), } @@ -98,34 +172,190 @@ def default_presentation_theme() -> PresentationTheme: return THEMES[DEFAULT_THEME_ID].model_copy(deep=True) +def normalize_theme_id(theme_id: str | None) -> VisualThemeId: + candidate = LEGACY_THEME_ALIASES.get(theme_id or "", theme_id) + return candidate if candidate in THEMES else DEFAULT_THEME_ID # type: ignore[return-value] + + def theme_by_id(theme_id: str | None) -> PresentationTheme: - return THEMES.get(theme_id or "", THEMES[DEFAULT_THEME_ID]).model_copy(deep=True) + return THEMES[normalize_theme_id(theme_id)].model_copy(deep=True) + + +def visual_theme_catalog() -> VisualThemeCatalog: + presets: list[VisualThemePresetSummary] = [] + for theme_id, theme in THEMES.items(): + name_key, description_key, motif = THEME_METADATA[theme_id] + presets.append(VisualThemePresetSummary( + theme_id=theme_id, + version=theme.version, + name_key=name_key, + description_key=description_key, + preview=VisualThemePreview( + background=f"#{theme.palette.background}", + surface=f"#{theme.palette.surface}", + primary=f"#{theme.palette.primary}", + accent=f"#{theme.palette.accent}", + text=f"#{theme.palette.text}", + motif=motif, + ), + )) + return VisualThemeCatalog(theme_version=THEME_VERSION, presets=presets) + + +def recommend_visual_theme( + profile: LessonProfile | None = None, + blueprint: LessonBlueprint | None = None, +) -> tuple[VisualThemeId, str]: + """Small, deterministic and explainable recommendation rules.""" + parts: list[str] = [] + if profile: + parts.extend([ + profile.lesson_title, + profile.subject, + profile.learner_level, + profile.target_students, + profile.lesson_type, + ]) + if blueprint: + parts.extend([ + blueprint.lesson_title, + blueprint.route_hint, + *blueprint.objectives, + *blueprint.grammar_points, + *(slide.title for slide in blueprint.slides), + *(slide.slide_type for slide in blueprint.slides), + ]) + text = " ".join(parts).casefold() + if any(token in text for token in ("科技", "工程", "能源", "职业", "technology", "engineering", "energy", "stem", "career")): + return "future-exploration", "future_content" + if any(token in text for token in ("节日", "文学", "古诗", "文化", "跨文化", "festival", "literature", "poetry", "culture")): + return "eastern-elegance", "cultural_content" + if any(token in text for token in ("幼儿", "儿童", "少年", "游戏", "竞赛", "练习", "child", "children", "young", "game", "quiz", "practice")): + return "active-learning", "younger_or_activity_focused" + if any(token in text for token in ("故事", "生活", "阅读", "情景", "对话", "story", "daily life", "reading", "scenario", "dialogue")): + return "warm-story", "story_or_life_context" + return DEFAULT_THEME_ID, "default_clear" + + +def visual_theme_selection_for_project( + project_root: Path, + *, + profile: LessonProfile | None = None, + blueprint: LessonBlueprint | None = None, +) -> VisualThemeSelection: + if profile is None: + profile = _read_project_model(project_root / "assets/data/lesson_profile.json", LessonProfile) + if blueprint is None: + blueprint = _read_project_model(project_root / "blueprints/lesson_blueprint.json", LessonBlueprint) + config = _read_selection(project_root) + recommended, reason = recommend_visual_theme(profile, blueprint) + if config.get("mode") in {"auto", "manual"}: + mode = str(config["mode"]) + requested = normalize_theme_id(str(config.get("selected_theme_id") or recommended)) + selected = recommended if mode == "auto" else requested + return VisualThemeSelection( + mode=mode, + selected_theme_id=selected, + recommended_theme_id=recommended, + recommendation_reason=reason, + theme_version=THEME_VERSION, + ) + if config.get("decision_source") == "teacher_selected": + return VisualThemeSelection( + mode="manual", selected_theme_id=normalize_theme_id(str(config.get("theme_id") or "")), + recommended_theme_id=recommended, recommendation_reason=reason, + ) + if config.get("decision_source") == "inherited_from_existing_assets": + manifest = _read_project_model(project_root / "assets/data/asset_manifest.json", AssetManifest) + decision = resolve_presentation_theme(project_root, manifest=manifest, selection=config) + return VisualThemeSelection( + mode="manual", selected_theme_id=normalize_theme_id(decision.theme.theme_id), + recommended_theme_id=recommended, recommendation_reason=reason, + ) + decision_path = project_root / THEME_DECISION_PATH + if decision_path.is_file(): + try: + decision = PresentationThemeDecision.model_validate_json(decision_path.read_text(encoding="utf-8")) + if decision.decision_source in {"teacher_selected", "inherited_from_existing_assets"}: + return VisualThemeSelection( + mode="manual", selected_theme_id=normalize_theme_id(decision.theme.theme_id), + recommended_theme_id=recommended, recommendation_reason=reason, + ) + except Exception: + pass + return VisualThemeSelection( + mode="auto", + selected_theme_id=recommended, + recommended_theme_id=recommended, + recommendation_reason=reason, + ) def presentation_theme_for_project(project_root: Path) -> PresentationTheme: + profile = _read_project_model(project_root / "assets/data/lesson_profile.json", LessonProfile) + blueprint = _read_project_model(project_root / "blueprints/lesson_blueprint.json", LessonBlueprint) decision_path = project_root / THEME_DECISION_PATH if decision_path.is_file(): try: - return PresentationThemeDecision.model_validate_json(decision_path.read_text(encoding="utf-8")).theme + decision = PresentationThemeDecision.model_validate_json(decision_path.read_text(encoding="utf-8")) + if decision.theme.theme_id in THEMES: + return decision.theme + if decision.decision_source == "ppt_master_auto": + selection = visual_theme_selection_for_project(project_root, profile=profile, blueprint=blueprint) + return theme_by_id(selection.selected_theme_id) + return theme_by_id(decision.theme.theme_id) except Exception: pass - return default_presentation_theme() + selection = visual_theme_selection_for_project(project_root, profile=profile, blueprint=blueprint) + return theme_by_id(selection.selected_theme_id) def project_has_presentation_theme(project_root: Path) -> bool: - """Keep legacy HTML output unchanged until a project gets a theme decision.""" - return (project_root / THEME_DECISION_PATH).is_file() + """Projects with a profile receive the deterministic default/recommendation.""" + return any((project_root / path).is_file() for path in ( + THEME_DECISION_PATH, + THEME_SELECTION_PATH, + Path("assets/data/lesson_profile.json"), + )) + + +def _read_project_model( + path: Path, + model_type: type[LessonProfile] | type[LessonBlueprint] | type[AssetManifest], +): + if not path.is_file(): + return None + try: + return model_type.model_validate_json(path.read_text(encoding="utf-8")) + except Exception: + return None def resolve_presentation_theme( project_root: Path, *, lesson_title: str = "", + profile: LessonProfile | None = None, + blueprint: LessonBlueprint | None = None, manifest: AssetManifest | None = None, selection: dict[str, Any] | None = None, ) -> PresentationThemeDecision: - """Resolve one of the supported, master-derived themes and persist no secrets.""" + """Resolve one registry preset and persist no secrets or provider fields.""" config = selection if selection is not None else _read_selection(project_root) + if config.get("mode") in {"auto", "manual"}: + mode = str(config["mode"]) + recommended, reason = recommend_visual_theme(profile, blueprint) + requested = normalize_theme_id(str(config.get("selected_theme_id") or recommended)) + selected = recommended if mode == "auto" else requested + return PresentationThemeDecision( + decision_source="ppt_master_auto" if mode == "auto" else "teacher_selected", + requested_theme_id=selected if mode == "manual" else None, + theme=theme_by_id(selected), + rationale=[ + f"Visual theme registry: {MASTER_THEME_SOURCE}", + f"{'Auto recommendation' if mode == 'auto' else 'Teacher selection'}: {selected} ({reason if mode == 'auto' else 'manual'}).", + ], + ) source = str(config.get("decision_source", "ppt_master_auto")) if source not in {"ppt_master_auto", "teacher_selected", "inherited_from_existing_assets"}: source = "ppt_master_auto" @@ -147,8 +377,9 @@ def resolve_presentation_theme( f"Palette compatibility score: {score:.3f}; warm master surfaces are closer to the observed peach/cream anchors.", ]) else: - theme = default_presentation_theme() - rationale.append(f"Auto-selected {theme.theme_id} for {lesson_title or 'the lesson'}.") + recommended, reason = recommend_visual_theme(profile, blueprint) + theme = theme_by_id(recommended) + rationale.append(f"Auto-selected {theme.theme_id} for {lesson_title or 'the lesson'} ({reason}).") return PresentationThemeDecision( decision_source=source, requested_theme_id=requested if source == "teacher_selected" else None, @@ -181,15 +412,156 @@ def persist_theme_decision(project_root: Path, decision: PresentationThemeDecisi if manifest is not None: manifest.presentation_theme_id = decision.theme.theme_id manifest.presentation_theme_version = decision.theme.version - for asset in [*manifest.images, *manifest.audio, *manifest.video, *manifest.fonts]: - asset.presentation_theme_id = decision.theme.theme_id - asset.presentation_theme_version = decision.theme.version - if asset.generation: - asset.generation.theme_id = decision.theme.theme_id - asset.generation.theme_version = decision.theme.version + # An inherited decision explicitly classifies the observed assets that + # produced it. Other decisions must never fill missing provenance on + # retained media: unknown historical assets need to remain observable + # as a mismatch instead of being silently declared current. + if decision.decision_source == "inherited_from_existing_assets": + for asset in [*manifest.images, *manifest.video]: + asset.presentation_theme_id = decision.theme.theme_id + asset.presentation_theme_version = decision.theme.version return decision +def persist_visual_theme_selection( + project_root: Path, + *, + mode: str, + selected_theme_id: str | None, + profile: LessonProfile | None = None, + blueprint: LessonBlueprint | None = None, +) -> VisualThemeSelection: + recommended, reason = recommend_visual_theme(profile, blueprint) + if mode not in {"auto", "manual"}: + raise ValueError("Visual theme mode must be auto or manual") + if mode == "manual" and selected_theme_id not in THEMES: + raise ValueError("A supported visual theme is required for manual mode") + selected = recommended if mode == "auto" else normalize_theme_id(selected_theme_id) + selection = VisualThemeSelection( + mode=mode, + selected_theme_id=selected, + recommended_theme_id=recommended, + recommendation_reason=reason, + theme_version=THEME_VERSION, + ) + path = project_root / THEME_SELECTION_PATH + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".json.tmp") + temporary.write_text(selection.model_dump_json(by_alias=True, indent=2), encoding="utf-8") + temporary.replace(path) + decision = resolve_presentation_theme( + project_root, + profile=profile, + blueprint=blueprint, + selection=selection.model_dump(mode="json", by_alias=True), + ) + persist_theme_decision(project_root, decision) + return selection + + +def _capability_support( + capability: str, + provider_id: str | None, + catalog: list[ProviderCapabilityDescriptor], +) -> ThemeCapabilitySupport: + if capability == "presentation": + return ThemeCapabilitySupport(capability="presentation", state="supported") + descriptor = next( + (item for item in catalog if item.capability == capability and item.provider_id == provider_id), + None, + ) + if descriptor is None: + return ThemeCapabilitySupport( + capability=capability, provider_id=provider_id, state="unsupported", + reason="No selected Provider exposes a visual-theme contract.", + ) + if not descriptor.implemented or "visual_theme" not in descriptor.supported_operations: + return ThemeCapabilitySupport( + capability=capability, provider_id=provider_id, state="unsupported", + reason=descriptor.unavailable_reason or "This Provider does not support visual-theme direction.", + ) + if not descriptor.configured: + return ThemeCapabilitySupport( + capability=capability, provider_id=provider_id, state="not_configured", + reason=descriptor.unavailable_reason or "Provider configuration is incomplete.", + ) + if not descriptor.available: + return ThemeCapabilitySupport( + capability=capability, provider_id=provider_id, state="unavailable", + reason=descriptor.unavailable_reason or "Provider is currently unavailable.", + ) + return ThemeCapabilitySupport(capability=capability, provider_id=provider_id, state="supported") + + +def visual_theme_state_for_project( + project_root: Path, + *, + profile: LessonProfile | None = None, + blueprint: LessonBlueprint | None = None, + manifest: AssetManifest | None = None, + provider_catalog: list[ProviderCapabilityDescriptor] | None = None, + provider_settings: ProviderSettings | None = None, +) -> VisualThemeState: + selection = visual_theme_selection_for_project(project_root, profile=profile, blueprint=blueprint) + catalog = provider_catalog or [] + image_provider = provider_settings.image.provider if provider_settings else None + video_provider = provider_settings.video.provider if provider_settings else None + support = [ + _capability_support("presentation", None, catalog), + _capability_support("image", image_provider, catalog), + _capability_support("video", video_provider, catalog), + ] + mismatch_ids: list[str] = [] + mismatch_capabilities: set[str] = set() + media = [*(manifest.images if manifest else []), *(manifest.video if manifest else [])] + for asset in media: + if not asset.path: + continue + asset_theme_id = normalize_theme_id(asset.presentation_theme_id) if asset.presentation_theme_id else None + if asset_theme_id != selection.selected_theme_id or asset.presentation_theme_version != selection.theme_version: + mismatch_ids.append(asset.id) + mismatch_capabilities.add("video" if asset.kind == "video" else "image") + support_by_capability = {item.capability: item for item in support} + regeneration_available = bool(mismatch_ids) and all( + support_by_capability[capability].state == "supported" + for capability in mismatch_capabilities + ) + return VisualThemeState( + selection=selection, + effective_theme_id=selection.selected_theme_id, + effective_theme_version=selection.theme_version, + media_state="not_generated" if not media else "mixed" if mismatch_ids else "current", + mismatched_media_count=len(mismatch_ids), + mismatched_media_ids=sorted(mismatch_ids), + provider_support=support, + regeneration_available=regeneration_available, + ) + + +def video_generation_requests( + blueprint: LessonBlueprint, + theme: PresentationTheme, + support: ThemeCapabilitySupport, +) -> list[VideoGenerationRequest]: + """Compile requests without claiming that an unsupported adapter ran.""" + requests: list[VideoGenerationRequest] = [] + for slide in blueprint.slides: + requirements = slide.media_requirements + if not requirements.video_key or not requirements.video_scene_prompt: + continue + requests.append(VideoGenerationRequest( + id=requirements.video_key, + prompt=requirements.video_scene_prompt, + provider_id=support.provider_id, + theme_id=normalize_theme_id(theme.theme_id), + theme_version=theme.version, + theme_direction=theme.video_treatment.model_copy(deep=True), + theme_application_state=support.state, + theme_application_reason=support.reason, + )) + return requests + + def observe_existing_image_palette(project_root: Path, manifest: AssetManifest | None = None) -> dict[str, Any]: images = (manifest.images if manifest else []) samples: list[tuple[int, int, int]] = [] diff --git a/apps/api/src/hcs_api/providers.py b/apps/api/src/hcs_api/providers.py index 56fcf08..58ded4a 100644 --- a/apps/api/src/hcs_api/providers.py +++ b/apps/api/src/hcs_api/providers.py @@ -81,7 +81,7 @@ def _provider_definitions() -> list[dict[str, Any]]: { "capability": "image", "provider_id": "placeholder", "display_name": "Deterministic SVG", "category": "local", "description": "Offline-safe deterministic illustration fallback", - "fields": [], "operations": ["placeholder"], + "fields": [], "operations": ["placeholder", "visual_theme"], }, { "capability": "image", "provider_id": "openai_images", "display_name": "OpenAI Images", @@ -89,7 +89,7 @@ def _provider_definitions() -> list[dict[str, Any]]: "fields": [_field("api_key", "API key", "password", required=True), _field("base_url", "Base URL", "url", placeholder="https://api.openai.com/v1"), _field("model", "Model", placeholder="gpt-image-1")], - "operations": ["image"], + "operations": ["image", "visual_theme"], }, { "capability": "image", "provider_id": "experimental_openai_images", "display_name": "OpenAI Images (experimental)", @@ -97,14 +97,14 @@ def _provider_definitions() -> list[dict[str, Any]]: "fields": [_field("api_key", "API key", "password", required=True), _field("base_url", "Base URL", "url", placeholder="https://api.openai.com/v1"), _field("model", "Model", placeholder="gpt-image-1")], - "operations": ["image"], "experimental": True, + "operations": ["image", "visual_theme"], "experimental": True, }, { "capability": "image", "provider_id": "codex_image", "display_name": "Codex Image Bridge", "category": "local", "description": "Audited asynchronous image handoff to a live Codex agent session", "fields": [_field("api_key", "Bridge token", "password", required=True), _field("model", "Model label", placeholder="codex-image")], - "operations": ["image"], + "operations": ["image", "visual_theme"], }, { "capability": "tts", "provider_id": "placeholder", "display_name": "Deterministic tone", From 6903995d7c8c3b24662eb1ad6de0a5c48461a012 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:19:45 +0800 Subject: [PATCH 2/5] feat(media): propagate themes through presentation and media requests --- apps/api/src/hcs_api/main.py | 66 +++++++++++++++++++++++++++++++++ apps/api/src/hcs_api/media.py | 56 ++++++++++++++++------------ apps/api/src/hcs_api/storage.py | 31 +++++++++++++++- 3 files changed, 127 insertions(+), 26 deletions(-) diff --git a/apps/api/src/hcs_api/main.py b/apps/api/src/hcs_api/main.py index d2174d6..0999848 100644 --- a/apps/api/src/hcs_api/main.py +++ b/apps/api/src/hcs_api/main.py @@ -48,9 +48,12 @@ ProjectSummary, ProviderCapabilityDescriptor, ProviderSettings, + QualityReport, SourceMaterial, StateFirstTeacherSummary, VideoProviderSettings, + VisualThemeCatalog, + VisualThemeSelectionUpdate, ) from .parser import parse_source from .source_understanding import OCRPolicy, get_engine_status @@ -76,6 +79,12 @@ from .pipeline import generate_lesson_blueprint, generate_project_media from .pipeline import render_and_check, run_full_pipeline, write_blueprint_artifacts, write_spec_artifacts from .pptx_exporter import export_editable_pptx +from .presentation_theme import ( + persist_visual_theme_selection, + recommend_visual_theme, + visual_theme_catalog, + visual_theme_selection_for_project, +) from .storage import ( PROJECTS_DIR, RUNTIME_DIR, @@ -1071,6 +1080,11 @@ def component_registry() -> dict: return load_component_registry() +@app.get("/api/visual-themes", response_model=VisualThemeCatalog) +def read_visual_theme_catalog() -> VisualThemeCatalog: + return visual_theme_catalog() + + @app.post("/api/projects/upload", response_model=ProjectState) async def upload_project(file: UploadFile = File(...), engine: str | None = Query(default=None)) -> ProjectState: if not file.filename: @@ -1162,6 +1176,58 @@ def read_project(project_id: str) -> ProjectState: return get_project_state(project_id) +@app.put("/api/projects/{project_id}/visual-theme", response_model=ProjectState) +def save_visual_theme( + project_id: str, + update: VisualThemeSelectionUpdate, + expected_revision: int | None = Query(default=None), +) -> ProjectState: + root = _assert_project(project_id) + _assert_expected_revision(project_id, expected_revision) + profile = read_model(project_id, "lesson_profile.json", LessonProfile) + blueprint = read_model(project_id, "lesson_blueprint.json", LessonBlueprint) + current = visual_theme_selection_for_project(root, profile=profile, blueprint=blueprint) + recommended, reason = recommend_visual_theme(profile, blueprint) + target_theme_id = recommended if update.mode == "auto" else update.selected_theme_id + if update.mode == "manual" and target_theme_id is None: + raise HTTPException(status_code=422, detail={ + "code": "visual_theme_selection_invalid", + "message": "A supported visual theme is required for manual mode.", + }) + no_change = ( + current.mode == update.mode + and current.selected_theme_id == target_theme_id + and (update.mode != "auto" or current.recommendation_reason == reason) + ) + if no_change: + return get_project_state(project_id) + try: + selection = persist_visual_theme_selection( + root, + mode=update.mode, + selected_theme_id=target_theme_id, + profile=profile, + blueprint=blueprint, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail={ + "code": "visual_theme_selection_invalid", + "message": str(exc), + }) from exc + if selection.selected_theme_id != current.selected_theme_id and ( + (root / "courseware/lesson.html").is_file() + or read_model(project_id, "quality_report.json", QualityReport) is not None + or latest_export_path(project_id) is not None + ): + invalidate_downstream( + project_id, + "visual_theme", + "Visual theme changed; render, quality, and export must be refreshed.", + ) + bump_project_revision(project_id) + return get_project_state(project_id) + + @app.get("/api/projects/{project_id}/artifacts", response_model=ArtifactTree) def read_project_artifacts(project_id: str) -> ArtifactTree: _assert_project(project_id) diff --git a/apps/api/src/hcs_api/media.py b/apps/api/src/hcs_api/media.py index 4361d29..4a35ac0 100644 --- a/apps/api/src/hcs_api/media.py +++ b/apps/api/src/hcs_api/media.py @@ -1,6 +1,5 @@ from __future__ import annotations -import html import json import math import wave @@ -15,12 +14,14 @@ from .models import ( AssetCandidate, AssetFile, AssetManifest, GeneratedImage, GeneratedImageFailure, IllustrationRequest, LessonBlueprint, PresentationTheme, ProviderSettings, + VideoGenerationRequestPlan, ) from .presentation_theme import ( - THEME_DECISION_PATH, THEME_SELECTION_PATH, persist_theme_decision, - resolve_presentation_theme, + THEME_DECISION_PATH, THEME_SELECTION_PATH, VIDEO_REQUEST_PATH, + persist_theme_decision, resolve_presentation_theme, video_generation_requests, + visual_theme_state_for_project, ) -from .providers import ProviderError, _llm_enabled, generate_openai_image, generate_openai_tts +from .providers import ProviderError, _llm_enabled, generate_openai_image, generate_openai_tts, provider_capability_catalog from .raster_provider import ( ProviderImagePayload, RasterProviderError, generate_experimental_raster_image, @@ -57,7 +58,7 @@ def generate_placeholder_media( path.write_text(render_scene_spec(spec, presentation_theme=theme), encoding="utf-8") path.with_suffix(".scene.json").write_text(json.dumps(spec, ensure_ascii=False), encoding="utf-8") else: - path.write_text(_placeholder_svg(slide.media_requirements.image_prompt, slide.id), encoding="utf-8") + path.write_text(_placeholder_svg(slide.media_requirements.image_prompt, slide.id, theme), encoding="utf-8") images.append( AssetFile( id=slide.media_requirements.image_key, @@ -65,6 +66,8 @@ def generate_placeholder_media( path=f"assets/images/{filename}", prompt=slide.media_requirements.image_prompt, origin_media_requirement_ids=[slide.media_requirements.image_key] if preserve_media_origin_trace else [], + presentation_theme_id=theme.theme_id if theme else None, + presentation_theme_version=theme.version if theme else None, ) ) if slide.media_requirements.audio_key and slide.media_requirements.audio_text: @@ -156,11 +159,16 @@ def generate_configured_media( for slide in blueprint.slides if slide.media_requirements.image_key and slide.media_requirements.media_kind != "svg_illustration" } + video_requested = any( + slide.media_requirements.video_key and slide.media_requirements.video_scene_prompt + for slide in blueprint.slides + ) # Raster art and presentation chrome must resolve the same project theme # before provider prompts are created; otherwise the exporter defaults to # blue while an unthemed provider is free to choose an unrelated palette. theme_requested = ( bool(raster_keys) + or video_requested or (project_root / THEME_SELECTION_PATH).is_file() or (project_root / THEME_DECISION_PATH).is_file() ) @@ -169,7 +177,7 @@ def generate_configured_media( if theme_requested: previous_manifest = AssetManifest(images=list(previous.values())) decision = resolve_presentation_theme( - project_root, lesson_title=blueprint.lesson_title, manifest=previous_manifest, + project_root, lesson_title=blueprint.lesson_title, blueprint=blueprint, manifest=previous_manifest, ) theme = decision.theme manifest = generate_placeholder_media(project_root, blueprint, preserve_media_origin_trace, theme=theme) @@ -205,6 +213,22 @@ def generate_configured_media( _assert_provider_media_success(manifest, settings, raster_keys) if decision is not None: persist_theme_decision(project_root, decision, manifest) + theme_state = visual_theme_state_for_project( + project_root, + blueprint=blueprint, + manifest=manifest, + provider_catalog=provider_capability_catalog(settings), + provider_settings=settings, + ) + video_support = next(item for item in theme_state.provider_support if item.capability == "video") + requests = video_generation_requests(blueprint, decision.theme, video_support) + if requests: + request_path = project_root / VIDEO_REQUEST_PATH + request_path.parent.mkdir(parents=True, exist_ok=True) + request_path.write_text( + VideoGenerationRequestPlan(requests=requests).model_dump_json(by_alias=True, indent=2), + encoding="utf-8", + ) return manifest @@ -598,21 +622,5 @@ def _write_tone(path: Path) -> None: wav.writeframesraw(amplitude.to_bytes(2, byteorder="little", signed=True)) -def _placeholder_svg(prompt: str, slide_id: int) -> str: - safe_prompt = html.escape(prompt[:120]) - hue = (slide_id * 41) % 360 - accent = f"hsl({hue}, 76%, 52%)" - secondary = f"hsl({(hue + 120) % 360}, 62%, 60%)" - return f""" - - - - - - - - - - - -""" +def _placeholder_svg(prompt: str, slide_id: int, theme: PresentationTheme | None = None) -> str: + return placeholder_svg(prompt, slide_id, presentation_theme=theme) diff --git a/apps/api/src/hcs_api/storage.py b/apps/api/src/hcs_api/storage.py index 402d994..2b9e983 100644 --- a/apps/api/src/hcs_api/storage.py +++ b/apps/api/src/hcs_api/storage.py @@ -28,6 +28,7 @@ QualityReport, SourceMaterial, StaleState, + VisualThemeState, ) @@ -310,6 +311,7 @@ def invalidate_downstream(project_id: str, dependency: str, reason: str) -> None "design": {"presentation", "media", "render", "quality", "delivery"}, "blueprint": {"media", "render", "quality", "delivery"}, "media": {"render", "quality", "delivery"}, + "visual_theme": {"render", "quality", "delivery"}, "render": {"quality", "delivery"}, } affected = downstream.get(dependency, set()) @@ -433,6 +435,24 @@ def get_project_state(project_id: str) -> ProjectState: stale_stages=stale_stages, ) profile_state = "stale" if "profile" in stale_stages else read_profile_state(project_id, profile) + provider_readiness = _provider_readiness() + from .presentation_theme import visual_theme_state_for_project + + try: + provider_settings = read_provider_settings() + except (OSError, ValueError): + # Provider configuration is optional for reading a project. Theme + # truth still needs to be returned, with capability support reported + # as unavailable instead of silently dropping the theme contract. + provider_settings = None + visual_theme = visual_theme_state_for_project( + root, + profile=profile, + blueprint=blueprint, + manifest=manifest, + provider_catalog=provider_readiness, + provider_settings=provider_settings, + ) stages = _project_stages( project_id, source=source, @@ -444,6 +464,7 @@ def get_project_state(project_id: str) -> ProjectState: export_exists=export_exists, gate_summary=gate_summary, stale_stages=stale_stages, + visual_theme=visual_theme, ) current_stage = next( (stage.stage_id for stage in stages if stage.state not in {"completed", "warning"}), @@ -458,7 +479,6 @@ def get_project_state(project_id: str) -> ProjectState: "quality_report": report is not None, "export": export_exists, } - provider_readiness = _provider_readiness() file_times = [path.stat().st_mtime for path in root.rglob("*") if path.is_file()] last_updated_at = ( datetime.fromtimestamp(max(file_times), tz=timezone.utc).isoformat() @@ -477,6 +497,7 @@ def get_project_state(project_id: str) -> ProjectState: artifacts=artifacts, stale_state=stale_state, provider_readiness=provider_readiness, + visual_theme=visual_theme, last_updated_at=last_updated_at, quality_state=report.state if report else None, source_material=source, @@ -643,6 +664,7 @@ def _project_stages( export_exists: bool, gate_summary: GateSummary, stale_stages: set[str] | None = None, + visual_theme: VisualThemeState | None = None, ) -> list[StageStatus]: stale_stages = stale_stages or set() learning_artifacts = [ @@ -724,7 +746,12 @@ def _project_stages( state=presentation_state, required_artifacts=["blueprints/lesson_blueprint.json", "presentation/activity_bindings.json"], blockers=presentation_blockers, - available_actions=["edit_blueprint", "generate_media"] if blueprint else ["generate_blueprint"], + available_actions=( + ["edit_blueprint", "generate_media"] + + (["regenerate_media_for_theme"] if visual_theme and visual_theme.regeneration_available else []) + if blueprint + else ["generate_blueprint"] + ), ), StageStatus( stage_id="quality", From 737c41296b2785aec94f20467c5e11bb159cafe6 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:19:52 +0800 Subject: [PATCH 3/5] feat(web): add simplified visual theme selection flow --- apps/web/src/App.tsx | 273 +++++++++++++++++++++++++++++++++++++++- apps/web/src/api.ts | 20 ++- apps/web/src/i18n.tsx | 268 +++++++++++++++++++++++++++++++++++++-- apps/web/src/state.ts | 9 ++ apps/web/src/styles.css | 259 ++++++++++++++++++++++++++++++++++++++ apps/web/src/types.ts | 58 +++++++++ 6 files changed, 870 insertions(+), 17 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 683fe23..561c48c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { type ChangeEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"; +import { type CSSProperties, type ChangeEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"; import { ArrowDownToLine, Boxes, @@ -22,6 +22,7 @@ import { Pencil, Play, PackageCheck, + Palette, Plus, RefreshCw, Save, @@ -40,6 +41,7 @@ import { exportEditablePptx, exportUrl, fetchProject, + fetchVisualThemeCatalog, fetchDesignSummary, fetchHealth, fetchProviderCapabilities, @@ -69,6 +71,7 @@ import { saveBlueprint, saveProfile, uploadProject, + updateVisualTheme, validateAgentOutput } from "./api"; import type { BackendProviderSettings } from "./api"; @@ -103,9 +106,12 @@ import type { SourceAnalysis, SourceAnalysisPage, StateFirstTeacherSummary, - StageStatus + StageStatus, + VisualThemeCatalog, + VisualThemeId, + VisualThemePreset } from "./types"; -import { canUseStageAction, getAvailableCapabilityProviders, getCapabilityProviders, getCapabilityRegistryProviders, getConfigurableCapabilityProviders, getNextWorkflowAction, getStageAccess, PIPELINE_STEP_KEYS as pipelineStepKeys, isCurrentRequest, pipelineStepsFromProject, providerConfigSnapshot, providerStatus, sanitizeProviderConfig, shouldFetchDesignSummary, shouldPersistProviderConfig, type PipelineStepStatus, type StageAccess, type WorkflowStageId } from "./state"; +import { canUnifyVisualThemeMedia, canUseStageAction, getAvailableCapabilityProviders, getCapabilityProviders, getCapabilityRegistryProviders, getConfigurableCapabilityProviders, getNextWorkflowAction, getStageAccess, PIPELINE_STEP_KEYS as pipelineStepKeys, isCurrentRequest, pipelineStepsFromProject, providerConfigSnapshot, providerStatus, sanitizeProviderConfig, shouldFetchDesignSummary, shouldPersistProviderConfig, type PipelineStepStatus, type StageAccess, type WorkflowStageId } from "./state"; import { ProjectLoadingSkeleton } from "./components/ProjectLoadingSkeleton"; const languages = ["English", "Arabic", "Russian", "Thai", "Korean", "Japanese", "Vietnamese", "Indonesian"]; @@ -355,6 +361,11 @@ export function App() { const [settingsSynced, setSettingsSynced] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); const [theme, setTheme] = useState(() => readStoredTheme()); + const [visualThemeCatalog, setVisualThemeCatalog] = useState(null); + const [visualThemeDialogOpen, setVisualThemeDialogOpen] = useState(false); + const [themeMediaConfirmOpen, setThemeMediaConfirmOpen] = useState(false); + const [visualThemeSaving, setVisualThemeSaving] = useState(false); + const [visualThemeError, setVisualThemeError] = useState(""); const [navNotice, setNavNotice] = useState(""); const [exportFormat, setExportFormat] = useState<"html" | "pptx">("html"); const [forceExportType, setForceExportType] = useState<"html" | "pptx" | null>(null); @@ -395,6 +406,9 @@ export function App() { getComponentRegistry() .then(setComponentRegistry) .catch((err) => setError(readableError(err, t))); + fetchVisualThemeCatalog() + .then(setVisualThemeCatalog) + .catch(() => setVisualThemeCatalog(null)); if (!readOnboardingSeen() && !routeProjectId) { setOnboardingOpen(true); } @@ -465,6 +479,39 @@ export function App() { writeStoredTheme(next); } + async function handleVisualThemeSave(mode: "auto" | "manual", selectedThemeId?: VisualThemeId) { + if (!project) return; + setVisualThemeSaving(true); + setVisualThemeError(""); + try { + const next = await updateVisualTheme( + project.project_id, + { mode, ...(mode === "manual" && selectedThemeId ? { selected_theme_id: selectedThemeId } : {}) }, + project.project_revision, + ); + updateProject(next); + setVisualThemeDialogOpen(false); + } catch (err) { + setVisualThemeError(readableError(err, t)); + } finally { + setVisualThemeSaving(false); + } + } + + async function handleUnifyVisualThemeMedia() { + if (!project || !canUnifyVisualThemeMedia(project)) { + setNavNotice(t("status.actionUnavailable")); + setThemeMediaConfirmOpen(false); + return; + } + setThemeMediaConfirmOpen(false); + await run( + t("busy.regeneratingMedia"), + () => generateMedia(project.project_id, true, project.project_revision), + "quality", + ); + } + // Load persisted provider settings from the backend (source of truth) on mount. useEffect(() => { fetchProviderSettings() @@ -1122,6 +1169,16 @@ export function App() {
} title={t("presentation.title")} action={t("panel.outline.action", { n: blueprint?.slides.length ?? 0 })} state={stageAccess.presentation.state} /> item.stage_id === "presentation")} /> + { + setVisualThemeError(""); + setVisualThemeDialogOpen(true); + }} + onUnify={() => setThemeMediaConfirmOpen(true)} + />

{t("presentation.compatibility")}

{blueprint ? ( @@ -1346,6 +1403,27 @@ export function App() { }} /> )} + {visualThemeDialogOpen && project?.visual_theme && visualThemeCatalog && ( + { + if (!visualThemeSaving) setVisualThemeDialogOpen(false); + }} + onComplete={handleVisualThemeSave} + /> + )} + {themeMediaConfirmOpen && project?.visual_theme && ( + setThemeMediaConfirmOpen(false)} + onConfirm={handleUnifyVisualThemeMedia} + /> + )} {onboardingOpen && (