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 && ( + + + + + + ); +} + +function VisualThemeCard({ + catalog, + state, + canUnify, + onChange, + onUnify, +}: { + catalog: VisualThemeCatalog | null; + state: ProjectState["visual_theme"] | null; + canUnify: boolean; + onChange: () => void; + onUnify: () => void; +}) { + const { t } = useI18n(); + const preset = catalog?.presets.find((item) => item.theme_id === state?.effective_theme_id); + const unsupported = state?.provider_support.filter((item) => item.state !== "supported") ?? []; + return ( + + {t("visualTheme.title")} + + + + + {preset ? t(preset.name_key) : t("visualTheme.unavailable")} + {state?.selection.mode === "auto" && {t("visualTheme.autoBadge")}} + + {preset ? t(preset.description_key) : t("visualTheme.unavailableDetail")} + {state?.selection.mode === "auto" && state.selection.recommendation_reason && ( + {t(`visualTheme.reason.${state.selection.recommendation_reason}`)} + )} + + + {t("visualTheme.change")} + + + {t("visualTheme.consistency")} + {unsupported.length > 0 && ( + + {t("visualTheme.providerUnsupported", { + capabilities: unsupported.map((item) => t(`visualTheme.capability.${item.capability}`)).join("、"), + })} + + )} + {Boolean(state?.mismatched_media_count) && ( + + {t("visualTheme.mismatch", { n: state?.mismatched_media_count ?? 0 })} + {canUnify ? ( + {t("visualTheme.unify")} + ) : ( + {t("visualTheme.unifyUnavailable")} + )} + + )} + + ); +} + +function VisualThemeDialog({ + catalog, + selection, + busy, + error, + onCancel, + onComplete, +}: { + catalog: VisualThemeCatalog; + selection: NonNullable["selection"]; + busy: boolean; + error: string; + onCancel: () => void; + onComplete: (mode: "auto" | "manual", selectedThemeId?: VisualThemeId) => Promise; +}) { + const { t } = useI18n(); + const dialogRef = useRef(null); + const [mode, setMode] = useState<"auto" | "manual">(selection.mode); + const [selectedThemeId, setSelectedThemeId] = useState(selection.selected_theme_id); + useNativeDialog(dialogRef, onCancel); + const recommended = selection.recommended_theme_id ?? selection.selected_theme_id; + const recommendedPreset = catalog.presets.find((item) => item.theme_id === recommended) ?? catalog.presets[0]; + + return ( + + + + + {t("visualTheme.eyebrow")} + {t("visualTheme.dialogTitle")} + + + + {t("visualTheme.dialogDescription")} + {error && {error}} + + + setMode("auto")} /> + + + {t("visualTheme.autoName")} + {t("visualTheme.autoDescription", { theme: t(recommendedPreset.name_key) })} + + {mode === "auto" && {t("visualTheme.selected")}} + + {catalog.presets.map((preset) => { + const selected = mode === "manual" && selectedThemeId === preset.theme_id; + return ( + + { + setMode("manual"); + setSelectedThemeId(preset.theme_id); + }} + /> + + {t(preset.name_key)}{t(preset.description_key)} + {selected && {t("visualTheme.selected")}} + + ); + })} + + + {t("visualTheme.cancel")} + void onComplete(mode, mode === "manual" ? selectedThemeId : undefined)}> + {busy ? t("visualTheme.saving") : t("visualTheme.done")} + + + + + ); +} + +function ThemeMediaConfirmDialog({ + count, + canConfirm, + busy, + onCancel, + onConfirm, +}: { + count: number; + canConfirm: boolean; + busy: boolean; + onCancel: () => void; + onConfirm: () => Promise; +}) { + const { t } = useI18n(); + const dialogRef = useRef(null); + useNativeDialog(dialogRef, onCancel); + return ( + + + {t("visualTheme.unifyTitle")} + {t("visualTheme.unifyBody", { n: count })} + + {t("visualTheme.cancel")} + void onConfirm()} disabled={busy || !canConfirm}>{t("visualTheme.unifyConfirm")} + + + + ); +} + function ForceExportDialog({ type, issueCount, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index fcb89fd..7ac0aa1 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -19,7 +19,9 @@ import type { ProviderDefinition, ProviderRegistryCatalog, ProjectSummary, - StateFirstTeacherSummary + StateFirstTeacherSummary, + VisualThemeCatalog, + VisualThemeId } from "./types"; export const API_BASE = import.meta.env.VITE_API_BASE ?? "http://127.0.0.1:8000"; @@ -93,6 +95,22 @@ export async function fetchProject(projectId: string): Promise { return request(`/api/projects/${encodeURIComponent(projectId)}`); } +export async function fetchVisualThemeCatalog(): Promise { + return request("/api/visual-themes"); +} + +export async function updateVisualTheme( + projectId: string, + selection: { mode: "auto" | "manual"; selected_theme_id?: VisualThemeId }, + expectedRevision?: number | null, +): Promise { + return request(withExpectedRevision(`/api/projects/${encodeURIComponent(projectId)}/visual-theme`, expectedRevision), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(selection), + }); +} + export async function fetchDesignSummary(projectId: string): Promise { return request(`/api/projects/${encodeURIComponent(projectId)}/design/summary`); } diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx index ace3d64..8eb864d 100644 --- a/apps/web/src/i18n.tsx +++ b/apps/web/src/i18n.tsx @@ -20,7 +20,7 @@ export const UI_LANGUAGES: UiLangMeta[] = [ type Dict = Record; -const zh: Dict = { +const zh = { "app.version": "本地演示版", "nav.workflow": "HanClassStudio 工作流程", "provider.title": "模型服务状态", @@ -450,10 +450,52 @@ const zh: Dict = { "theme.label": "界面外观", "theme.light": "白天", "theme.dark": "黑夜", - "theme.system": "自动" -}; + "theme.system": "自动", + "visualTheme.title": "视觉主题", + "visualTheme.eyebrow": "统一视觉方向", + "visualTheme.change": "更换", + "visualTheme.unavailable": "主题暂不可用", + "visualTheme.unavailableDetail": "无法读取后端主题注册表。", + "visualTheme.autoBadge": "自动推荐", + "visualTheme.consistency": "PPT、图片与视频请求将共享同一主题方向", + "visualTheme.providerUnsupported": "{capabilities}服务商暂不支持应用主题;主题元数据仍会保留。", + "visualTheme.capability.presentation": "PPT", + "visualTheme.capability.image": "图片", + "visualTheme.capability.video": "视频", + "visualTheme.mismatch": "{n} 个已有媒体仍使用原风格", + "visualTheme.unify": "统一风格", + "visualTheme.unifyUnavailable": "当前服务商不能安全地统一这些媒体", + "visualTheme.dialogTitle": "选择视觉主题", + "visualTheme.dialogDescription": "选择想要的课堂效果,系统会统一处理颜色、装饰与媒体方向。", + "visualTheme.autoName": "自动推荐", + "visualTheme.autoDescription": "根据课程内容使用“{theme}”", + "visualTheme.selected": "已选择", + "visualTheme.cancel": "取消", + "visualTheme.done": "完成", + "visualTheme.saving": "正在保存…", + "visualTheme.unifyTitle": "统一已有媒体风格", + "visualTheme.unifyBody": "将按当前主题重新生成 {n} 个不一致的媒体。旧候选和审阅记录仍会保留。", + "visualTheme.unifyConfirm": "确认重新生成", + "visualTheme.reason.default_clear": "课程信息不足,采用最稳妥的课堂清晰主题。", + "visualTheme.reason.future_content": "课程包含科技、工程、能源或职业内容。", + "visualTheme.reason.cultural_content": "课程以文化、节日或文学内容为主。", + "visualTheme.reason.younger_or_activity_focused": "课程面向较低年龄学习者或包含较多课堂活动。", + "visualTheme.reason.story_or_life_context": "课程包含生活场景、阅读、故事或情景对话。", + "visualTheme.preset.classroom-clear.name": "课堂清晰", + "visualTheme.preset.classroom-clear.description": "适合常规语言教学、知识讲解与教室投影", + "visualTheme.preset.active-learning.name": "活力互动", + "visualTheme.preset.active-learning.description": "适合对话、课堂练习与游戏化活动", + "visualTheme.preset.warm-story.name": "温暖叙事", + "visualTheme.preset.warm-story.description": "适合生活场景、阅读、故事与情景对话", + "visualTheme.preset.eastern-elegance.name": "东方雅韵", + "visualTheme.preset.eastern-elegance.description": "适合中文文化、节日、文学与跨文化课程", + "visualTheme.preset.future-exploration.name": "未来探索", + "visualTheme.preset.future-exploration.description": "适合科技、能源、工程与职业教育" +} satisfies Dict; + +type TranslationKey = keyof typeof zh; -const en: Dict = { +const en: Record = { "app.version": "Local demo", "nav.workflow": "HanClassStudio workflow", "provider.title": "Model Services", @@ -883,10 +925,50 @@ const en: Dict = { "theme.label": "Appearance", "theme.light": "Light", "theme.dark": "Dark", - "theme.system": "Automatic" + "theme.system": "Automatic", + "visualTheme.title": "Visual theme", + "visualTheme.eyebrow": "One visual direction", + "visualTheme.change": "Change", + "visualTheme.unavailable": "Theme unavailable", + "visualTheme.unavailableDetail": "The backend theme registry could not be loaded.", + "visualTheme.autoBadge": "Auto recommendation", + "visualTheme.consistency": "PPT, image, and video requests share one theme direction", + "visualTheme.providerUnsupported": "The {capabilities} Provider cannot apply themes yet; theme metadata will still be retained.", + "visualTheme.capability.presentation": "PPT", + "visualTheme.capability.image": "image", + "visualTheme.capability.video": "video", + "visualTheme.mismatch": "{n} existing media items still use their previous style", + "visualTheme.unify": "Unify style", + "visualTheme.unifyUnavailable": "The current Provider cannot safely unify these media items", + "visualTheme.dialogTitle": "Choose a visual theme", + "visualTheme.dialogDescription": "Choose the classroom result you want. The system coordinates colour, decoration, and media direction.", + "visualTheme.autoName": "Auto recommendation", + "visualTheme.autoDescription": "Use “{theme}” based on this course", + "visualTheme.selected": "Selected", + "visualTheme.cancel": "Cancel", + "visualTheme.done": "Done", + "visualTheme.saving": "Saving…", + "visualTheme.unifyTitle": "Unify existing media", + "visualTheme.unifyBody": "Regenerate {n} mismatched media items with the current theme. Previous candidates and review history remain available.", + "visualTheme.unifyConfirm": "Confirm regeneration", + "visualTheme.reason.default_clear": "Course information is limited, so the safest clear classroom theme is recommended.", + "visualTheme.reason.future_content": "The course includes technology, engineering, energy, or career content.", + "visualTheme.reason.cultural_content": "The course focuses on culture, festivals, or literature.", + "visualTheme.reason.younger_or_activity_focused": "The course targets younger learners or includes many classroom activities.", + "visualTheme.reason.story_or_life_context": "The course includes daily-life scenes, reading, stories, or situational dialogue.", + "visualTheme.preset.classroom-clear.name": "Classroom Clear", + "visualTheme.preset.classroom-clear.description": "For everyday language teaching, explanation, and classroom projection", + "visualTheme.preset.active-learning.name": "Active Learning", + "visualTheme.preset.active-learning.description": "For dialogue, classroom practice, and game-based activities", + "visualTheme.preset.warm-story.name": "Warm Story", + "visualTheme.preset.warm-story.description": "For daily-life scenes, reading, stories, and situational dialogue", + "visualTheme.preset.eastern-elegance.name": "Eastern Elegance", + "visualTheme.preset.eastern-elegance.description": "For Chinese culture, festivals, literature, and intercultural lessons", + "visualTheme.preset.future-exploration.name": "Future Exploration", + "visualTheme.preset.future-exploration.description": "For technology, energy, engineering, and vocational education" }; -const ja: Dict = { +const ja: Record = { "app.version": "ローカル版", "nav.workflow": "HanClassStudio ワークフロー", "provider.title": "モデルサービス", @@ -1316,10 +1398,50 @@ const ja: Dict = { "theme.label": "表示モード", "theme.light": "ライト", "theme.dark": "ダーク", - "theme.system": "自動" + "theme.system": "自動", + "visualTheme.title": "ビジュアルテーマ", + "visualTheme.eyebrow": "統一された視覚方向", + "visualTheme.change": "変更", + "visualTheme.unavailable": "テーマを利用できません", + "visualTheme.unavailableDetail": "バックエンドのテーマ登録を読み込めませんでした。", + "visualTheme.autoBadge": "自動おすすめ", + "visualTheme.consistency": "PPT・画像・動画リクエストで同じテーマ方向を共有します", + "visualTheme.providerUnsupported": "{capabilities}プロバイダーはまだテーマを適用できません。テーマ情報は保持されます。", + "visualTheme.capability.presentation": "PPT", + "visualTheme.capability.image": "画像", + "visualTheme.capability.video": "動画", + "visualTheme.mismatch": "既存メディア {n} 件は以前のスタイルのままです", + "visualTheme.unify": "スタイルを統一", + "visualTheme.unifyUnavailable": "現在のプロバイダーでは安全に統一できません", + "visualTheme.dialogTitle": "ビジュアルテーマを選択", + "visualTheme.dialogDescription": "希望する授業の印象を選ぶと、色・装飾・メディア方向をシステムが統一します。", + "visualTheme.autoName": "自動おすすめ", + "visualTheme.autoDescription": "授業内容に基づき「{theme}」を使用", + "visualTheme.selected": "選択済み", + "visualTheme.cancel": "キャンセル", + "visualTheme.done": "完了", + "visualTheme.saving": "保存中…", + "visualTheme.unifyTitle": "既存メディアのスタイルを統一", + "visualTheme.unifyBody": "現在のテーマで不一致のメディア {n} 件を再生成します。以前の候補とレビュー履歴は保持されます。", + "visualTheme.unifyConfirm": "再生成を確認", + "visualTheme.reason.default_clear": "授業情報が少ないため、最も安全で明快な教室テーマを推奨します。", + "visualTheme.reason.future_content": "技術・工学・エネルギー・職業に関する内容が含まれます。", + "visualTheme.reason.cultural_content": "文化・祝祭・文学を中心とした授業です。", + "visualTheme.reason.younger_or_activity_focused": "年少学習者向け、または教室活動が多い授業です。", + "visualTheme.reason.story_or_life_context": "生活場面・読解・物語・場面会話が含まれます。", + "visualTheme.preset.classroom-clear.name": "教室クリア", + "visualTheme.preset.classroom-clear.description": "通常の語学授業、知識説明、教室投影に適しています", + "visualTheme.preset.active-learning.name": "アクティブラーニング", + "visualTheme.preset.active-learning.description": "会話、教室練習、ゲーム型活動に適しています", + "visualTheme.preset.warm-story.name": "温かな物語", + "visualTheme.preset.warm-story.description": "生活場面、読解、物語、場面会話に適しています", + "visualTheme.preset.eastern-elegance.name": "東方の雅", + "visualTheme.preset.eastern-elegance.description": "中国文化、祝祭、文学、異文化授業に適しています", + "visualTheme.preset.future-exploration.name": "未来探索", + "visualTheme.preset.future-exploration.description": "技術、エネルギー、工学、職業教育に適しています" }; -const ko: Dict = { +const ko: Record = { "app.version": "로컬 데모", "nav.workflow": "HanClassStudio 워크플로", "provider.title": "모델 서비스", @@ -1749,10 +1871,50 @@ const ko: Dict = { "theme.label": "화면 모드", "theme.light": "라이트", "theme.dark": "다크", - "theme.system": "자동" + "theme.system": "자동", + "visualTheme.title": "비주얼 테마", + "visualTheme.eyebrow": "통일된 시각 방향", + "visualTheme.change": "변경", + "visualTheme.unavailable": "테마를 사용할 수 없음", + "visualTheme.unavailableDetail": "백엔드 테마 레지스트리를 불러오지 못했습니다.", + "visualTheme.autoBadge": "자동 추천", + "visualTheme.consistency": "PPT, 이미지, 동영상 요청이 하나의 테마 방향을 공유합니다", + "visualTheme.providerUnsupported": "{capabilities} 제공자는 아직 테마를 적용하지 못하지만 테마 메타데이터는 유지됩니다.", + "visualTheme.capability.presentation": "PPT", + "visualTheme.capability.image": "이미지", + "visualTheme.capability.video": "동영상", + "visualTheme.mismatch": "기존 미디어 {n}개가 이전 스타일을 사용 중입니다", + "visualTheme.unify": "스타일 통일", + "visualTheme.unifyUnavailable": "현재 제공자로는 이 미디어를 안전하게 통일할 수 없습니다", + "visualTheme.dialogTitle": "비주얼 테마 선택", + "visualTheme.dialogDescription": "원하는 수업 결과를 선택하면 색상, 장식, 미디어 방향을 시스템이 통일합니다.", + "visualTheme.autoName": "자동 추천", + "visualTheme.autoDescription": "수업 내용에 따라 ‘{theme}’ 사용", + "visualTheme.selected": "선택됨", + "visualTheme.cancel": "취소", + "visualTheme.done": "완료", + "visualTheme.saving": "저장 중…", + "visualTheme.unifyTitle": "기존 미디어 스타일 통일", + "visualTheme.unifyBody": "현재 테마와 일치하지 않는 미디어 {n}개를 다시 생성합니다. 이전 후보와 검토 기록은 유지됩니다.", + "visualTheme.unifyConfirm": "재생성 확인", + "visualTheme.reason.default_clear": "수업 정보가 부족하여 가장 안전하고 명확한 교실 테마를 추천합니다.", + "visualTheme.reason.future_content": "기술, 공학, 에너지 또는 직업 관련 내용을 포함합니다.", + "visualTheme.reason.cultural_content": "문화, 축제 또는 문학 중심 수업입니다.", + "visualTheme.reason.younger_or_activity_focused": "어린 학습자 대상이거나 교실 활동이 많은 수업입니다.", + "visualTheme.reason.story_or_life_context": "생활 장면, 읽기, 이야기 또는 상황 대화를 포함합니다.", + "visualTheme.preset.classroom-clear.name": "명확한 교실", + "visualTheme.preset.classroom-clear.description": "일반 언어 수업, 지식 설명, 교실 투사에 적합", + "visualTheme.preset.active-learning.name": "활동형 학습", + "visualTheme.preset.active-learning.description": "대화, 교실 연습, 게임형 활동에 적합", + "visualTheme.preset.warm-story.name": "따뜻한 이야기", + "visualTheme.preset.warm-story.description": "생활 장면, 읽기, 이야기, 상황 대화에 적합", + "visualTheme.preset.eastern-elegance.name": "동방의 우아함", + "visualTheme.preset.eastern-elegance.description": "중국 문화, 축제, 문학, 상호문화 수업에 적합", + "visualTheme.preset.future-exploration.name": "미래 탐험", + "visualTheme.preset.future-exploration.description": "기술, 에너지, 공학, 직업 교육에 적합" }; -const ar: Dict = { +const ar: Record = { "app.version": "نسخة تجريبية محلية", "nav.workflow": "سير عمل HanClassStudio", "provider.title": "خدمات النموذج", @@ -2182,10 +2344,50 @@ const ar: Dict = { "theme.label": "المظهر", "theme.light": "فاتح", "theme.dark": "داكن", - "theme.system": "تلقائي" + "theme.system": "تلقائي", + "visualTheme.title": "السمة البصرية", + "visualTheme.eyebrow": "اتجاه بصري موحّد", + "visualTheme.change": "تغيير", + "visualTheme.unavailable": "السمة غير متاحة", + "visualTheme.unavailableDetail": "تعذّر تحميل سجل السمات من الخادم.", + "visualTheme.autoBadge": "توصية تلقائية", + "visualTheme.consistency": "تشارك طلبات PPT والصور والفيديو اتجاهاً بصرياً واحداً", + "visualTheme.providerUnsupported": "لا يدعم موفّر {capabilities} تطبيق السمة بعد؛ ستظل بيانات السمة محفوظة.", + "visualTheme.capability.presentation": "PPT", + "visualTheme.capability.image": "الصور", + "visualTheme.capability.video": "الفيديو", + "visualTheme.mismatch": "لا تزال {n} عناصر وسائط تستخدم النمط السابق", + "visualTheme.unify": "توحيد النمط", + "visualTheme.unifyUnavailable": "لا يستطيع الموفّر الحالي توحيد هذه الوسائط بأمان", + "visualTheme.dialogTitle": "اختيار السمة البصرية", + "visualTheme.dialogDescription": "اختر نتيجة الصف المطلوبة، وسيوحّد النظام الألوان والزخارف واتجاه الوسائط.", + "visualTheme.autoName": "توصية تلقائية", + "visualTheme.autoDescription": "استخدام «{theme}» وفق محتوى الدرس", + "visualTheme.selected": "محدد", + "visualTheme.cancel": "إلغاء", + "visualTheme.done": "تم", + "visualTheme.saving": "جارٍ الحفظ…", + "visualTheme.unifyTitle": "توحيد نمط الوسائط الحالية", + "visualTheme.unifyBody": "ستُعاد توليد {n} عناصر وسائط غير متطابقة وفق السمة الحالية، مع الاحتفاظ بالبدائل وسجل المراجعة.", + "visualTheme.unifyConfirm": "تأكيد إعادة التوليد", + "visualTheme.reason.default_clear": "معلومات الدرس محدودة، لذلك نوصي بسمة صفية واضحة وآمنة.", + "visualTheme.reason.future_content": "يتضمن الدرس التقنية أو الهندسة أو الطاقة أو المهارات المهنية.", + "visualTheme.reason.cultural_content": "يركز الدرس على الثقافة أو الأعياد أو الأدب.", + "visualTheme.reason.younger_or_activity_focused": "يستهدف الدرس متعلمين أصغر سناً أو يضم أنشطة صفية كثيرة.", + "visualTheme.reason.story_or_life_context": "يتضمن الدرس مواقف حياتية أو قراءة أو قصصاً أو حواراً موقفياً.", + "visualTheme.preset.classroom-clear.name": "وضوح صفي", + "visualTheme.preset.classroom-clear.description": "للتدريس اللغوي المعتاد والشرح والعرض داخل الصف", + "visualTheme.preset.active-learning.name": "تعلم نشط", + "visualTheme.preset.active-learning.description": "للحوار والتمارين الصفية والأنشطة القائمة على اللعب", + "visualTheme.preset.warm-story.name": "سرد دافئ", + "visualTheme.preset.warm-story.description": "للمواقف الحياتية والقراءة والقصص والحوار الموقفي", + "visualTheme.preset.eastern-elegance.name": "أناقة شرقية", + "visualTheme.preset.eastern-elegance.description": "للثقافة الصينية والأعياد والأدب والدروس العابرة للثقافات", + "visualTheme.preset.future-exploration.name": "استكشاف المستقبل", + "visualTheme.preset.future-exploration.description": "للتقنية والطاقة والهندسة والتعليم المهني" }; -const ru: Dict = { +const ru: Record = { "app.version": "Локальная демо-версия", "nav.workflow": "Рабочий процесс HanClassStudio", "provider.title": "Сервисы моделей", @@ -2615,7 +2817,47 @@ const ru: Dict = { "theme.label": "Оформление", "theme.light": "Светлое", "theme.dark": "Тёмное", - "theme.system": "Авто" + "theme.system": "Авто", + "visualTheme.title": "Визуальная тема", + "visualTheme.eyebrow": "Единое визуальное направление", + "visualTheme.change": "Изменить", + "visualTheme.unavailable": "Тема недоступна", + "visualTheme.unavailableDetail": "Не удалось загрузить реестр тем с сервера.", + "visualTheme.autoBadge": "Автоматическая рекомендация", + "visualTheme.consistency": "Запросы PPT, изображений и видео используют единое направление темы", + "visualTheme.providerUnsupported": "Провайдер {capabilities} пока не применяет темы; метаданные темы будут сохранены.", + "visualTheme.capability.presentation": "PPT", + "visualTheme.capability.image": "изображений", + "visualTheme.capability.video": "видео", + "visualTheme.mismatch": "Существующие медиа ({n}) всё ещё используют прежний стиль", + "visualTheme.unify": "Унифицировать стиль", + "visualTheme.unifyUnavailable": "Текущий провайдер не может безопасно унифицировать эти медиа", + "visualTheme.dialogTitle": "Выберите визуальную тему", + "visualTheme.dialogDescription": "Выберите желаемый результат для урока, а система согласует цвета, оформление и направление медиа.", + "visualTheme.autoName": "Автоматическая рекомендация", + "visualTheme.autoDescription": "Использовать «{theme}» по содержанию курса", + "visualTheme.selected": "Выбрано", + "visualTheme.cancel": "Отмена", + "visualTheme.done": "Готово", + "visualTheme.saving": "Сохранение…", + "visualTheme.unifyTitle": "Унифицировать существующие медиа", + "visualTheme.unifyBody": "Повторно создать {n} несовпадающих медиа в текущей теме. Предыдущие варианты и история проверки сохранятся.", + "visualTheme.unifyConfirm": "Подтвердить создание", + "visualTheme.reason.default_clear": "Данных курса недостаточно, поэтому выбрана самая надёжная и ясная тема для класса.", + "visualTheme.reason.future_content": "Курс содержит материалы о технологиях, инженерии, энергетике или профессиях.", + "visualTheme.reason.cultural_content": "Курс посвящён культуре, праздникам или литературе.", + "visualTheme.reason.younger_or_activity_focused": "Курс рассчитан на младших учащихся или содержит много классных активностей.", + "visualTheme.reason.story_or_life_context": "Курс включает бытовые ситуации, чтение, истории или ситуативные диалоги.", + "visualTheme.preset.classroom-clear.name": "Ясный класс", + "visualTheme.preset.classroom-clear.description": "Для обычных языковых занятий, объяснения и проекции в классе", + "visualTheme.preset.active-learning.name": "Активное обучение", + "visualTheme.preset.active-learning.description": "Для диалогов, классной практики и игровых заданий", + "visualTheme.preset.warm-story.name": "Тёплая история", + "visualTheme.preset.warm-story.description": "Для бытовых сцен, чтения, историй и ситуативных диалогов", + "visualTheme.preset.eastern-elegance.name": "Восточная элегантность", + "visualTheme.preset.eastern-elegance.description": "Для китайской культуры, праздников, литературы и межкультурных уроков", + "visualTheme.preset.future-exploration.name": "Исследование будущего", + "visualTheme.preset.future-exploration.description": "Для технологий, энергетики, инженерии и профессионального образования" }; const dictionaries: Record = { zh, en, ja, ko, ar, ru }; diff --git a/apps/web/src/state.ts b/apps/web/src/state.ts index 724bff4..3279010 100644 --- a/apps/web/src/state.ts +++ b/apps/web/src/state.ts @@ -91,6 +91,7 @@ const EDITABLE_ACTIONS = new Set([ "review_media", "replace_media", "force_regenerate_media", + "regenerate_media_for_theme", ]); /** @@ -128,6 +129,14 @@ export function canUseStageAction(project: ProjectState | null, stageId: Workflo return access.executable && access.availableActions.includes(action); } +export function canUnifyVisualThemeMedia(project: ProjectState | null): boolean { + return Boolean( + project?.visual_theme?.mismatched_media_count + && project.visual_theme.regeneration_available + && canUseStageAction(project, "presentation", "regenerate_media_for_theme"), + ); +} + /** Only ask for the teacher summary after the design stage has produced * authoritative State-first artifacts. A ready/not-started stage is an * expected empty state, not an API error to surface in the browser console. */ diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d9f8e77..0bc017d 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -316,6 +316,231 @@ button:disabled, background: var(--surface-soft); } +.visual-theme-section { + display: grid; + gap: 10px; + margin-block: 20px; +} + +.visual-theme-section > h3 { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + color: var(--ink); + font-size: 13px; +} + +.visual-theme-section > h3 svg { + color: var(--primary); +} + +.visual-theme-current-card { + display: grid; + grid-template-columns: 104px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding: 12px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface); +} + +.visual-theme-current-copy, +.visual-theme-option-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.visual-theme-current-copy > span, +.visual-theme-option-copy > span { + color: var(--muted); + font-size: 11px; +} + +.visual-theme-current-copy > small { + color: var(--muted-strong); + font-size: 10px; +} + +.visual-theme-name-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px; +} + +.visual-theme-auto-badge, +.visual-theme-selected { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--success) 42%, var(--line)); + border-radius: 999px; + background: var(--success-soft); + color: var(--success); + font-size: 9px; + font-weight: 700; +} + +.visual-theme-change { + white-space: nowrap; +} + +.visual-theme-preview { + position: relative; + width: 104px; + aspect-ratio: 16 / 9; + overflow: hidden; + display: block; + border: 1px solid color-mix(in srgb, var(--visual-theme-text, var(--ink)) 18%, transparent); + border-radius: 6px; + background: var(--visual-theme-bg, var(--surface-soft)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, white 14%, transparent); +} + +.visual-theme-preview-heading, +.visual-theme-preview-copy, +.visual-theme-preview-card, +.visual-theme-preview-accent { + position: absolute; + display: block; + border-radius: 999px; +} + +.visual-theme-preview-heading { + inset-block-start: 18%; + inset-inline-start: 10%; + width: 44%; + height: 9%; + background: var(--visual-theme-primary, var(--primary)); +} + +.visual-theme-preview-copy { + inset-block-start: 34%; + inset-inline-start: 10%; + width: 32%; + height: 5%; + background: color-mix(in srgb, var(--visual-theme-text, var(--ink)) 45%, transparent); +} + +.visual-theme-preview-card { + inset-block: 17% 14%; + inset-inline-end: 9%; + width: 38%; + border-radius: 5px; + background: var(--visual-theme-surface, var(--surface)); + box-shadow: 0 2px 8px color-mix(in srgb, var(--visual-theme-text, var(--ink)) 14%, transparent); +} + +.visual-theme-preview-accent { + inset-block-end: 16%; + inset-inline-start: 10%; + width: 22%; + height: 8%; + background: var(--visual-theme-accent, var(--primary)); +} + +.visual-theme-preview-activity .visual-theme-preview-card { transform: rotate(-3deg); } +.visual-theme-preview-story .visual-theme-preview-card { border-radius: 50% 50% 8px 8px; } +.visual-theme-preview-paper .visual-theme-preview-heading { width: 34%; } +.visual-theme-preview-grid { background-image: linear-gradient(color-mix(in srgb, var(--visual-theme-primary) 16%, transparent) 1px, transparent 1px), linear-gradient(90deg, color-mix(in srgb, var(--visual-theme-primary) 16%, transparent) 1px, transparent 1px); background-size: 13px 13px; } + +.visual-theme-consistency, +.visual-theme-support-note { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + color: var(--success); + font-size: 10px; +} + +.visual-theme-support-note { + color: var(--muted); +} + +.visual-theme-mismatch { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + padding: 9px 11px; + border-inline-start: 3px solid var(--warning); + background: var(--warning-soft); + color: var(--warning); + font-size: 11px; +} + +.visual-theme-mismatch small { + color: var(--muted-strong); +} + +.visual-theme-dialog-description { + margin: 16px 22px 0; + color: var(--muted); + font-size: 12px; +} + +.visual-theme-modal > .notice { + margin-inline: 22px; +} + +.visual-theme-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 18px 22px; +} + +.visual-theme-option { + position: relative; + min-width: 0; + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + align-items: center; + gap: 12px; + min-height: 92px; + padding: 11px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface); + cursor: pointer; +} + +.visual-theme-option input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.visual-theme-option:has(input:focus-visible) { + outline: 3px solid color-mix(in srgb, var(--focus) 36%, transparent); + outline-offset: 2px; +} + +.visual-theme-option.selected { + border-color: var(--primary); + box-shadow: inset 0 0 0 1px var(--primary); +} + +.visual-theme-option .visual-theme-selected { + position: absolute; + inset-block-start: 7px; + inset-inline-end: 7px; +} + +.visual-theme-dialog-actions { + justify-content: flex-end; + margin: 0; + padding: 14px 22px 20px; + border-top: 1px solid var(--line); +} + .boundary-note p, .production-note, .more-actions p { margin: 6px 0 0; color: var(--muted); line-height: 1.65; } @@ -2718,6 +2943,40 @@ dialog.confirm-dialog { grid-template-columns: 1fr; } + .visual-theme-current-card { + grid-template-columns: 88px minmax(0, 1fr); + } + + .visual-theme-current-card > .visual-theme-preview { + width: 88px; + } + + .visual-theme-change { + grid-column: 1 / -1; + width: 100%; + } + + .visual-theme-options { + grid-template-columns: 1fr; + padding-inline: 18px; + } + + .visual-theme-option { + grid-template-columns: 88px minmax(0, 1fr); + } + + .visual-theme-option > .visual-theme-preview { + width: 88px; + } + + .visual-theme-dialog-description { + margin-inline: 18px; + } + + .visual-theme-dialog-actions { + padding-inline: 18px; + } + .export-format-grid { grid-template-columns: 1fr; } .state-flow { justify-content: flex-start; flex-wrap: wrap; overflow: visible; } .project-status-menu { grid-column: 1 / -1; justify-self: end; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 1f9487d..f6f299d 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -406,6 +406,63 @@ export interface StaleState { changed_at?: string | null; } +export type VisualThemeId = + | "classroom-clear" + | "active-learning" + | "warm-story" + | "eastern-elegance" + | "future-exploration"; + +export interface VisualThemeSelection { + mode: "auto" | "manual"; + selected_theme_id: VisualThemeId; + recommended_theme_id?: VisualThemeId | null; + recommendation_reason?: string | null; + theme_version: string; +} + +export interface VisualThemePreview { + background: string; + surface: string; + primary: string; + accent: string; + text: string; + motif: string; +} + +export interface VisualThemePreset { + theme_id: VisualThemeId; + version: string; + name_key: string; + description_key: string; + preview: VisualThemePreview; +} + +export interface VisualThemeCatalog { + schema: string; + theme_version: string; + presets: VisualThemePreset[]; +} + +export interface ThemeCapabilitySupport { + capability: "presentation" | "image" | "video"; + provider_id?: string | null; + state: "supported" | "unsupported" | "unavailable" | "not_configured"; + theme_metadata_preserved: boolean; + reason?: string | null; +} + +export interface VisualThemeState { + selection: VisualThemeSelection; + effective_theme_id: VisualThemeId; + effective_theme_version: string; + media_state: "not_generated" | "current" | "mixed"; + mismatched_media_count: number; + mismatched_media_ids: string[]; + provider_support: ThemeCapabilitySupport[]; + regeneration_available: boolean; +} + export interface ProjectState { project_id: string; status: string; @@ -418,6 +475,7 @@ export interface ProjectState { artifacts?: Record; stale_state?: StaleState; provider_readiness?: ProviderDefinition[]; + visual_theme?: VisualThemeState | null; last_updated_at?: string | null; quality_state?: QualityState | null; source_material?: SourceMaterial | null; From 0e8766d8cef9ce083b0ee0860c5a1def4dd6a050 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:20:00 +0800 Subject: [PATCH 4/5] test(theme): cover visual theme contracts and browser flows --- apps/api/tests/test_presentation_theme.py | 240 +++++++++++++++++- .../tests/test_raster_provider_experiment.py | 4 +- apps/web/src/state.test.ts | 28 +- e2e/workflow-contract.spec.mjs | 61 +++++ 4 files changed, 323 insertions(+), 10 deletions(-) diff --git a/apps/api/tests/test_presentation_theme.py b/apps/api/tests/test_presentation_theme.py index 7fca930..96fb6c4 100644 --- a/apps/api/tests/test_presentation_theme.py +++ b/apps/api/tests/test_presentation_theme.py @@ -3,18 +3,28 @@ import json from pathlib import Path +from fastapi.testclient import TestClient from PIL import Image +import hcs_api.main as main from hcs_api.illustration_brief import compile_illustration_request from hcs_api import storage +from hcs_api.main import app +from hcs_api.media import generate_configured_media from hcs_api.models import ( AssetFile, AssetManifest, IllustrationBrief, LessonBlueprint, LessonProfile, - PresentationContentPlan, QualityReport, + LessonSlide, MediaRequirements, PresentationContentPlan, ProviderSettings, + QualityReport, VideoGenerationRequestPlan, ) from hcs_api.presentation_theme import ( - DEFAULT_THEME_ID, WARM_THEME_ID, persist_theme_decision, - resolve_presentation_theme, + DEFAULT_THEME_ID, THEME_SELECTION_PATH, WARM_THEME_ID, + persist_theme_decision, persist_visual_theme_selection, + recommend_visual_theme, resolve_presentation_theme, theme_by_id, + video_generation_requests, visual_theme_catalog, + presentation_theme_for_project, visual_theme_selection_for_project, + visual_theme_state_for_project, ) +from hcs_api.providers import provider_capability_catalog from hcs_api.renderer import render_lesson from hcs_api.svg_components import render_scene_spec from hcs_api.pptx_exporter import export_editable_pptx @@ -38,6 +48,25 @@ def test_theme_serialization_is_versioned_and_provider_neutral() -> None: assert forbidden not in type(restored.theme).model_fields +def test_registry_contains_exactly_five_versioned_presets() -> None: + catalog = visual_theme_catalog() + assert [preset.theme_id for preset in catalog.presets] == [ + "classroom-clear", "active-learning", "warm-story", "eastern-elegance", "future-exploration", + ] + assert catalog.theme_version == "1" + assert all(preset.version == "1" for preset in catalog.presets) + assert all(preset.preview.background.startswith("#") for preset in catalog.presets) + assert "auto" not in {preset.theme_id for preset in catalog.presets} + + +def test_auto_recommendation_is_deterministic_and_falls_back_to_classroom_clear() -> None: + assert recommend_visual_theme() == ("classroom-clear", "default_clear") + assert recommend_visual_theme(LessonProfile(subject="能源工程"))[0] == "future-exploration" + assert recommend_visual_theme(LessonProfile(lesson_type="中国节日文化"))[0] == "eastern-elegance" + assert recommend_visual_theme(LessonProfile(target_students="儿童", lesson_type="游戏练习"))[0] == "active-learning" + assert recommend_visual_theme(LessonProfile(lesson_type="生活情景对话"))[0] == "warm-story" + + def test_teacher_selected_theme_falls_back_from_unknown_font(tmp_path: Path) -> None: decision = resolve_presentation_theme(tmp_path, selection={ "decision_source": "teacher_selected", "theme_id": WARM_THEME_ID, @@ -78,15 +107,161 @@ def test_brief_html_and_svg_consume_one_theme(tmp_path: Path) -> None: persist_theme_decision(tmp_path, decision) request = compile_illustration_request(_brief(WARM_THEME_ID), "greeting") assert request.theme_id == WARM_THEME_ID - assert "Presentation theme ppt_master_warm_classroom_v1@1" in request.scene_description + assert "Presentation theme warm-story@1" in request.scene_description svg = render_scene_spec({"concept": "喝水", "illustration_level": "scene", "setting": "neutral", "subjects": [], "objects": []}, presentation_theme=decision.theme) - assert "#FCF8F3" in svg + assert "#FCF7F0" in svg html = render_lesson(tmp_path, LessonProfile(lesson_title="问候"), LessonBlueprint(lesson_title="问候"), AssetManifest(), QualityReport()) text = html.read_text(encoding="utf-8") - assert "--bg: #FCF8F3" in text + assert "--bg: #FCF7F0" in text assert '"微软雅黑"' in text +def test_selection_persists_and_reload_restores_manual_mode(tmp_path: Path) -> None: + selection = persist_visual_theme_selection( + tmp_path, mode="manual", selected_theme_id="active-learning", + profile=LessonProfile(lesson_title="问候"), + ) + assert selection.selected_theme_id == "active-learning" + assert (tmp_path / THEME_SELECTION_PATH).is_file() + restored = visual_theme_selection_for_project(tmp_path, profile=LessonProfile(lesson_title="问候")) + assert restored.mode == "manual" + assert restored.selected_theme_id == "active-learning" + assert resolve_presentation_theme(tmp_path).theme.theme_id == "active-learning" + + +def test_legacy_theme_files_remain_readable_without_freezing_old_auto_choice(tmp_path: Path) -> None: + inherited_root = tmp_path / "inherited" + image_path = inherited_root / "assets/images/scene.png" + image_path.parent.mkdir(parents=True) + Image.new("RGB", (80, 45), "#EFA37E").save(image_path) + data_dir = inherited_root / "assets/data" + data_dir.mkdir(parents=True) + (data_dir / "asset_manifest.json").write_text(AssetManifest(images=[AssetFile( + id="scene", kind="image", path="assets/images/scene.png", + )]).model_dump_json(by_alias=True), encoding="utf-8") + selection_path = inherited_root / THEME_SELECTION_PATH + selection_path.parent.mkdir(parents=True) + selection_path.write_text(json.dumps({ + "decision_source": "inherited_from_existing_assets", + }), encoding="utf-8") + inherited = visual_theme_selection_for_project(inherited_root) + assert inherited.mode == "manual" + assert inherited.selected_theme_id == "warm-story" + + auto_root = tmp_path / "auto" + auto_data = auto_root / "assets/data" + auto_data.mkdir(parents=True) + (auto_data / "lesson_profile.json").write_text( + LessonProfile(subject="能源工程").model_dump_json(by_alias=True), encoding="utf-8", + ) + old_theme = theme_by_id("classroom-clear").model_dump(mode="json") + old_theme["theme_id"] = "ppt_master_blue_classroom_v1" + old_theme.pop("video_treatment") + decision_path = auto_root / "presentation/presentation_theme.json" + decision_path.parent.mkdir(parents=True) + decision_path.write_text(json.dumps({ + "schema": "hanclassstudio.presentation_theme.v1", + "decision_source": "ppt_master_auto", + "theme": old_theme, + }), encoding="utf-8") + assert visual_theme_selection_for_project(auto_root).selected_theme_id == "future-exploration" + assert presentation_theme_for_project(auto_root).theme_id == "future-exploration" + + +def test_theme_switch_keeps_old_media_provenance_and_exposes_backend_action(tmp_path: Path) -> None: + settings = ProviderSettings() + old = AssetFile( + id="scene", kind="image", path="assets/images/scene.svg", + presentation_theme_id="classroom-clear", presentation_theme_version="1", + ) + manifest = AssetManifest(images=[old]) + persist_visual_theme_selection(tmp_path, mode="manual", selected_theme_id="warm-story") + state = visual_theme_state_for_project( + tmp_path, + manifest=manifest, + provider_catalog=provider_capability_catalog(settings), + provider_settings=settings, + ) + assert old.presentation_theme_id == "classroom-clear" + assert state.media_state == "mixed" + assert state.mismatched_media_ids == ["scene"] + assert state.regeneration_available is True + + +def test_manual_theme_persistence_does_not_relabel_unknown_historical_media(tmp_path: Path) -> None: + old = AssetFile(id="legacy", kind="image", path="assets/images/legacy.svg") + manifest = AssetManifest(images=[old]) + decision = resolve_presentation_theme( + tmp_path, + selection={"mode": "manual", "selected_theme_id": "warm-story"}, + ) + + persist_theme_decision(tmp_path, decision, manifest) + + assert manifest.presentation_theme_id == "warm-story" + assert old.presentation_theme_id is None + assert old.presentation_theme_version is None + state = visual_theme_state_for_project( + tmp_path, + manifest=manifest, + provider_catalog=provider_capability_catalog(ProviderSettings()), + provider_settings=ProviderSettings(), + ) + assert state.media_state == "mixed" + assert state.mismatched_media_ids == ["legacy"] + + +def test_video_request_retains_theme_and_reports_unsupported_provider() -> None: + settings = ProviderSettings() + state = visual_theme_state_for_project( + Path("."), provider_catalog=provider_capability_catalog(settings), provider_settings=settings, + ) + video_support = next(item for item in state.provider_support if item.capability == "video") + blueprint = LessonBlueprint(slides=[LessonSlide( + id=1, slide_type="video", layout_variant="media", + title="场景", media_requirements=MediaRequirements(video_key="scene-video", video_scene_prompt="A greeting scene"), + )]) + requests = video_generation_requests(blueprint, theme_by_id("eastern-elegance"), video_support) + assert requests[0].theme_id == "eastern-elegance" + assert requests[0].theme_direction.color_grade + assert requests[0].theme_application_state == "unsupported" + assert requests[0].theme_application_reason + + +def test_media_pipeline_persists_video_theme_request_without_fake_asset(tmp_path: Path) -> None: + persist_visual_theme_selection(tmp_path, mode="manual", selected_theme_id="future-exploration") + blueprint = LessonBlueprint(slides=[LessonSlide( + id=1, slide_type="video", layout_variant="media", + title="能源", media_requirements=MediaRequirements(video_key="energy-video", video_scene_prompt="A safe energy lab"), + )]) + manifest = generate_configured_media(tmp_path, blueprint, ProviderSettings()) + assert manifest.video == [] + payload = VideoGenerationRequestPlan.model_validate_json( + (tmp_path / "assets/data/video_generation_requests.json").read_text(encoding="utf-8") + ) + assert payload.requests[0].theme_id == "future-exploration" + assert payload.requests[0].theme_application_state == "unsupported" + + +def test_media_pipeline_tags_only_new_placeholder_with_current_theme(tmp_path: Path) -> None: + persist_visual_theme_selection(tmp_path, mode="manual", selected_theme_id="active-learning") + blueprint = LessonBlueprint(slides=[LessonSlide( + id=1, + slide_type="scene", + layout_variant="media", + title="练习", + media_requirements=MediaRequirements( + image_key="activity-scene", + image_prompt="Two adult learners practise a greeting", + ), + )]) + + manifest = generate_configured_media(tmp_path, blueprint, ProviderSettings()) + + assert manifest.images[0].presentation_theme_id == "active-learning" + assert manifest.images[0].presentation_theme_version == "1" + + def test_pptx_and_html_report_the_same_persisted_theme(tmp_path: Path, monkeypatch) -> None: monkeypatch.setattr(storage, "RUNTIME_DIR", tmp_path / "runtime") monkeypatch.setattr(storage, "PROJECTS_DIR", tmp_path / "runtime" / "projects") @@ -101,6 +276,57 @@ def test_pptx_and_html_report_the_same_persisted_theme(tmp_path: Path, monkeypat html = render_lesson(root, LessonProfile(lesson_title="问候"), blueprint, AssetManifest(), QualityReport()) pptx = export_editable_pptx("themed") assert pptx.is_file() - assert "#FCF8F3" in html.read_text(encoding="utf-8") + assert "#FCF7F0" in html.read_text(encoding="utf-8") report = storage.read_json("themed", "quality/pptx_quality_report.json") assert report["presentation_theme"]["theme_id"] == WARM_THEME_ID + + +def test_visual_theme_api_persists_without_relabeling_or_regenerating_media(tmp_path: Path, monkeypatch) -> None: + runtime = tmp_path / "runtime" + monkeypatch.setattr(storage, "RUNTIME_DIR", runtime) + monkeypatch.setattr(storage, "PROJECTS_DIR", runtime / "projects") + monkeypatch.setattr(storage, "CONFIG_DIR", runtime / "config") + monkeypatch.setattr(storage, "PROVIDER_SETTINGS_PATH", runtime / "config/provider_settings.json") + monkeypatch.setattr(main, "PROJECTS_DIR", runtime / "projects") + root = storage.ensure_project("theme-api") + storage.write_model("theme-api", "lesson_profile.json", LessonProfile(lesson_title="问候")) + storage.write_model("theme-api", "lesson_blueprint.json", LessonBlueprint(lesson_title="问候")) + storage.write_model("theme-api", "asset_manifest.json", AssetManifest(images=[AssetFile( + id="existing", kind="image", path="assets/images/existing.png", + presentation_theme_id="classroom-clear", presentation_theme_version="1", + )])) + (root / "courseware/lesson.html").write_text("old render", encoding="utf-8") + storage.bump_project_revision("theme-api") + client = TestClient(app) + + catalog = client.get("/api/visual-themes") + assert catalog.status_code == 200 + assert len(catalog.json()["presets"]) == 5 + revision = storage.project_revision("theme-api") + response = client.put( + f"/api/projects/theme-api/visual-theme?expected_revision={revision}", + json={"mode": "manual", "selected_theme_id": "active-learning"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["visual_theme"]["selection"]["selected_theme_id"] == "active-learning" + assert body["visual_theme"]["mismatched_media_ids"] == ["existing"] + assert body["visual_theme"]["regeneration_available"] is True + presentation = next(stage for stage in body["stages"] if stage["stage_id"] == "presentation") + assert "regenerate_media_for_theme" in presentation["available_actions"] + assert set(body["stale_state"]["stale_stages"]) >= {"render", "quality", "delivery"} + stored_manifest = storage.read_model("theme-api", "asset_manifest.json", AssetManifest) + assert stored_manifest is not None + assert stored_manifest.images[0].presentation_theme_id == "classroom-clear" + assert not (root / "assets/images/active-learning.png").exists() + + reloaded = client.get("/api/projects/theme-api").json() + assert reloaded["visual_theme"]["selection"]["selected_theme_id"] == "active-learning" + + def unreadable_provider_settings() -> ProviderSettings: + raise ValueError("simulated corrupt optional provider settings") + + monkeypatch.setattr(storage, "read_provider_settings", unreadable_provider_settings) + recovered = storage.get_project_state("theme-api") + assert recovered.visual_theme is not None + assert recovered.visual_theme.selection.selected_theme_id == "active-learning" diff --git a/apps/api/tests/test_raster_provider_experiment.py b/apps/api/tests/test_raster_provider_experiment.py index a29059f..20aa258 100644 --- a/apps/api/tests/test_raster_provider_experiment.py +++ b/apps/api/tests/test_raster_provider_experiment.py @@ -27,7 +27,7 @@ ) from hcs_api.raster_provider import EXPERIMENTAL_PROVIDER, RasterProviderError, generate_experimental_raster_image from hcs_api.raster_provider_benchmark import BENCHMARK_CONCEPTS, create_raster_provider_ab_gallery -from hcs_api.presentation_theme import DEFAULT_THEME_ID +from hcs_api.presentation_theme import DEFAULT_THEME_ID, theme_by_id PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x03\x08\x06\x00\x00\x00" @@ -121,7 +121,7 @@ def test_timeout_http_and_invalid_mime_each_keep_svg_fallback(tmp_path: Path, mo ] for number, failure in enumerate(cases): root = tmp_path / str(number) - expected = generate_placeholder_media(root, _blueprint()).images[0] + expected = generate_placeholder_media(root, _blueprint(), theme=theme_by_id(DEFAULT_THEME_ID)).images[0] expected_bytes = (root / expected.path).read_bytes() if failure is None: _mock_generation(monkeypatch, {"url": "https://temporary.test/image.png"}, download=PNG, mime_type="text/html") diff --git a/apps/web/src/state.test.ts b/apps/web/src/state.test.ts index ccdf684..65f8563 100644 --- a/apps/web/src/state.test.ts +++ b/apps/web/src/state.test.ts @@ -1,4 +1,4 @@ -import { canUseStageAction, exportActionsFromProject, getAvailableCapabilityProviders, getCapabilityProviders, getCapabilityRegistryProviders, getConfigurableCapabilityProviders, getNextWorkflowAction, getStageAccess, isCurrentRequest, pipelineStepsFromProject, providerConfigSnapshot, providerStatus, sanitizeProviderConfig, shouldFetchDesignSummary, shouldPersistProviderConfig } from "./state"; +import { canUnifyVisualThemeMedia, canUseStageAction, exportActionsFromProject, getAvailableCapabilityProviders, getCapabilityProviders, getCapabilityRegistryProviders, getConfigurableCapabilityProviders, getNextWorkflowAction, getStageAccess, isCurrentRequest, pipelineStepsFromProject, providerConfigSnapshot, providerStatus, sanitizeProviderConfig, shouldFetchDesignSummary, shouldPersistProviderConfig } from "./state"; import type { ProjectState, ProviderRegistryCatalog } from "./types"; function equal(actual: unknown, expected: unknown): void { @@ -219,4 +219,30 @@ equal(isCurrentRequest(2, 2), true); equal(isCurrentRequest(1, 2), false); equal(isCurrentRequest(2, 2, true), false); +const mixedThemeProject: ProjectState = { + ...project, + visual_theme: { + selection: { mode: "manual", selected_theme_id: "warm-story", theme_version: "1" }, + effective_theme_id: "warm-story", + effective_theme_version: "1", + media_state: "mixed", + mismatched_media_count: 2, + mismatched_media_ids: ["hero", "scene"], + provider_support: [], + regeneration_available: true, + }, + stages: project.stages!.map((stage) => stage.stage_id === "presentation" + ? { ...stage, available_actions: ["edit_blueprint", "regenerate_media_for_theme"] } + : stage), +}; +equal(canUnifyVisualThemeMedia(mixedThemeProject), true); +equal(canUnifyVisualThemeMedia({ + ...mixedThemeProject, + stages: mixedThemeProject.stages!.map((stage) => stage.stage_id === "presentation" ? { ...stage, available_actions: ["edit_blueprint"] } : stage), +}), false); +equal(canUnifyVisualThemeMedia({ + ...mixedThemeProject, + visual_theme: { ...mixedThemeProject.visual_theme!, regeneration_available: false }, +}), false); + console.log("frontend state contract tests passed"); diff --git a/e2e/workflow-contract.spec.mjs b/e2e/workflow-contract.spec.mjs index 206dc2c..6948b2a 100644 --- a/e2e/workflow-contract.spec.mjs +++ b/e2e/workflow-contract.spec.mjs @@ -71,6 +71,67 @@ test("responsive workflow and settings dialog remain keyboard-safe", async ({ pa await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe(""); }); +test("visual theme selection is explicit, persistent, accessible, and responsive", async ({ page }) => { + let themePuts = 0; + page.on("request", (request) => { + if (request.method() === "PUT" && request.url().includes("/visual-theme")) themePuts += 1; + }); + await page.goto("/"); + const onboarding = page.locator("dialog.onboarding-dialog[open]"); + if (await onboarding.count()) await onboarding.getByRole("button", { name: "跳过", exact: true }).click(); + await page.locator('input[type="file"]').first().setInputFiles(fixture); + await expect(page).toHaveURL(/project_id=[^&]+&stage=profile/, { timeout: 30_000 }); + const projectId = new URL(page.url()).searchParams.get("project_id"); + await page.goto(`/?project_id=${projectId}&stage=presentation`); + + const card = page.locator(".visual-theme-section"); + await expect(card).toBeVisible(); + await expect(card).toContainText("视觉主题"); + await expect(card).toContainText("PPT、图片与视频请求将共享同一主题方向"); + await expect.poll(() => themePuts).toBe(0); + + const change = card.getByRole("button", { name: "更换", exact: true }); + await change.click(); + let dialog = page.locator("dialog.visual-theme-dialog[open]"); + await expect(dialog).toHaveAttribute("aria-describedby", "visualThemeDialogDescription"); + await expect(dialog.getByRole("radio")).toHaveCount(6); + await expect.poll(() => dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); + await page.keyboard.press("Tab"); + await expect.poll(() => dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(change).toBeFocused(); + await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe(""); + + await change.click(); + dialog = page.locator("dialog.visual-theme-dialog[open]"); + await dialog.getByRole("radio", { name: /温暖叙事/ }).check(); + await dialog.locator(".visual-theme-dialog-actions").getByRole("button", { name: "取消", exact: true }).click(); + await expect.poll(() => themePuts).toBe(0); + + await change.click(); + dialog = page.locator("dialog.visual-theme-dialog[open]"); + await dialog.getByRole("radio", { name: /温暖叙事/ }).check(); + const save = page.waitForResponse((response) => + response.request().method() === "PUT" + && response.url().includes("/visual-theme") + && response.ok() + ); + await dialog.getByRole("button", { name: "完成", exact: true }).click(); + await save; + await expect(dialog).toBeHidden(); + await expect(card).toContainText("温暖叙事"); + await expect.poll(() => themePuts).toBe(1); + + await page.reload(); + await expect(page.locator(".visual-theme-section")).toContainText("温暖叙事"); + await page.setViewportSize({ width: 390, height: 844 }); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); + await page.locator(".visual-theme-section").getByRole("button", { name: "更换", exact: true }).click(); + await expect(page.locator("dialog.visual-theme-dialog[open] .visual-theme-option")).toHaveCount(6); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBe(390); +}); + test("provider save reports a failed edit and retries the next edit", async ({ page }) => { let providerPuts = 0; let aborted = false; From c9b63a0dd676b1333b4936052e82bf2c11d71441 Mon Sep 17 00:00:00 2001 From: Hsueh0216 <105940021+Hsueh0216@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:21:35 +0800 Subject: [PATCH 5/5] docs(theme): record visual theme v1 delivery --- docs/frontend-remediation-progress.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/frontend-remediation-progress.md b/docs/frontend-remediation-progress.md index 4e11b3d..5bb963e 100644 --- a/docs/frontend-remediation-progress.md +++ b/docs/frontend-remediation-progress.md @@ -79,3 +79,25 @@ The browser channel itself remains unavailable in this host environment, and the acceptance used an isolated temporary runtime rather than production data. No visual, keyboard, or state-management claim was made from static tests alone. + +## Visual theme v1 + +**Status: Implemented — Draft PR #23** + +The focused visual-theme work is on `codex/visual-theme-v1` through feature +commit `0e8766d8cef9ce083b0ee0860c5a1def4dd6a050`, stacked on +`codex/zero-beginner-courseware-quality` so it remains separately reviewable. +It adds a backend-owned five-theme registry, deterministic auto recommendation, +project-persistent selection, presentation/image propagation, explicit media +mismatch handling, and an accessible responsive selection dialog. + +- Validation: focused theme tests `37 passed`; full API suite `488 passed, 1 + skipped`; frontend state contracts, TypeScript, production build, and 8 + Playwright E2E tests passed. +- Browser recheck: 1280×800 and 390×844 verified recommendation, manual + selection, persistence after reload, mismatch confirmation cancellation, + focus restoration, and no horizontal overflow. No console warnings or errors + were observed. +- Limitation: v1 only propagates a video style request plan. It does not claim + to install or execute a real video provider, and it intentionally excludes a + theme editor, template marketplace, and arbitrary visual controls.
{t("presentation.compatibility")}
{t("visualTheme.consistency")}
+ {t("visualTheme.providerUnsupported", { + capabilities: unsupported.map((item) => t(`visualTheme.capability.${item.capability}`)).join("、"), + })} +
{t("visualTheme.eyebrow")}
{t("visualTheme.dialogDescription")}
{t("visualTheme.unifyBody", { n: count })}