| 1 | /* PPT Master - Strategist confirmation UI |
| 2 | * Stage 1 confirms the communication contract and the free-design/template |
| 3 | * choice together. Stage 2 combines the coherent deck solution and production |
| 4 | * mechanics, then writes the final result. |
| 5 | * Finite fields use /static/catalogs.json; coordinated design directions seed |
| 6 | * color, typography, icons, generated-image rendering, and conditional |
| 7 | * template-application prose. Final confirm saves result.json. |
| 8 | */ |
| 9 | (function () { |
| 10 | "use strict"; |
| 11 | |
| 12 | // ---- i18n ------------------------------------------------------------ |
| 13 | var MESSAGES = { |
| 14 | en: { |
| 15 | page_title: "PPT Master - Confirm Design", |
| 16 | topbar_hint: "Answer the open questions, or pick and customize recommendations, then continue.", |
| 17 | stage_anchors: "Stage 1 · Communication contract", |
| 18 | stage_final_plan: "Stage 2 · Final plan & production", |
| 19 | loading: "Loading…", |
| 20 | load_error: "Could not load the current recommendation stage. The AI must write it before launch.", |
| 21 | btn_confirm: "Confirm", |
| 22 | btn_confirm_contract: "Confirm contract & continue →", |
| 23 | btn_confirm_final_plan: "Confirm final plan →", |
| 24 | deriving: "Generating the downstream options from your choices…", |
| 25 | template_selection_required: "Choose free design or use templates. When using templates, select at least one workspace.", |
| 26 | connection_lost: "Connection to the confirm server was interrupted; retrying. If this keeps failing, return to the chat for confirmation.", |
| 27 | confirmed_title: "✓ Confirmed", |
| 28 | confirmed_hint: "Your choices are saved. You can close this page and return to the chat.", |
| 29 | lang_toggle_title: "Switch language", |
| 30 | sec_template_choice: "Design basis", |
| 31 | template_choice_hint: "Choose how this deck should establish its design system.", |
| 32 | template_free_title: "Design from the current content", |
| 33 | template_free_desc: "Use no reusable template workspace. The Strategist will derive the visual system from this project.", |
| 34 | template_use_title: "Use templates", |
| 35 | template_use_desc: "Select one or more reusable Brand, Style, Layout, Deck, or specified workspaces.", |
| 36 | sec_template_library: "Template combination", |
| 37 | template_library_hint: "Choose at most one registered template for each type. Brand, Style, Layout, and Deck can be combined.", |
| 38 | sec_template_explicit: "Specified templates", |
| 39 | template_explicit_hint: "Choose at most one exact workspace supplied for this run. Its source path is shown for verification.", |
| 40 | template_kind_brand: "Brand", |
| 41 | template_kind_style: "Style", |
| 42 | template_kind_layout: "Layout", |
| 43 | template_kind_deck: "Deck", |
| 44 | template_source_library: "Library", |
| 45 | template_source_explicit: "Specified path", |
| 46 | template_source_path: "Source path", |
| 47 | template_select_none: "None", |
| 48 | template_none_registered: "No registered templates", |
| 49 | template_none_explicit: "No specified templates for this run", |
| 50 | sec_canvas: "Canvas format", |
| 51 | sec_pages: "Page count", |
| 52 | sec_audience: "Target audience", |
| 53 | sec_communication: "What this presentation must accomplish", |
| 54 | sec_delivery: "How it will be used and what must remain", |
| 55 | sec_narrative: "Narrative direction", |
| 56 | sec_visual: "Visual direction", |
| 57 | sec_color: "Color scheme", |
| 58 | sec_icons: "Icon usage", |
| 59 | sec_type: "Typography", |
| 60 | sec_images: "Image usage", |
| 61 | sec_image_production: "Image production", |
| 62 | sec_proactive_execution: "Proactive execution", |
| 63 | sec_mode: "Generation mode", |
| 64 | sec_refine: "Review the Design Spec first", |
| 65 | sec_design_directions: "Coherent design directions", |
| 66 | design_directions_hint: "Each direction coordinates style, color, typography, icons, and generated-image rendering. You can fine-tune every field below.", |
| 67 | sec_template_application: "Template application", |
| 68 | template_application_hint: "The AI recommends how to apply the installed template to this deck. Revise the plan directly in natural language.", |
| 69 | placeholder_template_application: "Describe which template pages or prototypes to use, skip, repeat, or reorder; what must stay; and what may be replaced or reorganized.", |
| 70 | sub_mode: "Narrative mode", |
| 71 | sub_visual: "Visual style", |
| 72 | sub_divergence: "Material divergence (how freely to reshape vs. stay close to the source)", |
| 73 | placeholder_divergence: "In your words — e.g. \"stick closely to the document\" / \"freely restructure and expand within the source\". Leave blank for a balanced default.", |
| 74 | communication_intent: "What should this presentation accomplish?", |
| 75 | communication_intent_hint: "Open answer — combine any that apply: inform, explain, persuade, decide, align, teach, report/account, mobilize, or leave a record/hand-off. Describe priority or sequence when useful; do not choose labels.", |
| 76 | placeholder_communication_intent: "e.g. Report progress and surface risk first, then secure a decision on the next investment.", |
| 77 | audience_outcome: "Desired audience outcome / success condition", |
| 78 | placeholder_audience_outcome: "What should the audience know, understand, believe, decide, or do afterward?", |
| 79 | core_message: "Core message / decision ask / action", |
| 80 | placeholder_core_message: "Which claims, requests, or actions must land even if little else is remembered?", |
| 81 | delivery_context: "Delivery context (name the primary)", |
| 82 | delivery_context_hint: "Distinguish presenter-led, reader-led, hybrid, or recorded/self-running. For hybrid use, state which mode leads and what secondary use must still work.", |
| 83 | placeholder_delivery_context: "e.g. Primary: presenter-led 20-minute leadership review. Secondary: reader-led approval copy shared afterward.", |
| 84 | artifact_afterlife: "Artifact afterlife", |
| 85 | placeholder_artifact_afterlife: "e.g. approval, review, audit, archive, hand-off, or reuse; leave blank when no later use is expected.", |
| 86 | stage1_current_value_hint: "Editable fields contain recommendations. Keep, revise, or clear them; confirmation saves the current text exactly, including blank values.", |
| 87 | content_divergence_locked_hint: "This profile preserves the source wording and page structure, so this field is fixed.", |
| 88 | custom: "Custom", |
| 89 | custom_placeholder: "Type your own…", |
| 90 | ai_custom_candidate: "AI custom proposal", |
| 91 | ai_custom_candidate_hint: "Always visible for comparison. It is not selected by default; select it to edit.", |
| 92 | custom_behavior_required: "The selected AI custom proposal cannot be blank.", |
| 93 | custom_color_required: "Describe the custom color scheme before continuing.", |
| 94 | design_system_required: "Choose a complete palette and typography system before continuing.", |
| 95 | mode_behavior_placeholder: "Describe the act sequence, title voice, page rhythm, and presentation posture.", |
| 96 | visual_style_behavior_placeholder: "Describe shape language, composition, decoration density, whitespace, typography character, and texture.", |
| 97 | recommended: "Recommended", |
| 98 | placeholder_audience: "Who is this deck for?", |
| 99 | placeholder_pages: "e.g. 12-15", |
| 100 | hex_override: "Custom HEX override:", |
| 101 | formula_policy: "Formula rendering policy", |
| 102 | image_ai_path: "AI image source", |
| 103 | image_strategy: "Generated image style", |
| 104 | image_strategy_empty: "No preset style references are available. You can still use a custom style.", |
| 105 | image_strategy_required: "Choose a generated-image preset or describe a custom style.", |
| 106 | image_strategy_invalid: "The selected generated-image preset is not available.", |
| 107 | image_strategy_select_placeholder: "Choose a generated-image preset…", |
| 108 | image_strategy_recommended_group: "Recommended for this deck", |
| 109 | image_strategy_all_group: "All preset styles", |
| 110 | image_strategy_rendering: "Rendering", |
| 111 | image_strategy_visual: "Visual", |
| 112 | image_strategy_mood: "Mood", |
| 113 | image_strategy_ai_custom: "AI custom proposal", |
| 114 | image_strategy_ai_custom_desc: "A novel or multi-reference rendering proposal. Select it to edit.", |
| 115 | image_strategy_custom_placeholder: "Describe the exact generated-image direction, subjects, composition, style cues, or things to avoid.", |
| 116 | image_strategy_reference_hint: "Reference images show rendering only. Final AI images inherit the deck color scheme selected above.", |
| 117 | image_strategy_no_reference: "No reference image for this custom choice.", |
| 118 | image_source_summary: "Selected image sources", |
| 119 | image_production_hint: "Image sources and rendering are selected above. Resolve only the production path here.", |
| 120 | image_usage_notes: "Additional image requirements", |
| 121 | image_usage_notes_placeholder: "e.g. realistic handwashing scenes; avoid cartoon germs; keep product photos untouched.", |
| 122 | image_usage_required: "Select at least one image usage option.", |
| 123 | image_usage_none_exclusive: "No images cannot be combined with other image options.", |
| 124 | proactive_execution_hint: "These defaults apply only when you have not explicitly instructed otherwise. Your latest explicit instruction always takes priority.", |
| 125 | proactive_speaker_notes: "Proactively generate speaker notes", |
| 126 | proactive_speaker_notes_desc: "On by default. The agent generates speaker notes without a separate request.", |
| 127 | proactive_custom_animations: "Proactively create custom animations", |
| 128 | proactive_custom_animations_desc: "Off by default. Strategist motion suggestions remain available; turn this on to have the agent create custom animations without a separate request.", |
| 129 | proactive_narration_audio: "Proactively generate narration audio", |
| 130 | proactive_narration_audio_desc: "Off by default. This raw choice does not rewrite the speaker-notes toggle; the Strategist resolves narration's effective notes dependency in the Design Spec.", |
| 131 | font_heading: "Heading", |
| 132 | font_body: "Body", |
| 133 | font_selection: "Font selection", |
| 134 | primary_language_font: "Primary-language font", |
| 135 | english_font: "English font", |
| 136 | font_picker_hint: "Choosing a recommendation fills these selectors. Changing any font marks the typography as customized.", |
| 137 | other_installed_font: "Other installed font…", |
| 138 | other_font_placeholder: "Exact installed font name", |
| 139 | customized: "Customized", |
| 140 | font_body_size: "Body baseline size", |
| 141 | font_body_size_hint: "All type sizes derive from this body baseline.", |
| 142 | body_size_unit_relation: "SVG px to PPT pt: 1px = 0.75pt.", |
| 143 | body_size_pt_hint: "Approximately {pt} pt (1px = 0.75pt; saved as px).", |
| 144 | role_size_pt_hint: "≈ {pt} pt", |
| 145 | body_size_hint_canvas: "This canvas suggests ~{lo}–{hi}px (scales with canvas height).", |
| 146 | body_size_hint_purpose: "This reading mode recommends {def}px — one fixed size, not a range.", |
| 147 | body_size_hint_oor: "(Current value is outside the usual range for this canvas — check the unit is right and that it fits.)", |
| 148 | delivery_purpose: "Reading mode", |
| 149 | delivery_purpose_hint: "Choose where the meaning lives: read-close decks explain themselves with complete sentences and detail; presenter-led decks use one idea, concise claims, and visual evidence.", |
| 150 | size_override: "Per-role size override:", |
| 151 | size_role_title: "title", |
| 152 | size_role_subtitle: "subtitle", |
| 153 | size_role_annotation: "annotation", |
| 154 | custom_typography: "Custom typography", |
| 155 | custom_color: "Custom color", |
| 156 | custom_color_placeholder: "Describe your colors in words, e.g. deep navy primary, warm orange accent, white background — or paste HEX values…", |
| 157 | role_background: "bg", |
| 158 | role_secondary_bg: "2nd bg", |
| 159 | role_primary: "primary", |
| 160 | role_accent: "accent", |
| 161 | role_secondary_accent: "2nd accent", |
| 162 | role_body_text: "body text", |
| 163 | cjk: "CJK", |
| 164 | latin: "Latin", |
| 165 | sample_heading_cjk: "主题方案标题", |
| 166 | sample_heading_latin: "Presentation Title", |
| 167 | sample_body_cjk: "关键信息摘要", |
| 168 | sample_body_latin: "Key message summary", |
| 169 | style_preview_label: "Overall impression (color + typography + icons)", |
| 170 | style_preview_body: "· rough feel only, not the actual slide layout", |
| 171 | no_icons: "No icons", |
| 172 | preview_big_title: "Big Title", |
| 173 | preview_section_title: "Section Title", |
| 174 | preview_latin_title: "Section Title", |
| 175 | preview_body_intro: "Body copy shows the baseline text rhythm and contrast.", |
| 176 | preview_latin_body: "Body text sample for checking Latin typography.", |
| 177 | preview_point_1_title: "Body content", |
| 178 | preview_point_1_text: "Use this area to judge paragraph density and line spacing.", |
| 179 | preview_point_2_title: "Key point", |
| 180 | preview_point_2_text: "Icons are placed next to real text instead of floating alone.", |
| 181 | preview_point_3_title: "Conclusion", |
| 182 | preview_point_3_text: "The combination should stay readable at presentation scale.", |
| 183 | mode_continuous_desc: "Generate the whole deck in one pass.", |
| 184 | mode_split_desc: "Stop after the spec; resume SVG generation in a fresh window.", |
| 185 | refine_off_desc: "Write the Design Spec and execution lock in sequence, then auto-proceed.", |
| 186 | refine_on_desc: "Stop after the Design Spec. Revise any part in chat; approval then creates the execution lock and continues generation.", |
| 187 | off_default: "Off", |
| 188 | on: "On", |
| 189 | option_prefix: "Option", |
| 190 | error_retry: "Error - retry" |
| 191 | }, |
| 192 | ja: { |
| 193 | page_title: "PPT Master - デザイン確認", |
| 194 | topbar_hint: "自由記述の質問に答えるか、提案を選択・調整して次へ進んでください。", |
| 195 | stage_anchors: "ステージ 1 · コミュニケーション契約", |
| 196 | stage_final_plan: "ステージ 2 · 最終プランと制作", |
| 197 | loading: "読み込み中…", |
| 198 | load_error: "現在の推奨ステージを読み込めませんでした。起動前にAIが書き込む必要があります。", |
| 199 | btn_confirm: "確定", |
| 200 | btn_confirm_contract: "契約内容を確定して次へ →", |
| 201 | btn_confirm_final_plan: "最終プランを確定 →", |
| 202 | deriving: "選択内容をもとに後続の選択肢を生成しています…", |
| 203 | template_selection_required: "自由デザインまたはテンプレート利用を選んでください。テンプレート利用時は、1つ以上のワークスペースを選択してください。", |
| 204 | connection_lost: "確認ページのサーバー接続が中断されました。再試行しています。失敗が続く場合はチャットで確認してください。", |
| 205 | confirmed_title: "✓ 確定しました", |
| 206 | confirmed_hint: "選択内容を保存しました。このページを閉じてチャットに戻ってください。", |
| 207 | lang_toggle_title: "言語を切り替え", |
| 208 | sec_template_choice: "デザインの基準", |
| 209 | template_choice_hint: "この資料のデザインシステムをどう決めるか選択します。", |
| 210 | template_free_title: "現在の内容からデザインする", |
| 211 | template_free_desc: "再利用テンプレートを使わず、Strategist がこのプロジェクトからビジュアルシステムを組み立てます。", |
| 212 | template_use_title: "テンプレートを使用", |
| 213 | template_use_desc: "Brand、Style、Layout、Deck、または指定ワークスペースから1つ以上選択します。", |
| 214 | sec_template_library: "テンプレートの組み合わせ", |
| 215 | template_library_hint: "登録済みテンプレートは種類ごとに1件まで選択でき、Brand、Style、Layout、Deck を組み合わせられます。", |
| 216 | sec_template_explicit: "指定テンプレート", |
| 217 | template_explicit_hint: "この実行で指定された正確なワークスペースを1件まで選択できます。確認用に参照元パスを表示します。", |
| 218 | template_kind_brand: "Brand", |
| 219 | template_kind_style: "Style", |
| 220 | template_kind_layout: "Layout", |
| 221 | template_kind_deck: "Deck", |
| 222 | template_source_library: "ライブラリ", |
| 223 | template_source_explicit: "指定パス", |
| 224 | template_source_path: "参照元パス", |
| 225 | template_select_none: "なし", |
| 226 | template_none_registered: "登録済みテンプレートはありません", |
| 227 | template_none_explicit: "この実行で指定されたテンプレートはありません", |
| 228 | sec_canvas: "キャンバス形式", |
| 229 | sec_pages: "ページ数", |
| 230 | sec_audience: "想定読者", |
| 231 | sec_communication: "このプレゼンで何を実現するか", |
| 232 | sec_delivery: "どう使い、何を残すか", |
| 233 | sec_narrative: "ナラティブ方針", |
| 234 | sec_visual: "ビジュアル方針", |
| 235 | sec_color: "配色", |
| 236 | sec_icons: "アイコンの使用", |
| 237 | sec_type: "タイポグラフィ", |
| 238 | sec_images: "画像の使用", |
| 239 | sec_image_production: "画像制作", |
| 240 | sec_proactive_execution: "能動的な実行", |
| 241 | sec_mode: "生成モード", |
| 242 | sec_refine: "先に設計仕様を確認", |
| 243 | sec_design_directions: "統合デザイン方針", |
| 244 | design_directions_hint: "各案はスタイル、配色、書体、アイコン、生成画像のレンダリングを一体で提案します。下の各項目で微調整できます。", |
| 245 | sec_template_application: "テンプレートの適用方法", |
| 246 | template_application_hint: "AIが現在の内容に合わせたテンプレートの使い方を提案します。自然言語で直接修正できます。", |
| 247 | placeholder_template_application: "使用・省略・反復・並べ替えするページやプロトタイプ、保持する要素、差し替え・再構成できる内容を記述します。", |
| 248 | sub_mode: "ナラティブモード", |
| 249 | sub_visual: "ビジュアルスタイル", |
| 250 | sub_divergence: "素材からの発散度(どこまで自由に再構成するか、原文に忠実か)", |
| 251 | placeholder_divergence: "自分の言葉でどうぞ — 例:「文書に忠実に」「元素材の範囲内で自由に再構成・展開」。空欄ならバランス型になります。", |
| 252 | communication_intent: "このプレゼンで何を実現したいですか?", |
| 253 | communication_intent_hint: "自由記述です。情報共有・説明・説得・意思決定・合意形成・教育・報告と説明責任・行動喚起・記録と引き継ぎを必要に応じて組み合わせ、必要なら優先順位や順序も書いてください。ラベルを選ぶ必要はありません。", |
| 254 | placeholder_communication_intent: "例:まず進捗とリスクを報告し、そのうえで次の投資判断を得る。", |
| 255 | audience_outcome: "聴衆に期待する変化・成功条件", |
| 256 | placeholder_audience_outcome: "終了後、聴衆は何を知り、理解し、信じ、決め、行動できる状態になるべきですか?", |
| 257 | core_message: "中核メッセージ/意思決定の依頼/行動", |
| 258 | placeholder_core_message: "ほかの内容が忘れられても、必ず残すべき主張・依頼・行動は何ですか?", |
| 259 | delivery_context: "利用状況(主モードを明記)", |
| 260 | delivery_context_hint: "発表者主導、読者主導、ハイブリッド、録画/自動再生を区別してください。ハイブリッドでは主モードと、維持すべき副用途を明記します。", |
| 261 | placeholder_delivery_context: "例:主は発表者付き20分の経営レビュー。副は会議後に単独で読む承認資料。", |
| 262 | artifact_afterlife: "資料の利用後", |
| 263 | placeholder_artifact_afterlife: "例:承認、レビュー、監査、保管、引き継ぎ、再利用。後続利用がなければ空欄で構いません。", |
| 264 | stage1_current_value_hint: "編集可能な欄には提案が入っています。そのまま使う・修正する・空にすることができ、確定時の現在値を空欄も含めてそのまま保存します。", |
| 265 | content_divergence_locked_hint: "このプロファイルは原文とページ構成を保持するため、この項目は固定されています。", |
| 266 | custom: "カスタム", |
| 267 | custom_placeholder: "自由に入力…", |
| 268 | ai_custom_candidate: "AIカスタム案", |
| 269 | ai_custom_candidate_hint: "比較できるよう常に全文を表示します。初期選択はされず、選択後に編集できます。", |
| 270 | custom_behavior_required: "選択したAIカスタム案を空欄にはできません。", |
| 271 | custom_color_required: "続行する前に、カスタム配色の説明を入力してください。", |
| 272 | design_system_required: "続行する前に、完全な配色と書体システムを選択してください。", |
| 273 | mode_behavior_placeholder: "構成の流れ、タイトルの語り口、ページのリズム、表現姿勢を記述します。", |
| 274 | visual_style_behavior_placeholder: "形状言語、構図、装飾密度、余白、書体の性格、質感を記述します。", |
| 275 | recommended: "おすすめ", |
| 276 | placeholder_audience: "この資料は誰に向けたもの?", |
| 277 | placeholder_pages: "例:12-15", |
| 278 | hex_override: "カスタムHEXで上書き:", |
| 279 | formula_policy: "数式レンダリング方針", |
| 280 | image_ai_path: "AI画像の生成元", |
| 281 | image_strategy: "生成画像のスタイル", |
| 282 | image_strategy_empty: "プリセットのスタイル見本を利用できません。カスタムスタイルは引き続き使用できます。", |
| 283 | image_strategy_required: "生成画像のプリセットを選ぶか、カスタムスタイルを記述してください。", |
| 284 | image_strategy_invalid: "選択した生成画像プリセットは利用できません。", |
| 285 | image_strategy_select_placeholder: "生成画像のプリセットを選択…", |
| 286 | image_strategy_recommended_group: "この資料へのおすすめ", |
| 287 | image_strategy_all_group: "すべてのプリセットスタイル", |
| 288 | image_strategy_rendering: "レンダリング", |
| 289 | image_strategy_visual: "ビジュアル", |
| 290 | image_strategy_mood: "ムード", |
| 291 | image_strategy_ai_custom: "AIカスタム案", |
| 292 | image_strategy_ai_custom_desc: "新規または複数の既存表現を統合したレンダリング案です。選択後に編集できます。", |
| 293 | image_strategy_custom_placeholder: "生成画像の方向性、被写体、構図、スタイル要素、避けたい要素を具体的に入力してください。", |
| 294 | image_strategy_reference_hint: "参照画像はレンダリングのみを示します。最終AI画像の色は上で選んだデッキ配色を継承します。", |
| 295 | image_strategy_no_reference: "このカスタム選択には参照画像がありません。", |
| 296 | image_source_summary: "選択中の画像ソース", |
| 297 | image_production_hint: "画像ソースとレンダリングは上で選択済みです。ここでは制作経路だけを決めます。", |
| 298 | image_usage_notes: "画像に関する補足要件", |
| 299 | image_usage_notes_placeholder: "例:リアルな手洗いシーンを優先、漫画調の菌のイラストは避ける、製品写真はそのまま使う。", |
| 300 | image_usage_required: "画像の使用方法を少なくとも1つ選択してください。", |
| 301 | image_usage_none_exclusive: "「画像なし」は他の画像オプションと同時に選択できません。", |
| 302 | proactive_execution_hint: "これらの初期設定は、明示的な指示がない場合にのみ適用されます。最新の明示的な指示が常に優先されます。", |
| 303 | proactive_speaker_notes: "発表者ノートを能動的に生成", |
| 304 | proactive_speaker_notes_desc: "初期設定はオンです。個別の依頼がなくても発表者ノートを生成します。", |
| 305 | proactive_custom_animations: "カスタムアニメーションを能動的に作成", |
| 306 | proactive_custom_animations_desc: "初期設定はオフです。ストラテジストの動きの提案は維持され、オンにすると個別の依頼がなくてもカスタムアニメーションを作成します。", |
| 307 | proactive_narration_audio: "ナレーション音声を能動的に生成", |
| 308 | proactive_narration_audio_desc: "初期設定はオフです。この選択は発表者ノートの設定を書き換えません。ナレーションに必要なノートの有効状態は、ストラテジストが設計仕様で解決します。", |
| 309 | font_heading: "見出し", |
| 310 | font_body: "本文", |
| 311 | font_selection: "フォント選択", |
| 312 | primary_language_font: "主要言語のフォント", |
| 313 | english_font: "英語フォント", |
| 314 | font_picker_hint: "提案を選ぶと下の選択欄に反映されます。いずれかのフォントを変更するとカスタマイズ済みになります。", |
| 315 | other_installed_font: "その他のインストール済みフォント…", |
| 316 | other_font_placeholder: "インストール済みフォントの正確な名前", |
| 317 | customized: "カスタマイズ済み", |
| 318 | font_body_size: "本文の基準サイズ", |
| 319 | font_body_size_hint: "すべての文字サイズはこの本文基準から導出されます。", |
| 320 | body_size_unit_relation: "SVG px と PPT pt の換算:1px = 0.75pt。", |
| 321 | body_size_pt_hint: "約 {pt} pt(1px = 0.75pt 換算、保存は px)。", |
| 322 | role_size_pt_hint: "約 {pt} pt", |
| 323 | body_size_hint_canvas: "このキャンバスの目安は約{lo}–{hi}px(キャンバスの高さに応じて変化)。", |
| 324 | body_size_hint_purpose: "この閲覧モードの推奨は{def}px — 範囲ではなく固定値です。", |
| 325 | body_size_hint_oor: "(現在の値はこのキャンバスの通常範囲外です — 単位とサイズ感を確認してください。)", |
| 326 | delivery_purpose: "閲覧モード", |
| 327 | delivery_purpose_hint: "情報を主にページと話者のどちらに担わせるかを決めます。近距離閲覧は完全な文と細部で自立させ、プレゼン型は1枚1メッセージで短い主張と視覚的根拠を中心にします。", |
| 328 | size_override: "役割ごとのサイズ上書き:", |
| 329 | size_role_title: "タイトル", |
| 330 | size_role_subtitle: "サブタイトル", |
| 331 | size_role_annotation: "注釈", |
| 332 | custom_typography: "カスタムタイポグラフィ", |
| 333 | custom_color: "カスタム配色", |
| 334 | custom_color_placeholder: "配色を言葉で説明 — 例:濃紺をメインに暖色オレンジのアクセント、背景は白 — またはHEX値を貼り付け…", |
| 335 | role_background: "背景", |
| 336 | role_secondary_bg: "第2背景", |
| 337 | role_primary: "メイン", |
| 338 | role_accent: "アクセント", |
| 339 | role_secondary_accent: "第2アクセント", |
| 340 | role_body_text: "本文文字", |
| 341 | cjk: "和文", |
| 342 | latin: "欧文", |
| 343 | sample_heading_cjk: "プレゼンテーションの表題", |
| 344 | sample_heading_latin: "Presentation Title", |
| 345 | sample_body_cjk: "キーメッセージの要約", |
| 346 | sample_body_latin: "Key message summary", |
| 347 | style_preview_label: "全体の印象(配色 + タイポグラフィ + アイコン)", |
| 348 | style_preview_body: "· 雰囲気の確認用で、実際のレイアウトではありません", |
| 349 | no_icons: "アイコンなし", |
| 350 | preview_big_title: "大見出し", |
| 351 | preview_section_title: "章タイトル", |
| 352 | preview_latin_title: "Section Title", |
| 353 | preview_body_intro: "本文の基準サイズとコントラストを確認するための文です。", |
| 354 | preview_latin_body: "Body text sample for checking Latin typography.", |
| 355 | preview_point_1_title: "本文内容", |
| 356 | preview_point_1_text: "段落密度と行間の見え方をここで確認します。", |
| 357 | preview_point_2_title: "要点説明", |
| 358 | preview_point_2_text: "アイコンは単独ではなく、実際の文章の横に配置します。", |
| 359 | preview_point_3_title: "結論・提案", |
| 360 | preview_point_3_text: "投影時にも読みやすい組み合わせかを判断します。", |
| 361 | mode_continuous_desc: "デッキ全体を一気に生成します。", |
| 362 | mode_split_desc: "設計仕様の作成後に停止し、別ウィンドウでSVG生成を再開します。", |
| 363 | refine_off_desc: "設計仕様と実行ロックを順番に作成し、そのまま自動で進みます。", |
| 364 | refine_on_desc: "設計仕様の作成後に停止します。チャットで任意の箇所を修正し、承認後に実行ロックを作成して生成を続けます。", |
| 365 | off_default: "オフ", |
| 366 | on: "オン", |
| 367 | option_prefix: "案", |
| 368 | error_retry: "エラー - 再試行" |
| 369 | }, |
| 370 | zh: { |
| 371 | page_title: "确认设计方案", |
| 372 | topbar_hint: "回答开放问题,或选择并调整推荐项,然后继续。", |
| 373 | stage_anchors: "第一阶段 · 沟通契约", |
| 374 | stage_final_plan: "第二阶段 · 最终方案与制作", |
| 375 | loading: "加载中…", |
| 376 | load_error: "无法加载推荐文件,需在启动前写入。", |
| 377 | btn_confirm: "确认", |
| 378 | btn_confirm_contract: "确认沟通契约并继续 →", |
| 379 | btn_confirm_final_plan: "确认最终方案 →", |
| 380 | deriving: "正在根据你的选择生成下游选项…", |
| 381 | template_selection_required: "请选择自由设计或使用模板;选择使用模板时,至少选择一个工作区。", |
| 382 | connection_lost: "确认页服务连接中断,正在重试;如果持续失败,请回到聊天窗口走聊天确认。", |
| 383 | confirmed_title: "✓ 已确认", |
| 384 | confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。", |
| 385 | lang_toggle_title: "切换语言", |
| 386 | sec_template_choice: "设计基础", |
| 387 | template_choice_hint: "选择这份演示文稿如何建立设计系统。", |
| 388 | template_free_title: "根据当前内容从零设计", |
| 389 | template_free_desc: "不使用可复用模板工作区,由 Strategist 根据当前项目推导视觉系统。", |
| 390 | template_use_title: "使用模板", |
| 391 | template_use_desc: "选择一个或多个 Brand、Style、Layout、Deck 或指定工作区。", |
| 392 | sec_template_library: "模板组合", |
| 393 | template_library_hint: "每种已注册模板最多选择一个;Brand、Style、Layout、Deck 可以组合使用。", |
| 394 | sec_template_explicit: "指定模板", |
| 395 | template_explicit_hint: "本次运行明确提供的精确工作区最多选择一个;显示来源路径供你核对。", |
| 396 | template_kind_brand: "Brand", |
| 397 | template_kind_style: "Style", |
| 398 | template_kind_layout: "Layout", |
| 399 | template_kind_deck: "Deck", |
| 400 | template_source_library: "模板库", |
| 401 | template_source_explicit: "指定地址", |
| 402 | template_source_path: "来源路径", |
| 403 | template_select_none: "无", |
| 404 | template_none_registered: "暂无已注册模板", |
| 405 | template_none_explicit: "本次运行没有指定模板", |
| 406 | sec_canvas: "画布格式", |
| 407 | sec_pages: "页数", |
| 408 | sec_audience: "目标受众", |
| 409 | sec_communication: "这份演示要完成什么", |
| 410 | sec_delivery: "如何使用、之后留下什么", |
| 411 | sec_narrative: "叙事方向", |
| 412 | sec_visual: "视觉方向", |
| 413 | sec_color: "色彩方案", |
| 414 | sec_icons: "图标使用", |
| 415 | sec_type: "字体方案", |
| 416 | sec_images: "图片使用", |
| 417 | sec_image_production: "图片生产", |
| 418 | sec_proactive_execution: "主动执行", |
| 419 | sec_mode: "生成模式", |
| 420 | sec_refine: "先审核设计规范", |
| 421 | sec_design_directions: "成套设计方向", |
| 422 | design_directions_hint: "每套方向会一起协调风格、配色、字体、图标和生成图渲染;你仍可在下方逐项微调。", |
| 423 | sec_template_application: "模板应用方式", |
| 424 | template_application_hint: "AI 会根据当前内容推荐如何使用已安装模板;你可以直接用自然语言修改。", |
| 425 | placeholder_template_application: "说明使用、跳过、重复或重排哪些模板页面/原型,哪些内容必须保留,哪些可以替换或重组。", |
| 426 | sub_mode: "叙事模式", |
| 427 | sub_visual: "视觉风格", |
| 428 | sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)", |
| 429 | placeholder_divergence: "用你自己的话写,例如「严格贴着文档来」/「在源材料范围内自由重组并展开」。留空则按平衡处理。", |
| 430 | communication_intent: "这份演示文稿需要完成什么?", |
| 431 | communication_intent_hint: "开放回答,可按需组合:告知、解释、说服、决策、对齐、教学、汇报与问责、动员、留档与交接。必要时说明主次或先后,不需要选择标签。", |
| 432 | placeholder_communication_intent: "例如:先汇报进展并暴露风险,再推动管理层决定下一阶段投入。", |
| 433 | audience_outcome: "期望的受众变化 / 成功条件", |
| 434 | placeholder_audience_outcome: "结束后,受众应该知道、理解、相信、决定或采取什么行动?", |
| 435 | core_message: "核心信息 / 决策请求 / 行动", |
| 436 | placeholder_core_message: "即使其他内容没有被记住,受众至少需要接住哪些主张、请求或行动?", |
| 437 | delivery_context: "传递场景(明确主要模式)", |
| 438 | delivery_context_hint: "区分演讲者主导、读者主导、混合、录制/自动播放;混合场景要说明哪一种主导,以及还要兼顾什么次要用途。", |
| 439 | placeholder_delivery_context: "例如:主要为有主讲的 20 分钟管理层现场评审;次要为会后独立阅读的审批材料。", |
| 440 | artifact_afterlife: "演示后的成果用途", |
| 441 | placeholder_artifact_afterlife: "例如:审批、评审、审计、留档、交接或复用;没有后续用途时可留空。", |
| 442 | stage1_current_value_hint: "可编辑字段中是推荐内容。你可以保留、修改或清空;确认时会按当前内容原样保存,空白也会保持为空。", |
| 443 | content_divergence_locked_hint: "当前流程要求原文和页面结构保持不变,因此该字段已锁定。", |
| 444 | custom: "自定义", |
| 445 | custom_placeholder: "输入自定义内容…", |
| 446 | ai_custom_candidate: "AI 自定义方案", |
| 447 | ai_custom_candidate_hint: "始终展示完整内容用于比较;默认不选中,选择后可编辑。", |
| 448 | custom_behavior_required: "已选择的 AI 自定义方案不能为空。", |
| 449 | custom_color_required: "请先填写自定义配色说明,再继续确认。", |
| 450 | design_system_required: "请先选择完整的配色与字体方案,再继续确认。", |
| 451 | mode_behavior_placeholder: "描述叙事阶段、标题语气、页面节奏和表达姿态。", |
| 452 | visual_style_behavior_placeholder: "描述形状语言、构图、装饰密度、留白、字体气质和纹理。", |
| 453 | recommended: "推荐", |
| 454 | placeholder_audience: "这份演示文稿面向谁?", |
| 455 | placeholder_pages: "如:12-15", |
| 456 | hex_override: "自定义色值覆盖:", |
| 457 | formula_policy: "公式渲染策略", |
| 458 | image_ai_path: "生成配图来源", |
| 459 | image_strategy: "生成图风格", |
| 460 | image_strategy_empty: "当前没有可用的预设风格参考,仍可使用自定义风格。", |
| 461 | image_strategy_required: "请选择一种生成图预设,或填写自定义风格。", |
| 462 | image_strategy_invalid: "所选生成图预设当前不可用。", |
| 463 | image_strategy_select_placeholder: "选择生成图预设…", |
| 464 | image_strategy_recommended_group: "本项目推荐", |
| 465 | image_strategy_all_group: "全部预设风格", |
| 466 | image_strategy_rendering: "渲染风格", |
| 467 | image_strategy_visual: "视觉", |
| 468 | image_strategy_mood: "情绪", |
| 469 | image_strategy_ai_custom: "AI 自定义方案", |
| 470 | image_strategy_ai_custom_desc: "一套全新或综合多个已有风格的渲染方案;选择后可以编辑。", |
| 471 | image_strategy_custom_placeholder: "描述生成图的具体方向、主体、构图、风格关键词或需要避免的内容。", |
| 472 | image_strategy_reference_hint: "参考图只展示渲染风格;最终 AI 图片直接继承上方已选的整套 PPT 配色。", |
| 473 | image_strategy_no_reference: "自定义选择没有参考图。", |
| 474 | image_source_summary: "已选图片来源", |
| 475 | image_production_hint: "图片来源和渲染方向已在上方选择;这里仅决定实际生产路径。", |
| 476 | image_usage_notes: "图片补充要求", |
| 477 | image_usage_notes_placeholder: "例如:优先真实洗手场景;不要卡通病菌;产品照片保持原样。", |
| 478 | image_usage_required: "请至少选择一种图片使用方式。", |
| 479 | image_usage_none_exclusive: "「不使用图片」不能和其它图片选项同时选择。", |
| 480 | proactive_execution_hint: "这些默认开关只在你没有明确要求时生效;你最新的明确指令始终优先。", |
| 481 | proactive_speaker_notes: "主动生成演讲者备注", |
| 482 | proactive_speaker_notes_desc: "默认开启。无需另行要求,Agent 也会生成演讲者备注。", |
| 483 | proactive_custom_animations: "主动生成自定义动画", |
| 484 | proactive_custom_animations_desc: "默认关闭。策略师的动画建议仍会保留;开启后,Agent 可在没有另行要求时实际制作自定义动画。", |
| 485 | proactive_narration_audio: "主动生成旁白音频", |
| 486 | proactive_narration_audio_desc: "默认关闭。这里保留原始选择,不改写演讲者备注开关;策略师会在设计规范中解析旁白所需的最终备注状态。", |
| 487 | font_heading: "标题", |
| 488 | font_body: "正文", |
| 489 | font_selection: "字体选择", |
| 490 | primary_language_font: "主要语言字体", |
| 491 | english_font: "英文字体", |
| 492 | font_picker_hint: "选择推荐方案会同步下方字体;修改任一下拉或手动字体后会标记为已自定义。", |
| 493 | other_installed_font: "其他已安装字体…", |
| 494 | other_font_placeholder: "输入精确的已安装字体名称", |
| 495 | customized: "已自定义", |
| 496 | font_body_size: "正文基准字号", |
| 497 | font_body_size_hint: "所有字号按这个正文基准推导。", |
| 498 | body_size_unit_relation: "SVG px 与 PPT pt 的换算:1px = 0.75pt。", |
| 499 | body_size_pt_hint: "约 {pt} pt(按 1px = 0.75pt 换算;提交仍保存 px)。", |
| 500 | role_size_pt_hint: "约 {pt} pt", |
| 501 | body_size_hint_canvas: "当前画布建议 ~{lo}–{hi}px(随画布高度缩放)。", |
| 502 | body_size_hint_purpose: "该阅读模式推荐 {def}px(单一固定值,非区间)。", |
| 503 | body_size_hint_oor: "(当前数值超出该画布的常用范围——请确认单位无误、是否合适。)", |
| 504 | delivery_purpose: "阅读模式", |
| 505 | delivery_purpose_hint: "决定信息主要由页面还是讲者承担:近读型用完整句、短段落和细节自洽;演讲型一页一意,以简短主张和视觉证据为主。", |
| 506 | size_override: "逐角色字号覆盖:", |
| 507 | size_role_title: "标题", |
| 508 | size_role_subtitle: "副标题", |
| 509 | size_role_annotation: "注释", |
| 510 | custom_typography: "自定义字体方案", |
| 511 | custom_color: "自定义配色", |
| 512 | custom_color_placeholder: "用文字描述配色,如:深蓝主色、暖橙强调、白色背景——或直接粘贴 HEX 值…", |
| 513 | role_background: "背景", |
| 514 | role_secondary_bg: "次级背景", |
| 515 | role_primary: "主色", |
| 516 | role_accent: "强调", |
| 517 | role_secondary_accent: "次强调", |
| 518 | role_body_text: "正文文字", |
| 519 | cjk: "中文", |
| 520 | latin: "西文", |
| 521 | sample_heading_cjk: "主题方案标题", |
| 522 | sample_heading_latin: "Presentation Title", |
| 523 | sample_body_cjk: "关键信息摘要", |
| 524 | sample_body_latin: "Key message summary", |
| 525 | style_preview_label: "整体形象(配色 + 字体 + 图标)", |
| 526 | style_preview_body: "· 仅大致形象,非实际版式", |
| 527 | no_icons: "无图标", |
| 528 | preview_big_title: "大标题", |
| 529 | preview_section_title: "章节标题", |
| 530 | preview_latin_title: "Section Title", |
| 531 | preview_body_intro: "正文内容用于判断基础字号、行距和颜色对比。", |
| 532 | preview_latin_body: "Body text sample for checking Latin typography.", |
| 533 | preview_point_1_title: "正文内容", |
| 534 | preview_point_1_text: "这里展示普通段落的密度和阅读节奏。", |
| 535 | preview_point_2_title: "要点说明", |
| 536 | preview_point_2_text: "图标和文字放在一起,判断真实使用效果。", |
| 537 | preview_point_3_title: "结论建议", |
| 538 | preview_point_3_text: "组合效果需要在演示场景下保持清晰可读。", |
| 539 | mode_continuous_desc: "一次性连续生成整份演示文稿。", |
| 540 | mode_split_desc: "写完设计规范后停止,另开窗口继续生成页面。", |
| 541 | refine_off_desc: "依次生成设计规范和执行锁,然后自动继续。", |
| 542 | refine_on_desc: "生成设计规范后暂停;你可在聊天中修改任何部分,明确确认后再生成执行锁并继续制作。", |
| 543 | off_default: "关", |
| 544 | on: "开", |
| 545 | option_prefix: "方案", |
| 546 | error_retry: "出错,请重试" |
| 547 | } |
| 548 | }; |
| 549 | |
| 550 | var LANG = (function () { |
| 551 | try { |
| 552 | var stored = window.localStorage.getItem("ppt_lang"); |
| 553 | if (stored === "zh" || stored === "en" || stored === "ja") return stored; |
| 554 | } catch (e) { /* ignore */ } |
| 555 | var nav = (navigator.language || navigator.userLanguage || "en").toLowerCase(); |
| 556 | if (nav.indexOf("zh") === 0) return "zh"; |
| 557 | if (nav.indexOf("ja") === 0) return "ja"; |
| 558 | return "en"; |
| 559 | })(); |
| 560 | |
| 561 | function t(key) { |
| 562 | var dict = MESSAGES[LANG] || MESSAGES.en; |
| 563 | return dict[key] != null ? dict[key] : key; |
| 564 | } |
| 565 | |
| 566 | // Fallback stays LANG-relative: zh/en users never see Japanese labels, |
| 567 | // ja pages fall back ja → en → zh. |
| 568 | var LANG_FALLBACK = { zh: ["zh", "en", "ja"], en: ["en", "zh", "ja"], ja: ["ja", "en", "zh"] }; |
| 569 | var IMAGE_COMPARISON_LABELS = { |
| 570 | rendering: { |
| 571 | "vector-illustration": { zh: "矢量插画", en: "Vector illustration", ja: "ベクターイラスト" }, |
| 572 | flat: { zh: "扁平插画", en: "Flat illustration", ja: "フラットイラスト" }, |
| 573 | "3d-isometric": { zh: "3D 等距", en: "3D isometric", ja: "3Dアイソメトリック" }, |
| 574 | "digital-dashboard": { zh: "数字仪表盘", en: "Digital dashboard", ja: "デジタルダッシュボード" }, |
| 575 | "corporate-photo": { zh: "企业摄影", en: "Corporate photo", ja: "企業写真" }, |
| 576 | blueprint: { zh: "蓝图线稿", en: "Blueprint", ja: "ブループリント" }, |
| 577 | editorial: { zh: "编辑杂志", en: "Editorial", ja: "エディトリアル" }, |
| 578 | "sketch-notes": { zh: "手绘笔记", en: "Sketch notes", ja: "スケッチノート" }, |
| 579 | "ink-notes": { zh: "墨线笔记", en: "Ink notes", ja: "インクノート" }, |
| 580 | chalkboard: { zh: "粉笔黑板", en: "Chalkboard", ja: "チョークボード" }, |
| 581 | watercolor: { zh: "水彩", en: "Watercolor", ja: "水彩" }, |
| 582 | "warm-scene": { zh: "暖调场景", en: "Warm scene", ja: "暖色シーン" }, |
| 583 | "screen-print": { zh: "丝网印刷", en: "Screen print", ja: "スクリーンプリント" }, |
| 584 | "fantasy-animation": { zh: "幻想动画", en: "Fantasy animation", ja: "ファンタジーアニメ" }, |
| 585 | "pixel-art": { zh: "像素艺术", en: "Pixel art", ja: "ピクセルアート" }, |
| 586 | nature: { zh: "自然有机", en: "Nature", ja: "自然・オーガニック" }, |
| 587 | "minimalist-swiss": { zh: "瑞士极简", en: "Minimalist Swiss", ja: "スイスミニマル" }, |
| 588 | glassmorphism: { zh: "玻璃拟态", en: "Glassmorphism", ja: "グラスモーフィズム" }, |
| 589 | "vintage-poster": { zh: "复古海报", en: "Vintage poster", ja: "ヴィンテージポスター" }, |
| 590 | "paper-cut": { zh: "剪纸拼贴", en: "Paper cut", ja: "ペーパーカット" } |
| 591 | } |
| 592 | }; |
| 593 | |
| 594 | function localized(obj, base) { |
| 595 | if (!obj) return ""; |
| 596 | var langKey = base + "_" + LANG; |
| 597 | if (obj[langKey] != null) return obj[langKey]; |
| 598 | var order = LANG_FALLBACK[LANG] || LANG_FALLBACK.en; |
| 599 | var i; |
| 600 | if (obj[base] != null) { |
| 601 | if (typeof obj[base] === "object") { |
| 602 | for (i = 0; i < order.length; i++) { |
| 603 | if (obj[base][order[i]]) return obj[base][order[i]]; |
| 604 | } |
| 605 | return ""; |
| 606 | } |
| 607 | return obj[base]; |
| 608 | } |
| 609 | for (i = 0; i < order.length; i++) { |
| 610 | if (obj[base + "_" + order[i]]) return obj[base + "_" + order[i]]; |
| 611 | } |
| 612 | return ""; |
| 613 | } |
| 614 | |
| 615 | function optionLabel(option) { |
| 616 | return localized(option, "label") || String(option && option.id); |
| 617 | } |
| 618 | |
| 619 | function optionDesc(option) { |
| 620 | return localized(option, "desc"); |
| 621 | } |
| 622 | |
| 623 | function groupLabel(group) { |
| 624 | return localized(group, "group"); |
| 625 | } |
| 626 | |
| 627 | function humanizeId(value) { |
| 628 | return String(value || "") |
| 629 | .replace(/[_-]+/g, " ") |
| 630 | .replace(/\b[a-z]/g, function (match) { return match.toUpperCase(); }); |
| 631 | } |
| 632 | |
| 633 | function langMappedLabel(kind, id) { |
| 634 | if (!id) return ""; |
| 635 | if (id === "custom") return t("custom"); |
| 636 | var entry = IMAGE_COMPARISON_LABELS[kind] && IMAGE_COMPARISON_LABELS[kind][id]; |
| 637 | if (!entry) return ""; |
| 638 | var order = LANG_FALLBACK[LANG] || LANG_FALLBACK.en; |
| 639 | for (var i = 0; i < order.length; i += 1) { |
| 640 | if (entry[order[i]]) return entry[order[i]]; |
| 641 | } |
| 642 | return entry.en || ""; |
| 643 | } |
| 644 | |
| 645 | function comparisonValueLabel(kind, id) { |
| 646 | return langMappedLabel(kind, id) || humanizeId(id); |
| 647 | } |
| 648 | |
| 649 | function applyStaticTranslations() { |
| 650 | document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : (LANG === "ja" ? "ja" : "en")); |
| 651 | document.querySelectorAll("[data-i18n]").forEach(function (node) { |
| 652 | node.textContent = t(node.getAttribute("data-i18n")); |
| 653 | }); |
| 654 | } |
| 655 | |
| 656 | var LANG_NAMES = { zh: "中文", en: "English", ja: "日本語" }; |
| 657 | |
| 658 | function refreshLangToggle(toggleBtn) { |
| 659 | // Custom dropdown (OS-independent): button shows the CURRENT language. |
| 660 | var cur = document.getElementById("lang-current"); |
| 661 | if (cur) cur.textContent = LANG_NAMES[LANG] || LANG; |
| 662 | toggleBtn.title = t("lang_toggle_title"); |
| 663 | document.querySelectorAll("#lang-menu li").forEach(function (li) { |
| 664 | var selected = li.getAttribute("data-lang") === LANG; |
| 665 | li.classList.toggle("selected", selected); |
| 666 | li.setAttribute("aria-selected", selected ? "true" : "false"); |
| 667 | }); |
| 668 | } |
| 669 | |
| 670 | // ---- state ----------------------------------------------------------- |
| 671 | var CAT = null; // catalogs.json — finite option universe |
| 672 | var REC = null; // current recommendation stage — AI picks + candidates |
| 673 | var ICON_PREVIEWS = {}; // /api/icon-previews — real SVG samples from templates/icons |
| 674 | var AI_IMAGE_COMPARISON = {}; // /api/ai-image-comparison — preset rendering catalog |
| 675 | var STATE = {}; |
| 676 | var TEMPLATE_KINDS = ["brand", "style", "layout", "deck"]; |
| 677 | var TEMPLATE_OPTIONS = null; |
| 678 | var TEMPLATE_CANDIDATES = []; |
| 679 | var TEMPLATE_SELECTED_KEYS = []; |
| 680 | var TEMPLATE_SELECTIONS = { brand: "", style: "", layout: "", deck: "", explicit: "" }; |
| 681 | var TEMPLATE_MODE = ""; |
| 682 | var REC_ALIASES = { |
| 683 | icons: { |
| 684 | line: "tabler-outline", |
| 685 | filled: "tabler-filled", |
| 686 | monochrome: "chunk-filled" |
| 687 | }, |
| 688 | image_usage: { |
| 689 | search: "web" |
| 690 | }, |
| 691 | image_ai_path: { |
| 692 | default: "auto", |
| 693 | builtin: "host-native" |
| 694 | } |
| 695 | }; |
| 696 | |
| 697 | // ---- DOM helpers ----------------------------------------------------- |
| 698 | function el(tag, cls, text) { |
| 699 | var node = document.createElement(tag); |
| 700 | if (cls) node.className = cls; |
| 701 | if (text != null) node.textContent = text; |
| 702 | return node; |
| 703 | } |
| 704 | |
| 705 | function previewNode(kind, id) { |
| 706 | var node = el("div", "option-preview option-preview-" + kind); |
| 707 | node.setAttribute("aria-hidden", "true"); |
| 708 | if (kind === "visual_style") { |
| 709 | appendVisualStyleImage(node, id); |
| 710 | return node; |
| 711 | } |
| 712 | var markup = kind === "icons" ? iconStylePreview(id) : ""; |
| 713 | if (!markup) return null; |
| 714 | node.innerHTML = markup; |
| 715 | return node; |
| 716 | } |
| 717 | |
| 718 | function visualStylePreviewSrc(id) { |
| 719 | return "/static/style_previews/" + encodeURIComponent(id || "") + ".svg"; |
| 720 | } |
| 721 | |
| 722 | function appendVisualStyleImage(parent, id) { |
| 723 | var img = document.createElement("img"); |
| 724 | img.alt = ""; |
| 725 | img.loading = "lazy"; |
| 726 | img.src = visualStylePreviewSrc(id); |
| 727 | img.onerror = function () { |
| 728 | parent.innerHTML = visualStylePreview(id); |
| 729 | }; |
| 730 | parent.appendChild(img); |
| 731 | } |
| 732 | |
| 733 | function escapeHtml(value) { |
| 734 | return String(value == null ? "" : value) |
| 735 | .replace(/&/g, "&") |
| 736 | .replace(/</g, "<") |
| 737 | .replace(/>/g, ">") |
| 738 | .replace(/"/g, """); |
| 739 | } |
| 740 | |
| 741 | function visualStylePreview(id) { |
| 742 | var label = escapeHtml(humanizeId(id) || t("sub_visual")); |
| 743 | var fallback = escapeHtml(t("error_retry")); |
| 744 | return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">' + |
| 745 | '<rect width="1280" height="720" fill="#F8FAFC"/>' + |
| 746 | '<path d="M0 160H1280M0 360H1280M0 560H1280M220 0V720M640 0V720M1060 0V720" stroke="#E2E8F0" stroke-width="2"/>' + |
| 747 | '<rect x="116" y="96" width="1048" height="528" rx="28" fill="#FFFFFF" stroke="#CBD5E1" stroke-width="3"/>' + |
| 748 | '<rect x="164" y="148" width="360" height="34" rx="17" fill="#111827"/>' + |
| 749 | '<rect x="164" y="228" width="520" height="20" rx="10" fill="#CBD5E1"/>' + |
| 750 | '<rect x="164" y="274" width="440" height="20" rx="10" fill="#E2E8F0"/>' + |
| 751 | '<rect x="760" y="188" width="292" height="236" rx="22" fill="#EFF6FF" stroke="#BFDBFE" stroke-width="3"/>' + |
| 752 | '<path d="M808 378L874 308L942 348L1012 258" fill="none" stroke="#2563EB" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>' + |
| 753 | '<circle cx="1012" cy="258" r="22" fill="#0F172A"/>' + |
| 754 | '<rect x="164" y="464" width="220" height="24" rx="12" fill="#94A3B8"/>' + |
| 755 | '<text x="164" y="548" fill="#475569" font-family="Arial, sans-serif" font-size="34">' + label + '</text>' + |
| 756 | '<text x="164" y="594" fill="#94A3B8" font-family="Arial, sans-serif" font-size="24">' + fallback + '</text>' + |
| 757 | '</svg>'; |
| 758 | } |
| 759 | |
| 760 | function iconStylePreview(id) { |
| 761 | var common = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100"'; |
| 762 | var samples = ICON_PREVIEWS[id] || []; |
| 763 | if (samples.length) { |
| 764 | var sample = samples[0] || {}; |
| 765 | return '<div class="real-icon-preview real-icon-preview-' + escapeHtml(id) + '">' + |
| 766 | '<div class="real-icon-sample"><div class="real-icon-mark">' + |
| 767 | (sample.svg || "") + |
| 768 | '</div><span class="real-icon-label">' + escapeHtml(sample.name || "") + '</span></div>' + |
| 769 | '</div>'; |
| 770 | } |
| 771 | if (id === "emoji") return '<svg ' + common + '><rect width="160" height="100" rx="10" fill="#FFF7ED"/><text x="80" y="60" text-anchor="middle" font-size="34">📊</text></svg>'; |
| 772 | if (id === "none") return '<svg ' + common + '><rect width="160" height="100" rx="10" fill="#FFFFFF"/><rect x="22" y="20" width="116" height="9" rx="4.5" fill="#111827"/><rect x="22" y="42" width="90" height="6" rx="3" fill="#CBD5E1"/><rect x="22" y="56" width="104" height="6" rx="3" fill="#CBD5E1"/><rect x="22" y="70" width="70" height="6" rx="3" fill="#CBD5E1"/><path d="M118 42l24 24M142 42l-24 24" stroke="#94A3B8" stroke-width="3" stroke-linecap="round"/></svg>'; |
| 773 | return ""; |
| 774 | } |
| 775 | |
| 776 | function comparisonImageUrl(kind, id) { |
| 777 | var value = String(id || "").trim(); |
| 778 | if (!value || value === "custom") return ""; |
| 779 | if (!/^[A-Za-z0-9_.-]+$/.test(value)) return ""; |
| 780 | return "/ai-image-comparison/" + kind + "/" + encodeURIComponent(value) + ".png"; |
| 781 | } |
| 782 | |
| 783 | function appendImageStrategyPreviews(card, candidate) { |
| 784 | if (candidate.rendering === "custom") return; |
| 785 | var previews = [ |
| 786 | [t("image_strategy_rendering"), comparisonImageUrl("rendering", candidate.rendering)] |
| 787 | ].filter(function (item) { return item[1]; }); |
| 788 | if (!previews.length) return; |
| 789 | var row = el("div", "image-strategy-previews"); |
| 790 | previews.forEach(function (item) { |
| 791 | var frame = el("div", "image-strategy-preview"); |
| 792 | var img = document.createElement("img"); |
| 793 | img.alt = item[0]; |
| 794 | img.loading = "lazy"; |
| 795 | img.src = item[1]; |
| 796 | img.onerror = function () { |
| 797 | frame.remove(); |
| 798 | if (!row.childElementCount) row.remove(); |
| 799 | }; |
| 800 | frame.appendChild(img); |
| 801 | frame.appendChild(el("span", "image-strategy-preview-label", item[0])); |
| 802 | row.appendChild(frame); |
| 803 | }); |
| 804 | card.appendChild(row); |
| 805 | return row; |
| 806 | } |
| 807 | |
| 808 | // Section numbers run 1..N within the stage currently rendered; the counter is |
| 809 | // reset at the top of renderForStage. The legacy `num` arg is ignored so each |
| 810 | // stage numbers its own sections cleanly (stage 2 is not a continuation of 1). |
| 811 | var _secCounter = 0; |
| 812 | function section(num, titleKey, noteText) { |
| 813 | _secCounter += 1; |
| 814 | var sec = el("div", "section"); |
| 815 | var head = el("div", "section-head"); |
| 816 | head.appendChild(el("span", "section-num", String(_secCounter))); |
| 817 | head.appendChild(el("span", "section-title", t(titleKey))); |
| 818 | if (noteText) head.appendChild(el("span", "section-note", noteText)); |
| 819 | sec.appendChild(head); |
| 820 | return sec; |
| 821 | } |
| 822 | |
| 823 | function setSectionNote(sec, text) { |
| 824 | var head = sec.querySelector(".section-head"); |
| 825 | var note = head.querySelector(".section-note"); |
| 826 | if (!note) { |
| 827 | note = el("span", "section-note"); |
| 828 | head.appendChild(note); |
| 829 | } |
| 830 | note.textContent = text; |
| 831 | } |
| 832 | |
| 833 | // ---- Stage 1 template selection -------------------------------------- |
| 834 | function normalizeTemplateCandidates(raw, source, kind) { |
| 835 | if (!Array.isArray(raw)) return []; |
| 836 | return raw.filter(function (candidate) { |
| 837 | return candidate && candidate.key != null && String(candidate.key).trim(); |
| 838 | }).map(function (candidate) { |
| 839 | var normalized = Object.assign({}, candidate); |
| 840 | normalized.key = String(candidate.key); |
| 841 | normalized.source = source; |
| 842 | if (kind) normalized.kind = kind; |
| 843 | return normalized; |
| 844 | }); |
| 845 | } |
| 846 | |
| 847 | function initTemplateOptions(data) { |
| 848 | var library = data && data.library && typeof data.library === "object" |
| 849 | ? data.library : {}; |
| 850 | var defaultMode = data && data.default_mode; |
| 851 | if (defaultMode !== "free_design" && defaultMode !== "templates") { |
| 852 | throw new Error("template_options.default_mode must be free_design or templates"); |
| 853 | } |
| 854 | var normalized = { |
| 855 | lang: data && data.lang, |
| 856 | default_mode: defaultMode, |
| 857 | library: {}, |
| 858 | explicit: [], |
| 859 | preselected_keys: Array.isArray(data && data.preselected_keys) |
| 860 | ? data.preselected_keys.map(String) : [] |
| 861 | }; |
| 862 | TEMPLATE_CANDIDATES = []; |
| 863 | TEMPLATE_KINDS.forEach(function (kind) { |
| 864 | var candidates = normalizeTemplateCandidates(library[kind], "library", kind); |
| 865 | normalized.library[kind] = candidates; |
| 866 | TEMPLATE_CANDIDATES = TEMPLATE_CANDIDATES.concat(candidates); |
| 867 | }); |
| 868 | normalized.explicit = normalizeTemplateCandidates(data && data.explicit, "explicit", ""); |
| 869 | TEMPLATE_CANDIDATES = TEMPLATE_CANDIDATES.concat(normalized.explicit); |
| 870 | var candidatesByKey = Object.create(null); |
| 871 | TEMPLATE_CANDIDATES.forEach(function (candidate) { |
| 872 | if (candidatesByKey[candidate.key]) { |
| 873 | throw new Error("Duplicate template option key: " + candidate.key); |
| 874 | } |
| 875 | candidatesByKey[candidate.key] = candidate; |
| 876 | }); |
| 877 | TEMPLATE_SELECTIONS = emptyTemplateSelections(); |
| 878 | normalized.preselected_keys.filter(function (key, index, all) { |
| 879 | return all.indexOf(key) === index; |
| 880 | }).forEach(function (key) { |
| 881 | var candidate = candidatesByKey[key]; |
| 882 | var slot = templateSelectionSlot(candidate); |
| 883 | if (!candidate || !slot) { |
| 884 | throw new Error("Invalid preselected template key: " + key); |
| 885 | } |
| 886 | if (TEMPLATE_SELECTIONS[slot]) { |
| 887 | throw new Error("Multiple preselected templates for slot: " + slot); |
| 888 | } |
| 889 | TEMPLATE_SELECTIONS[slot] = key; |
| 890 | }); |
| 891 | syncTemplateSelectionState(); |
| 892 | TEMPLATE_MODE = normalized.default_mode; |
| 893 | TEMPLATE_OPTIONS = normalized; |
| 894 | } |
| 895 | |
| 896 | function emptyTemplateSelections() { |
| 897 | return { brand: "", style: "", layout: "", deck: "", explicit: "" }; |
| 898 | } |
| 899 | |
| 900 | function templateSelectionSlot(candidate) { |
| 901 | if (!candidate) return ""; |
| 902 | if (candidate.source === "explicit") return "explicit"; |
| 903 | if (candidate.source === "library" && TEMPLATE_KINDS.indexOf(candidate.kind) >= 0) { |
| 904 | return candidate.kind; |
| 905 | } |
| 906 | return ""; |
| 907 | } |
| 908 | |
| 909 | function templateCandidateByKey(key) { |
| 910 | key = String(key || ""); |
| 911 | for (var i = 0; i < TEMPLATE_CANDIDATES.length; i += 1) { |
| 912 | if (TEMPLATE_CANDIDATES[i].key === key) return TEMPLATE_CANDIDATES[i]; |
| 913 | } |
| 914 | return null; |
| 915 | } |
| 916 | |
| 917 | function syncTemplateSelectionState() { |
| 918 | TEMPLATE_SELECTED_KEYS = TEMPLATE_KINDS.map(function (kind) { |
| 919 | return TEMPLATE_SELECTIONS[kind]; |
| 920 | }); |
| 921 | TEMPLATE_SELECTED_KEYS.push(TEMPLATE_SELECTIONS.explicit); |
| 922 | TEMPLATE_SELECTED_KEYS = TEMPLATE_SELECTED_KEYS.filter(Boolean); |
| 923 | } |
| 924 | |
| 925 | function templateCandidateTitle(candidate) { |
| 926 | return localized(candidate, "label") || candidate.label || candidate.id || candidate.key; |
| 927 | } |
| 928 | |
| 929 | function templateCandidateSummary(candidate) { |
| 930 | return localized(candidate, "summary") || candidate.summary || ""; |
| 931 | } |
| 932 | |
| 933 | function templateKindLabel(kind) { |
| 934 | return t("template_kind_" + kind); |
| 935 | } |
| 936 | |
| 937 | function chooseFreeDesign() { |
| 938 | TEMPLATE_SELECTIONS = emptyTemplateSelections(); |
| 939 | syncTemplateSelectionState(); |
| 940 | TEMPLATE_MODE = "free_design"; |
| 941 | updateTemplateSelectionControls(); |
| 942 | } |
| 943 | |
| 944 | function chooseTemplateMode() { |
| 945 | TEMPLATE_MODE = "templates"; |
| 946 | updateTemplateSelectionControls(); |
| 947 | } |
| 948 | |
| 949 | function chooseTemplateForSlot(slot, key) { |
| 950 | TEMPLATE_SELECTIONS[slot] = String(key || ""); |
| 951 | syncTemplateSelectionState(); |
| 952 | TEMPLATE_MODE = "templates"; |
| 953 | updateTemplateSelectionControls(); |
| 954 | } |
| 955 | |
| 956 | function templateOptionTitle(candidate) { |
| 957 | var details = []; |
| 958 | var summary = templateCandidateSummary(candidate); |
| 959 | if (summary) details.push(summary); |
| 960 | if (candidate.workspace_root) details.push(candidate.workspace_root); |
| 961 | return details.join(" · "); |
| 962 | } |
| 963 | |
| 964 | function renderTemplateSelectField(slot, label, candidates) { |
| 965 | var field = el("div", "template-select-field template-select-field-" + slot); |
| 966 | var selectId = "template-select-" + slot; |
| 967 | var fieldLabel = el("label", "template-select-label", label); |
| 968 | fieldLabel.setAttribute("for", selectId); |
| 969 | field.appendChild(fieldLabel); |
| 970 | |
| 971 | var select = el("select", "template-select"); |
| 972 | select.id = selectId; |
| 973 | select.setAttribute("data-template-slot", slot); |
| 974 | var none = el("option", "", t("template_select_none")); |
| 975 | none.value = ""; |
| 976 | select.appendChild(none); |
| 977 | candidates.forEach(function (candidate) { |
| 978 | var option = el("option", "", templateCandidateTitle(candidate)); |
| 979 | option.value = candidate.key; |
| 980 | var title = templateOptionTitle(candidate); |
| 981 | if (title) option.title = title; |
| 982 | select.appendChild(option); |
| 983 | }); |
| 984 | select.value = TEMPLATE_SELECTIONS[slot] || ""; |
| 985 | select.disabled = !candidates.length; |
| 986 | select.addEventListener("change", function () { |
| 987 | chooseTemplateForSlot(slot, select.value); |
| 988 | }); |
| 989 | field.appendChild(select); |
| 990 | |
| 991 | if (slot === "explicit") { |
| 992 | field.appendChild(el( |
| 993 | "div", |
| 994 | "template-select-help", |
| 995 | candidates.length ? t("template_explicit_hint") : t("template_none_explicit") |
| 996 | )); |
| 997 | var path = el("div", "template-selected-path"); |
| 998 | path.id = "template-explicit-path"; |
| 999 | path.appendChild(el("span", "template-selected-path-label", t("template_source_path") + ":")); |
| 1000 | var code = el("code", "template-selected-path-value"); |
| 1001 | code.id = "template-explicit-path-value"; |
| 1002 | path.appendChild(code); |
| 1003 | field.appendChild(path); |
| 1004 | } |
| 1005 | return field; |
| 1006 | } |
| 1007 | |
| 1008 | function renderTemplateModeChoice(id, mode, titleKey, descKey, onSelect) { |
| 1009 | var selected = TEMPLATE_MODE === mode; |
| 1010 | var choice = el("button", "template-mode-choice" + (selected ? " selected" : "")); |
| 1011 | choice.id = id; |
| 1012 | choice.type = "button"; |
| 1013 | choice.setAttribute("aria-pressed", selected ? "true" : "false"); |
| 1014 | choice.appendChild(el("span", "template-choice-radio")); |
| 1015 | var copy = el("span", "template-choice-copy"); |
| 1016 | copy.appendChild(el("span", "template-choice-title", t(titleKey))); |
| 1017 | copy.appendChild(el("span", "template-choice-desc", t(descKey))); |
| 1018 | choice.appendChild(copy); |
| 1019 | choice.addEventListener("click", onSelect); |
| 1020 | return choice; |
| 1021 | } |
| 1022 | |
| 1023 | function renderTemplateSelection(host) { |
| 1024 | if (!TEMPLATE_OPTIONS) return; |
| 1025 | var sec = section("T", "sec_template_choice", t("template_choice_hint")); |
| 1026 | var choices = el("div", "template-mode-choices"); |
| 1027 | choices.appendChild(renderTemplateModeChoice( |
| 1028 | "template-free-choice", "free_design", "template_free_title", |
| 1029 | "template_free_desc", chooseFreeDesign |
| 1030 | )); |
| 1031 | var useChoice = renderTemplateModeChoice( |
| 1032 | "template-use-choice", "templates", "template_use_title", |
| 1033 | "template_use_desc", chooseTemplateMode |
| 1034 | ); |
| 1035 | useChoice.setAttribute("aria-controls", "template-selector-panel"); |
| 1036 | choices.appendChild(useChoice); |
| 1037 | sec.appendChild(choices); |
| 1038 | |
| 1039 | var panel = el("div", "template-selector-panel"); |
| 1040 | panel.id = "template-selector-panel"; |
| 1041 | panel.appendChild(el("div", "template-selector-hint", t("template_library_hint"))); |
| 1042 | var grid = el("div", "template-select-grid"); |
| 1043 | TEMPLATE_KINDS.forEach(function (kind) { |
| 1044 | var candidates = TEMPLATE_OPTIONS.library[kind] || []; |
| 1045 | grid.appendChild(renderTemplateSelectField(kind, templateKindLabel(kind), candidates)); |
| 1046 | }); |
| 1047 | grid.appendChild(renderTemplateSelectField( |
| 1048 | "explicit", |
| 1049 | t("template_source_explicit"), |
| 1050 | TEMPLATE_OPTIONS.explicit || [] |
| 1051 | )); |
| 1052 | panel.appendChild(grid); |
| 1053 | sec.appendChild(panel); |
| 1054 | host.appendChild(sec); |
| 1055 | updateTemplateSelectionControls(); |
| 1056 | } |
| 1057 | |
| 1058 | function updateTemplateExplicitPath() { |
| 1059 | var path = document.getElementById("template-explicit-path"); |
| 1060 | var value = document.getElementById("template-explicit-path-value"); |
| 1061 | if (!path || !value) return; |
| 1062 | var candidate = templateCandidateByKey(TEMPLATE_SELECTIONS.explicit); |
| 1063 | var workspaceRoot = candidate && candidate.workspace_root ? candidate.workspace_root : ""; |
| 1064 | path.hidden = !workspaceRoot; |
| 1065 | value.textContent = workspaceRoot; |
| 1066 | value.title = workspaceRoot; |
| 1067 | } |
| 1068 | |
| 1069 | function updateTemplateSelectionControls() { |
| 1070 | var freeChoice = document.getElementById("template-free-choice"); |
| 1071 | var useChoice = document.getElementById("template-use-choice"); |
| 1072 | var selectorPanel = document.getElementById("template-selector-panel"); |
| 1073 | var freeSelected = TEMPLATE_MODE === "free_design"; |
| 1074 | var templatesSelected = TEMPLATE_MODE === "templates"; |
| 1075 | if (freeChoice) { |
| 1076 | freeChoice.classList.toggle("selected", freeSelected); |
| 1077 | freeChoice.setAttribute("aria-pressed", freeSelected ? "true" : "false"); |
| 1078 | } |
| 1079 | if (useChoice) { |
| 1080 | useChoice.classList.toggle("selected", templatesSelected); |
| 1081 | useChoice.setAttribute("aria-pressed", templatesSelected ? "true" : "false"); |
| 1082 | useChoice.setAttribute("aria-expanded", templatesSelected ? "true" : "false"); |
| 1083 | } |
| 1084 | if (selectorPanel) selectorPanel.hidden = !templatesSelected; |
| 1085 | TEMPLATE_KINDS.concat(["explicit"]).forEach(function (slot) { |
| 1086 | var select = document.getElementById("template-select-" + slot); |
| 1087 | if (select) select.value = TEMPLATE_SELECTIONS[slot] || ""; |
| 1088 | }); |
| 1089 | updateTemplateExplicitPath(); |
| 1090 | var status = document.getElementById("confirm-status"); |
| 1091 | if (status) status.textContent = ""; |
| 1092 | } |
| 1093 | |
| 1094 | function normalizeRecId(field, value) { |
| 1095 | if (Array.isArray(value)) return normalizeRecId(field, value[0]); |
| 1096 | if (value == null || value === "") return value; |
| 1097 | var aliases = REC_ALIASES[field] || {}; |
| 1098 | return aliases[value] || value; |
| 1099 | } |
| 1100 | |
| 1101 | function normalizeRecIds(field, value) { |
| 1102 | if (Array.isArray(value)) { |
| 1103 | return value.map(function (item) { return normalizeRecId(field, item); }) |
| 1104 | .filter(function (item, idx, arr) { return item && arr.indexOf(item) === idx; }); |
| 1105 | } |
| 1106 | var normalized = normalizeRecId(field, value); |
| 1107 | return normalized ? [normalized] : []; |
| 1108 | } |
| 1109 | |
| 1110 | function legacyRecId(field) { |
| 1111 | if (!REC) return null; |
| 1112 | if (field === "canvas") return REC.canvas && REC.canvas.value; |
| 1113 | if (field === "visual_style") return REC.visual_style || (REC.style && REC.style.value); |
| 1114 | if (field === "icons") return REC.icons && REC.icons.value; |
| 1115 | if (field === "image_usage") return REC.images && REC.images.value; |
| 1116 | if (field === "image_ai_path") return REC.image_ai_path || (REC.images && REC.images.ai_path); |
| 1117 | if (field === "formula_policy") return REC.typography && REC.typography.formula_policy && REC.typography.formula_policy.value; |
| 1118 | if (field === "generation_mode") return REC.generation_mode && REC.generation_mode.value; |
| 1119 | return REC[field] && REC[field].value; |
| 1120 | } |
| 1121 | |
| 1122 | function recId(field) { |
| 1123 | var value = (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field); |
| 1124 | return normalizeRecId(field, value || null); |
| 1125 | } |
| 1126 | |
| 1127 | function recValue(field) { |
| 1128 | return (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field); |
| 1129 | } |
| 1130 | |
| 1131 | function booleanRecommendation(field, fallback) { |
| 1132 | if (!REC || !Object.prototype.hasOwnProperty.call(REC, field)) return fallback; |
| 1133 | var spec = REC[field]; |
| 1134 | var value = spec && typeof spec === "object" ? spec.value : spec; |
| 1135 | return typeof value === "boolean" ? value : fallback; |
| 1136 | } |
| 1137 | |
| 1138 | function recommendationFieldLocked(field) { |
| 1139 | return !!(REC && REC[field] && typeof REC[field] === "object" && REC[field].locked === true); |
| 1140 | } |
| 1141 | |
| 1142 | // Guaranteed recommendation: the AI's pick, or the first catalog option as a |
| 1143 | // fallback so an enumerable field ALWAYS shows a badged recommendation. |
| 1144 | function recOrFirst(field, list) { |
| 1145 | var r = recId(field); |
| 1146 | if (r == null || r === "") r = normalizeRecId(field, directionField(field)); |
| 1147 | if (r != null && r !== "") return r; |
| 1148 | return firstId(list); |
| 1149 | } |
| 1150 | // Render an enumerable field: ALL options from the catalog, recommended one |
| 1151 | // badged, current selection from STATE, plus an optional Custom box. An |
| 1152 | // AI-authored custom candidate stays fully visible while unselected and |
| 1153 | // becomes editable only after selection; legacy custom inputs keep their |
| 1154 | // compact free-text behavior. |
| 1155 | // `list` is either a flat array of {id,label,desc,dim,viewbox} or a grouped array |
| 1156 | // of {group, items:[...]}. |
| 1157 | function enumField(parent, list, recommendedId, getVal, setVal, opts2) { |
| 1158 | list = list || []; |
| 1159 | opts2 = opts2 || {}; |
| 1160 | var grouped = list.length && list[0] && list[0].items; |
| 1161 | var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list; |
| 1162 | var ids = flat.map(function (o) { return o.id; }); |
| 1163 | // Optional personality spectrum: instead of a single ★ recommendation, |
| 1164 | // the AI marks a few catalog ids (safe / shifted / bold) each with a |
| 1165 | // temperament tag + a real-world analogy note. Replaces the single badge. |
| 1166 | var spectrum = (opts2.spectrum && opts2.spectrum.length) ? opts2.spectrum : null; |
| 1167 | var specById = {}; |
| 1168 | if (spectrum) spectrum.forEach(function (s) { |
| 1169 | if (s && s.id) specById[s.id] = { tag: localized(s, "tag"), note: localized(s, "note") }; |
| 1170 | }); |
| 1171 | var allowCustom = opts2.allowCustom === true; // only for fields not fully enumerable |
| 1172 | var aiCustom = opts2.aiCustom || null; |
| 1173 | var customSentinel = opts2.customSentinel || (aiCustom ? "custom" : ""); |
| 1174 | var customInvalidValues = opts2.customInvalidValues || []; |
| 1175 | var cur = getVal(); |
| 1176 | var isCustom = cur != null && cur !== "" && |
| 1177 | (cur === customSentinel || ids.indexOf(cur) === -1); |
| 1178 | if (!allowCustom && isCustom) { |
| 1179 | // closed field with an out-of-catalog value → snap to recommended/first |
| 1180 | cur = ids.indexOf(recommendedId) >= 0 ? recommendedId : ids[0]; |
| 1181 | setVal(cur); |
| 1182 | isCustom = false; |
| 1183 | } |
| 1184 | |
| 1185 | var allChips = []; |
| 1186 | var customInput = el(aiCustom ? "textarea" : "input", "text-input custom-input"); |
| 1187 | setNaturalInputDirection(customInput); |
| 1188 | if (opts2.inputClass) customInput.classList.add(opts2.inputClass); |
| 1189 | if (aiCustom) customInput.rows = aiCustom.rows || 4; |
| 1190 | else customInput.type = "text"; |
| 1191 | customInput.placeholder = opts2.placeholder || t("custom_placeholder"); |
| 1192 | customInput.style.display = "none"; |
| 1193 | var customPreview = null; |
| 1194 | |
| 1195 | function customText() { |
| 1196 | if (!aiCustom) return customInput.value || ""; |
| 1197 | return String(aiCustom.getText ? aiCustom.getText() : (aiCustom.text || "")); |
| 1198 | } |
| 1199 | |
| 1200 | function syncAiCustom(selected) { |
| 1201 | if (!aiCustom || !customPreview) return; |
| 1202 | var value = customText(); |
| 1203 | customPreview.textContent = value; |
| 1204 | customPreview.style.display = selected ? "none" : "block"; |
| 1205 | customInput.style.display = selected ? "block" : "none"; |
| 1206 | if (selected && customInput.value !== value) customInput.value = value; |
| 1207 | } |
| 1208 | |
| 1209 | function deselect() { |
| 1210 | allChips.forEach(function (c) { c.classList.remove("selected"); }); |
| 1211 | if (aiCustom) syncAiCustom(false); |
| 1212 | } |
| 1213 | function makeChip(o) { |
| 1214 | var label = optionLabel(o); |
| 1215 | var desc = optionDesc(o); |
| 1216 | var spec = specById[o.id]; |
| 1217 | var chip = el("div", "chip"); |
| 1218 | var preview = previewNode(opts2.preview, o.id); |
| 1219 | if (preview) { |
| 1220 | chip.classList.add("chip-with-preview"); |
| 1221 | chip.classList.add("chip-preview-" + opts2.preview); |
| 1222 | chip.appendChild(preview); |
| 1223 | } |
| 1224 | var copy = el("div", "chip-copy"); |
| 1225 | if (o.viewbox) { |
| 1226 | label = label + (o.dim ? " · " + o.dim : ""); |
| 1227 | } else { |
| 1228 | if (o.dim) label += " · " + o.dim; |
| 1229 | if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc; |
| 1230 | if (spec && spec.note) label += " · " + spec.note; |
| 1231 | } |
| 1232 | copy.appendChild(el("span", "chip-text", label)); |
| 1233 | if (spec) { |
| 1234 | // spectrum pick: badge shows its temperament tag, not the generic ★ |
| 1235 | chip.classList.add("recommended"); |
| 1236 | copy.appendChild(el("span", "rec-badge", "★ " + (spec.tag || t("recommended")))); |
| 1237 | } else if (!spectrum && o.id === recommendedId) { |
| 1238 | chip.classList.add("recommended"); |
| 1239 | copy.appendChild(el("span", "rec-badge", "★ " + t("recommended"))); |
| 1240 | } |
| 1241 | chip.appendChild(copy); |
| 1242 | if (!isCustom && o.id === cur) chip.classList.add("selected"); |
| 1243 | chip.addEventListener("click", function () { |
| 1244 | deselect(); |
| 1245 | chip.classList.add("selected"); |
| 1246 | if (!aiCustom) customInput.style.display = "none"; |
| 1247 | setVal(o.id); |
| 1248 | }); |
| 1249 | allChips.push(chip); |
| 1250 | return chip; |
| 1251 | } |
| 1252 | |
| 1253 | var chipsClass = "chips" + (opts2.chipsClass ? " " + opts2.chipsClass : ""); |
| 1254 | if (grouped) { |
| 1255 | list.forEach(function (g) { |
| 1256 | if (groupLabel(g)) parent.appendChild(el("div", "group-label", groupLabel(g))); |
| 1257 | var row = el("div", chipsClass); |
| 1258 | (g.items || []).forEach(function (o) { row.appendChild(makeChip(o)); }); |
| 1259 | parent.appendChild(row); |
| 1260 | }); |
| 1261 | if (allowCustom) { |
| 1262 | var lastRow = el("div", aiCustom ? "chips custom-chip-row" : "chips"); |
| 1263 | lastRow.appendChild(buildCustomChip()); |
| 1264 | parent.appendChild(lastRow); |
| 1265 | } |
| 1266 | } else { |
| 1267 | var wrap = el("div", chipsClass); |
| 1268 | var ownRowIds = opts2.ownRowIds || []; |
| 1269 | flat.filter(function (o) { return ownRowIds.indexOf(o.id) === -1; }) |
| 1270 | .forEach(function (o) { wrap.appendChild(makeChip(o)); }); |
| 1271 | if (allowCustom && !opts2.customOnOwnRow) wrap.appendChild(buildCustomChip()); |
| 1272 | parent.appendChild(wrap); |
| 1273 | flat.filter(function (o) { return ownRowIds.indexOf(o.id) >= 0; }) |
| 1274 | .forEach(function (o) { |
| 1275 | var ownRow = el("div", "chips standalone-chip-row"); |
| 1276 | ownRow.appendChild(makeChip(o)); |
| 1277 | parent.appendChild(ownRow); |
| 1278 | }); |
| 1279 | if (allowCustom && opts2.customOnOwnRow) { |
| 1280 | var customRow = el("div", "chips custom-chip-row"); |
| 1281 | customRow.appendChild(buildCustomChip()); |
| 1282 | parent.appendChild(customRow); |
| 1283 | } |
| 1284 | } |
| 1285 | if (allowCustom && !aiCustom) parent.appendChild(customInput); |
| 1286 | |
| 1287 | function buildCustomChip() { |
| 1288 | var customChip = el("div", aiCustom ? "chip ai-custom-candidate" : "chip"); |
| 1289 | if (aiCustom) { |
| 1290 | var customHead = el("div", "ai-custom-candidate-head"); |
| 1291 | customHead.appendChild(el("span", "chip-text", |
| 1292 | aiCustom.label || t("ai_custom_candidate"))); |
| 1293 | customHead.appendChild(el("span", "ai-custom-candidate-hint", |
| 1294 | aiCustom.hint || t("ai_custom_candidate_hint"))); |
| 1295 | customChip.appendChild(customHead); |
| 1296 | customPreview = el("div", "ai-custom-candidate-copy", customText()); |
| 1297 | customChip.appendChild(customPreview); |
| 1298 | customChip.appendChild(customInput); |
| 1299 | } else { |
| 1300 | customChip.appendChild(el("span", "chip-text", t("custom"))); |
| 1301 | } |
| 1302 | if (recommendedId === customSentinel || |
| 1303 | (recommendedId && ids.indexOf(recommendedId) === -1 && isCustom)) { |
| 1304 | customChip.classList.add("recommended"); |
| 1305 | customChip.appendChild(el("span", "rec-badge", "★ " + t("recommended"))); |
| 1306 | } |
| 1307 | if (isCustom) { |
| 1308 | customChip.classList.add("selected"); |
| 1309 | if (aiCustom) syncAiCustom(true); |
| 1310 | else { |
| 1311 | customInput.style.display = "block"; |
| 1312 | customInput.value = customInvalidValues.indexOf(cur) >= 0 ? "" : cur; |
| 1313 | } |
| 1314 | } else if (aiCustom) { |
| 1315 | syncAiCustom(false); |
| 1316 | } |
| 1317 | customChip.addEventListener("click", function () { |
| 1318 | deselect(); |
| 1319 | customChip.classList.add("selected"); |
| 1320 | if (aiCustom) syncAiCustom(true); |
| 1321 | else customInput.style.display = "block"; |
| 1322 | customInput.focus(); |
| 1323 | setVal(aiCustom ? customSentinel : (customInput.value || customSentinel)); |
| 1324 | }); |
| 1325 | allChips.push(customChip); |
| 1326 | return customChip; |
| 1327 | } |
| 1328 | customInput.addEventListener("click", function (event) { event.stopPropagation(); }); |
| 1329 | customInput.addEventListener("input", function () { |
| 1330 | if (aiCustom) { |
| 1331 | if (aiCustom.setText) aiCustom.setText(customInput.value); |
| 1332 | setVal(customSentinel); |
| 1333 | } else { |
| 1334 | setVal(customInput.value || customSentinel); |
| 1335 | } |
| 1336 | }); |
| 1337 | } |
| 1338 | |
| 1339 | function textField(parent, getVal, setVal, placeholderKey, numeric) { |
| 1340 | var input = el("input", numeric ? "num-input" : "text-input"); |
| 1341 | input.type = "text"; |
| 1342 | if (!numeric) setNaturalInputDirection(input); |
| 1343 | input.value = getVal() || ""; |
| 1344 | input.placeholder = t(placeholderKey); |
| 1345 | input.addEventListener("input", function () { setVal(input.value); }); |
| 1346 | parent.appendChild(input); |
| 1347 | } |
| 1348 | |
| 1349 | function textareaField(parent, getVal, setVal, placeholderKey, rows) { |
| 1350 | var input = el("textarea", "text-input"); |
| 1351 | setNaturalInputDirection(input); |
| 1352 | input.rows = rows || 2; |
| 1353 | input.value = getVal() || ""; |
| 1354 | input.placeholder = t(placeholderKey); |
| 1355 | input.addEventListener("input", function () { setVal(input.value); }); |
| 1356 | parent.appendChild(input); |
| 1357 | return input; |
| 1358 | } |
| 1359 | |
| 1360 | function labeledTextarea(parent, labelKey, getVal, setVal, placeholderKey, hintKey, rows) { |
| 1361 | var field = el("div", "subfield"); |
| 1362 | field.appendChild(el("div", "subfield-label", t(labelKey))); |
| 1363 | if (hintKey) field.appendChild(el("div", "toggle-desc", t(hintKey))); |
| 1364 | textareaField(field, getVal, setVal, placeholderKey, rows); |
| 1365 | parent.appendChild(field); |
| 1366 | return field; |
| 1367 | } |
| 1368 | |
| 1369 | function normPalette(c) { |
| 1370 | function read(src, keys) { |
| 1371 | if (!src) return undefined; |
| 1372 | for (var i = 0; i < keys.length; i += 1) { |
| 1373 | if (src[keys[i]] != null) return src[keys[i]]; |
| 1374 | } |
| 1375 | return undefined; |
| 1376 | } |
| 1377 | function collect(src) { |
| 1378 | return { |
| 1379 | background: read(src, ["background", "bg"]), |
| 1380 | secondary_bg: read(src, ["secondary_bg", "secondary_background", "card_bg", "card_background"]), |
| 1381 | primary: read(src, ["primary"]), |
| 1382 | accent: read(src, ["accent"]), |
| 1383 | secondary_accent: read(src, ["secondary_accent", "secondary"]), |
| 1384 | body_text: read(src, ["body_text", "text"]) |
| 1385 | }; |
| 1386 | } |
| 1387 | if (c && c.palette) { |
| 1388 | return collect(c.palette); |
| 1389 | } |
| 1390 | if (!c) return {}; |
| 1391 | return collect(c); |
| 1392 | } |
| 1393 | |
| 1394 | function recommendationLanguage() { |
| 1395 | // `lang` controls the Confirm UI language; it is not evidence of the |
| 1396 | // deck's content language. New staged files carry `primary_language`. |
| 1397 | var raw = REC && (REC.primary_language || REC.content_language || REC.language); |
| 1398 | if (raw && typeof raw === "object") { |
| 1399 | raw = raw.value || raw.id || raw.code || ""; |
| 1400 | } |
| 1401 | // The API is the single normalizer; the browser consumes its canonical |
| 1402 | // value instead of maintaining a second alias table. |
| 1403 | return String(raw || "und").trim().replace(/_/g, "-"); |
| 1404 | } |
| 1405 | |
| 1406 | function isEnglishProject() { |
| 1407 | return /^en(?:-|$)/i.test(recommendationLanguage()); |
| 1408 | } |
| 1409 | |
| 1410 | function setEnglishLanguageAttributes(node) { |
| 1411 | node.lang = "en"; |
| 1412 | node.dir = "ltr"; |
| 1413 | } |
| 1414 | |
| 1415 | function setUiLanguageAttributes(node) { |
| 1416 | node.lang = LANG === "zh" ? "zh-CN" : (LANG === "ja" ? "ja-JP" : "en-US"); |
| 1417 | node.dir = "ltr"; |
| 1418 | } |
| 1419 | |
| 1420 | function setNaturalInputDirection(node) { |
| 1421 | node.dir = "auto"; |
| 1422 | } |
| 1423 | |
| 1424 | function stringList(value) { |
| 1425 | if (Array.isArray(value)) return value.map(String); |
| 1426 | if (value == null || value === "") return []; |
| 1427 | return [String(value)]; |
| 1428 | } |
| 1429 | |
| 1430 | function normalizedFontToken(value) { |
| 1431 | return String(value || "").trim().toLowerCase().replace(/[_\s]+/g, "-"); |
| 1432 | } |
| 1433 | |
| 1434 | function fontScriptTokens(language) { |
| 1435 | var parts = normalizedFontToken(language).split("-"); |
| 1436 | var base = parts[0]; |
| 1437 | if (!base || base === "und") return []; |
| 1438 | var explicitScript = parts.filter(function (part) { |
| 1439 | return /^[a-z]{4}$/.test(part); |
| 1440 | })[0]; |
| 1441 | if (explicitScript) return [explicitScript]; |
| 1442 | if (base === "en") return ["latin", "latn", "english"]; |
| 1443 | if (base === "zh") { |
| 1444 | if (parts.indexOf("cn") >= 0 || parts.indexOf("sg") >= 0) { |
| 1445 | return ["han", "hans", "cjk", "chinese", "zh"]; |
| 1446 | } |
| 1447 | if (parts.indexOf("tw") >= 0 || parts.indexOf("hk") >= 0 || |
| 1448 | parts.indexOf("mo") >= 0) { |
| 1449 | return ["han", "hant", "cjk", "chinese", "zh"]; |
| 1450 | } |
| 1451 | return ["han", "hans", "cjk", "chinese", "zh"]; |
| 1452 | } |
| 1453 | if (base === "ja") return ["han", "jpan", "kana", "hiragana", "katakana", "cjk", "japanese", "ja"]; |
| 1454 | if (base === "ko") return ["hangul", "kore", "han", "cjk", "korean", "ko"]; |
| 1455 | if (["ru", "uk", "bg", "sr", "mk"].indexOf(base) >= 0) return ["cyrl", "cyrillic"]; |
| 1456 | if (["ar", "fa", "ur"].indexOf(base) >= 0) return ["arab", "arabic"]; |
| 1457 | if (base === "he" || base === "iw") return ["hebr", "hebrew"]; |
| 1458 | if (base === "el") return ["grek", "greek"]; |
| 1459 | if (base === "th") return ["thai"]; |
| 1460 | if (base === "hi" || base === "mr" || base === "ne") return ["deva", "devanagari"]; |
| 1461 | // Most remaining presentation locales use Latin script. Locale metadata |
| 1462 | // still wins before this fallback. |
| 1463 | return ["latin", "latn"]; |
| 1464 | } |
| 1465 | |
| 1466 | function fontSupportsLanguage(font, language) { |
| 1467 | var target = normalizedFontToken(language); |
| 1468 | var targetBase = target.split("-")[0]; |
| 1469 | var locales = stringList(font && font.locales).map(normalizedFontToken); |
| 1470 | if (locales.some(function (locale) { |
| 1471 | return locale === "*" || locale === target || |
| 1472 | (target !== targetBase && locale === targetBase); |
| 1473 | })) return true; |
| 1474 | |
| 1475 | var supportedScripts = stringList(font && font.scripts).map(normalizedFontToken); |
| 1476 | var wantedScripts = fontScriptTokens(language); |
| 1477 | if (supportedScripts.some(function (script) { |
| 1478 | return wantedScripts.indexOf(script) >= 0; |
| 1479 | })) return true; |
| 1480 | |
| 1481 | // A catalog entry without language metadata remains usable. Once either |
| 1482 | // metadata field is present, filtering follows it. |
| 1483 | return !locales.length && !supportedScripts.length; |
| 1484 | } |
| 1485 | |
| 1486 | function fontCatalogEntries(field) { |
| 1487 | var fonts = (CAT && Array.isArray(CAT.fonts)) ? CAT.fonts : []; |
| 1488 | var language = field === "english" ? "en" : recommendationLanguage(); |
| 1489 | if (field === "primary" && language === "und") { |
| 1490 | return fonts.filter(function (font) { return font && font.id; }); |
| 1491 | } |
| 1492 | var matching = fonts.filter(function (font) { |
| 1493 | return font && font.id && fontSupportsLanguage(font, language); |
| 1494 | }); |
| 1495 | return matching; |
| 1496 | } |
| 1497 | |
| 1498 | function findFontCatalogEntry(id) { |
| 1499 | var fonts = (CAT && Array.isArray(CAT.fonts)) ? CAT.fonts : []; |
| 1500 | var target = String(id || "").trim().toLowerCase(); |
| 1501 | for (var i = 0; i < fonts.length; i += 1) { |
| 1502 | if (String(fonts[i].id || "").trim().toLowerCase() === target) return fonts[i]; |
| 1503 | } |
| 1504 | return null; |
| 1505 | } |
| 1506 | |
| 1507 | function derivedFontCss(font) { |
| 1508 | var primary = findFontCatalogEntry(font && font.primary); |
| 1509 | return String((primary && primary.css) || "sans-serif").trim() || |
| 1510 | "sans-serif"; |
| 1511 | } |
| 1512 | |
| 1513 | function normalizedFontRole(font, samples) { |
| 1514 | font = (font && typeof font === "object") ? Object.assign({}, font) : {}; |
| 1515 | samples = samples || {}; |
| 1516 | var primary = font.primary; |
| 1517 | var english = font.english; |
| 1518 | if (isEnglishProject()) { |
| 1519 | primary = primary || font.latin || font.cjk || ""; |
| 1520 | english = ""; |
| 1521 | } else { |
| 1522 | primary = primary || font.cjk || ""; |
| 1523 | english = english || font.latin || ""; |
| 1524 | } |
| 1525 | font.primary = primary; |
| 1526 | if (isEnglishProject()) delete font.english; |
| 1527 | else font.english = english; |
| 1528 | font.sample_primary = font.sample_primary || |
| 1529 | (isEnglishProject() ? font.sample_latin : font.sample_cjk) || |
| 1530 | samples.primary || ""; |
| 1531 | if (isEnglishProject()) { |
| 1532 | delete font.sample_english; |
| 1533 | } else { |
| 1534 | font.sample_english = font.sample_english || font.sample_latin || |
| 1535 | samples.english || ""; |
| 1536 | } |
| 1537 | delete font.cjk; |
| 1538 | delete font.latin; |
| 1539 | delete font.sample_cjk; |
| 1540 | delete font.sample_latin; |
| 1541 | if (!String(font.css || "").trim()) font.css = derivedFontCss(font); |
| 1542 | return font; |
| 1543 | } |
| 1544 | |
| 1545 | function normTypography(c) { |
| 1546 | c = c || {}; |
| 1547 | var normalized = Object.assign({}, c); |
| 1548 | var objectShape = c.heading && typeof c.heading === "object" && |
| 1549 | c.body && typeof c.body === "object"; |
| 1550 | var heading = objectShape ? c.heading : { |
| 1551 | primary: isEnglishProject() ? (c.heading_latin || c.heading || "") : (c.heading || ""), |
| 1552 | english: isEnglishProject() ? "" : (c.heading_latin || ""), |
| 1553 | css: c.heading_css || "" |
| 1554 | }; |
| 1555 | var body = objectShape ? c.body : { |
| 1556 | primary: isEnglishProject() ? (c.body_latin || c.body || "") : (c.body || ""), |
| 1557 | english: isEnglishProject() ? "" : (c.body_latin || ""), |
| 1558 | css: c.body_css || "" |
| 1559 | }; |
| 1560 | normalized.name = c.name || ""; |
| 1561 | normalized.note = c.note || ""; |
| 1562 | normalized.custom = c.custom || ""; |
| 1563 | normalized.body_size = typographyBodySize(c); |
| 1564 | normalized.heading = normalizedFontRole(heading, { |
| 1565 | primary: isEnglishProject() |
| 1566 | ? (c.sample_heading_latin || c.sample_heading || "") |
| 1567 | : (c.sample_heading || ""), |
| 1568 | english: c.sample_heading_latin || "" |
| 1569 | }); |
| 1570 | normalized.body = normalizedFontRole(body, { |
| 1571 | primary: isEnglishProject() |
| 1572 | ? (c.sample_body_latin || c.sample_body || "") |
| 1573 | : (c.sample_body || ""), |
| 1574 | english: c.sample_body_latin || "" |
| 1575 | }); |
| 1576 | delete normalized.heading_latin; |
| 1577 | delete normalized.body_latin; |
| 1578 | delete normalized.heading_css; |
| 1579 | delete normalized.body_css; |
| 1580 | delete normalized.sample_heading; |
| 1581 | delete normalized.sample_heading_latin; |
| 1582 | delete normalized.sample_body; |
| 1583 | delete normalized.sample_body_latin; |
| 1584 | return normalized; |
| 1585 | } |
| 1586 | |
| 1587 | function typographyBodySize(c) { |
| 1588 | c = c || {}; |
| 1589 | var value = c.body_size || c.body_baseline || c.body_px || |
| 1590 | (c.sizes && c.sizes.body) || |
| 1591 | (c.size && c.size.body) || |
| 1592 | (c.body && typeof c.body === "object" && (c.body.size || c.body.font_size)); |
| 1593 | return value == null ? "" : String(value).replace(/px$/i, ""); |
| 1594 | } |
| 1595 | |
| 1596 | function designDirectionSpec() { |
| 1597 | return (REC && REC.design_directions) || |
| 1598 | (REC && REC.design && REC.design.directions) || |
| 1599 | {}; |
| 1600 | } |
| 1601 | |
| 1602 | function designDirectionCandidates() { |
| 1603 | var spec = designDirectionSpec(); |
| 1604 | return spec.candidates || spec.options || []; |
| 1605 | } |
| 1606 | |
| 1607 | function selectedDesignDirection() { |
| 1608 | var candidates = designDirectionCandidates(); |
| 1609 | var selected = Number(designDirectionSpec().selected || 0); |
| 1610 | if (!isFinite(selected) || selected < 0) selected = 0; |
| 1611 | return candidates[Math.min(selected, Math.max(candidates.length - 1, 0))] || {}; |
| 1612 | } |
| 1613 | |
| 1614 | function directionField(field) { |
| 1615 | var candidate = selectedDesignDirection(); |
| 1616 | return candidate[field] != null ? candidate[field] : null; |
| 1617 | } |
| 1618 | |
| 1619 | function customCandidateSpec(field) { |
| 1620 | var candidates = REC && REC.custom_candidates; |
| 1621 | var candidate = candidates && candidates[field]; |
| 1622 | if (typeof candidate === "string") return { behavior: candidate }; |
| 1623 | if (candidate && typeof candidate === "object") return candidate; |
| 1624 | var legacy = REC && REC[field + "_behavior"]; |
| 1625 | if (legacy && typeof legacy === "object") legacy = legacy.value; |
| 1626 | return legacy ? { behavior: legacy } : {}; |
| 1627 | } |
| 1628 | |
| 1629 | function customCandidateBehavior(field) { |
| 1630 | var candidate = customCandidateSpec(field); |
| 1631 | return String(localized(candidate, "behavior") || candidate.value || candidate.custom || ""); |
| 1632 | } |
| 1633 | |
| 1634 | function creativeCustomOptions(field, stateKey, placeholderKey) { |
| 1635 | var candidate = customCandidateSpec(field); |
| 1636 | var current = String(STATE[stateKey] || customCandidateBehavior(field) || ""); |
| 1637 | if (!current.trim()) return null; |
| 1638 | STATE[stateKey] = current; |
| 1639 | return { |
| 1640 | label: localized(candidate, "name") || t("ai_custom_candidate"), |
| 1641 | hint: t("ai_custom_candidate_hint"), |
| 1642 | getText: function () { return STATE[stateKey] || ""; }, |
| 1643 | setText: function (value) { STATE[stateKey] = value; }, |
| 1644 | rows: 4, |
| 1645 | placeholder: t(placeholderKey) |
| 1646 | }; |
| 1647 | } |
| 1648 | |
| 1649 | function colorRecommendationCandidates() { |
| 1650 | var direct = (REC.color && REC.color.candidates) || []; |
| 1651 | if (direct.length) return direct; |
| 1652 | return designDirectionCandidates().map(function (candidate) { |
| 1653 | return candidate && candidate.color; |
| 1654 | }).filter(Boolean); |
| 1655 | } |
| 1656 | |
| 1657 | function typographyRecommendationCandidates() { |
| 1658 | var direct = (REC.typography && REC.typography.candidates) || []; |
| 1659 | if (direct.length) return direct; |
| 1660 | return designDirectionCandidates().map(function (candidate) { |
| 1661 | return candidate && candidate.typography; |
| 1662 | }).filter(Boolean); |
| 1663 | } |
| 1664 | |
| 1665 | function imageStrategySpec() { |
| 1666 | return (REC && REC.image_strategy) || |
| 1667 | (REC && REC.images && REC.images.strategy) || |
| 1668 | (REC && REC.images && REC.images.ai_strategy) || |
| 1669 | {}; |
| 1670 | } |
| 1671 | |
| 1672 | function imageStrategyCandidates() { |
| 1673 | var spec = imageStrategySpec(); |
| 1674 | var direct = spec.candidates || spec.options || []; |
| 1675 | if (direct.length) return direct; |
| 1676 | return designDirectionCandidates().map(function (candidate) { |
| 1677 | return candidate && candidate.image_strategy; |
| 1678 | }).filter(Boolean); |
| 1679 | } |
| 1680 | |
| 1681 | function imageStrategyRecommendationCandidates() { |
| 1682 | return imageStrategyCandidates().filter(function (candidate) { |
| 1683 | return candidate && candidate.rendering !== "custom"; |
| 1684 | }).slice(0, 3); |
| 1685 | } |
| 1686 | |
| 1687 | function imageStrategyCatalogCandidates() { |
| 1688 | var items = AI_IMAGE_COMPARISON && AI_IMAGE_COMPARISON.rendering; |
| 1689 | if (!Array.isArray(items)) return []; |
| 1690 | return items.map(function (item) { |
| 1691 | return item && item.id ? { rendering: item.id } : null; |
| 1692 | }).filter(Boolean); |
| 1693 | } |
| 1694 | |
| 1695 | function imageStrategySelectableCandidates() { |
| 1696 | var recommended = imageStrategyRecommendationCandidates(); |
| 1697 | var seen = {}; |
| 1698 | recommended.forEach(function (candidate) { seen[candidate.rendering] = true; }); |
| 1699 | return recommended.concat(imageStrategyCatalogCandidates().filter(function (candidate) { |
| 1700 | return !seen[candidate.rendering]; |
| 1701 | })); |
| 1702 | } |
| 1703 | |
| 1704 | function imageStrategyCustomCandidate() { |
| 1705 | var candidate = customCandidateSpec("image_strategy"); |
| 1706 | if (!customCandidateBehavior("image_strategy")) { |
| 1707 | candidate = imageStrategyCandidates().filter(function (item) { |
| 1708 | return item && item.rendering === "custom"; |
| 1709 | })[0] || {}; |
| 1710 | } |
| 1711 | if (!candidate || typeof candidate !== "object") candidate = {}; |
| 1712 | candidate = Object.assign({}, candidate, { rendering: "custom" }); |
| 1713 | return normalizedImageStrategy(candidate); |
| 1714 | } |
| 1715 | |
| 1716 | function normalizedImageStrategy(candidate) { |
| 1717 | candidate = candidate || {}; |
| 1718 | var out = { |
| 1719 | name: localized(candidate, "name") || candidate.name || "", |
| 1720 | rendering: candidate.rendering || "", |
| 1721 | visual: localized(candidate, "visual") || "", |
| 1722 | mood: localized(candidate, "mood") || "" |
| 1723 | }; |
| 1724 | var behavior = localized(candidate, "behavior") || candidate.behavior || candidate.custom || ""; |
| 1725 | if (behavior) out.behavior = behavior; |
| 1726 | return out; |
| 1727 | } |
| 1728 | |
| 1729 | function usesCustomImagePlanValue(value) { |
| 1730 | var ids = (CAT.image_usage || []).map(function (item) { return item.id; }); |
| 1731 | if (Array.isArray(value)) return false; |
| 1732 | return value && ids.indexOf(value) === -1; |
| 1733 | } |
| 1734 | |
| 1735 | function customImagePlanHasAiSignal() { |
| 1736 | return imageStrategyRecommendationCandidates().length > 0 || !!recId("image_ai_path"); |
| 1737 | } |
| 1738 | |
| 1739 | function needsGeneratedImagesForUsage(value) { |
| 1740 | if (Array.isArray(value)) return value.indexOf("ai") >= 0; |
| 1741 | return value === "ai" || (usesCustomImagePlanValue(value) && customImagePlanHasAiSignal()); |
| 1742 | } |
| 1743 | |
| 1744 | function selectedImageUsageIds(value) { |
| 1745 | var validIds = (CAT.image_usage || []).map(function (item) { return item.id; }); |
| 1746 | return normalizeRecIds("image_usage", value).filter(function (id) { |
| 1747 | return validIds.indexOf(id) >= 0; |
| 1748 | }); |
| 1749 | } |
| 1750 | |
| 1751 | function imageUsageNotesRecommendation(rawUsage) { |
| 1752 | var notes = (REC && REC.image_notes && REC.image_notes.value) || |
| 1753 | (REC && REC.image_notes) || |
| 1754 | (REC && REC.images && REC.images.notes) || |
| 1755 | ""; |
| 1756 | if (!notes && usesCustomImagePlanValue(rawUsage)) notes = rawUsage; |
| 1757 | return typeof notes === "string" ? notes : ""; |
| 1758 | } |
| 1759 | |
| 1760 | function defaultImageUsageId() { |
| 1761 | return firstId(CAT.image_usage); |
| 1762 | } |
| 1763 | |
| 1764 | function imageStrategySelectedIndex() { |
| 1765 | var spec = imageStrategySpec(); |
| 1766 | var direct = spec.candidates || spec.options || []; |
| 1767 | var idx = direct.length ? (spec.selected || 0) : (designDirectionSpec().selected || 0); |
| 1768 | return Math.min(idx, Math.max(imageStrategyRecommendationCandidates().length - 1, 0)); |
| 1769 | } |
| 1770 | |
| 1771 | // ---- section renderers ---------------------------------------------- |
| 1772 | function renderCanvas(host) { |
| 1773 | var sec = section(1, "sec_canvas"); |
| 1774 | enumField(sec, CAT.canvas, recOrFirst("canvas", CAT.canvas), |
| 1775 | function () { return STATE.canvas; }, |
| 1776 | function (v) { |
| 1777 | STATE.canvas = v; |
| 1778 | if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} }; |
| 1779 | // Canvas changes dimensions only — never silently rewrite font sizes |
| 1780 | // the user can see / edit. The size hint re-renders with the new |
| 1781 | // canvas; a default body is filled only when none is set yet. |
| 1782 | if (!STATE.typography.body_size) { |
| 1783 | STATE.typography.body_size = defaultBodySizeForCanvas(v, STATE.delivery_purpose); |
| 1784 | } |
| 1785 | renderAll(); |
| 1786 | }, { allowCustom: true }); |
| 1787 | host.appendChild(sec); |
| 1788 | } |
| 1789 | |
| 1790 | function renderPages(host) { |
| 1791 | var sec = section(2, "sec_pages"); |
| 1792 | textField(sec, function () { return STATE.page_count; }, |
| 1793 | function (v) { STATE.page_count = v; }, "placeholder_pages", true); |
| 1794 | host.appendChild(sec); |
| 1795 | } |
| 1796 | |
| 1797 | function templateApplicationRecommendation() { |
| 1798 | if (!REC || REC.template_application == null) return null; |
| 1799 | var field = REC.template_application; |
| 1800 | if (typeof field === "object") { |
| 1801 | return field.value == null ? "" : String(field.value); |
| 1802 | } |
| 1803 | return String(field); |
| 1804 | } |
| 1805 | |
| 1806 | function renderTemplateApplication(host) { |
| 1807 | if (templateApplicationRecommendation() == null) return; |
| 1808 | var sec = section("T", "sec_template_application"); |
| 1809 | setSectionNote(sec, t("template_application_hint")); |
| 1810 | textareaField(sec, |
| 1811 | function () { return STATE.template_application; }, |
| 1812 | function (v) { STATE.template_application = v; }, |
| 1813 | "placeholder_template_application", 4); |
| 1814 | host.appendChild(sec); |
| 1815 | } |
| 1816 | |
| 1817 | function renderCommunication(host) { |
| 1818 | var sec = section(1, "sec_communication"); |
| 1819 | var audienceField = el("div", "subfield"); |
| 1820 | audienceField.appendChild(el("div", "subfield-label", t("sec_audience"))); |
| 1821 | textField(audienceField, function () { return STATE.audience; }, |
| 1822 | function (v) { STATE.audience = v; }, "placeholder_audience", false); |
| 1823 | sec.appendChild(audienceField); |
| 1824 | labeledTextarea(sec, "communication_intent", |
| 1825 | function () { return STATE.communication_intent; }, |
| 1826 | function (v) { STATE.communication_intent = v; }, |
| 1827 | "placeholder_communication_intent", "communication_intent_hint", 3); |
| 1828 | labeledTextarea(sec, "audience_outcome", |
| 1829 | function () { return STATE.audience_outcome; }, |
| 1830 | function (v) { STATE.audience_outcome = v; }, |
| 1831 | "placeholder_audience_outcome", null, 2); |
| 1832 | labeledTextarea(sec, "core_message", |
| 1833 | function () { return STATE.core_message; }, |
| 1834 | function (v) { STATE.core_message = v; }, |
| 1835 | "placeholder_core_message", null, 2); |
| 1836 | host.appendChild(sec); |
| 1837 | } |
| 1838 | |
| 1839 | function renderDelivery(host) { |
| 1840 | var sec = section(2, "sec_delivery"); |
| 1841 | labeledTextarea(sec, "delivery_context", |
| 1842 | function () { return STATE.delivery_context; }, |
| 1843 | function (v) { STATE.delivery_context = v; }, |
| 1844 | "placeholder_delivery_context", "delivery_context_hint", 2); |
| 1845 | labeledTextarea(sec, "artifact_afterlife", |
| 1846 | function () { return STATE.artifact_afterlife; }, |
| 1847 | function (v) { STATE.artifact_afterlife = v; }, |
| 1848 | "placeholder_artifact_afterlife", null, 2); |
| 1849 | // Material divergence remains open prose: it controls how source material |
| 1850 | // may be reshaped, independently of communication intent and template reuse. |
| 1851 | var divergenceField = labeledTextarea(sec, "sub_divergence", |
| 1852 | function () { return STATE.content_divergence; }, |
| 1853 | function (v) { STATE.content_divergence = v; }, |
| 1854 | "placeholder_divergence", null, 2); |
| 1855 | if (recommendationFieldLocked("content_divergence")) { |
| 1856 | var divergenceInput = divergenceField.querySelector("textarea"); |
| 1857 | if (divergenceInput) { |
| 1858 | divergenceInput.readOnly = true; |
| 1859 | divergenceInput.classList.add("locked-field"); |
| 1860 | } |
| 1861 | divergenceField.appendChild(el("div", "toggle-desc locked-field-hint", t("content_divergence_locked_hint"))); |
| 1862 | } |
| 1863 | host.appendChild(sec); |
| 1864 | } |
| 1865 | |
| 1866 | function renderReadingMode(host) { |
| 1867 | if (!isPptCanvas(STATE.canvas)) return; |
| 1868 | var sec = section("D", "delivery_purpose"); |
| 1869 | setSectionNote(sec, t("delivery_purpose_hint")); |
| 1870 | enumField(sec, CAT.delivery_purpose, |
| 1871 | recOrFirst("delivery_purpose", CAT.delivery_purpose), |
| 1872 | function () { return STATE.delivery_purpose; }, |
| 1873 | function (v) { |
| 1874 | STATE.delivery_purpose = v; |
| 1875 | // Same-stage dependency, resolved entirely in the browser: do |
| 1876 | // not ask the backend to author Stage 2 again. Manual size |
| 1877 | // overrides remain authoritative. |
| 1878 | syncUnpinnedTypographySizes(true); |
| 1879 | }); |
| 1880 | host.appendChild(sec); |
| 1881 | } |
| 1882 | |
| 1883 | function applyDesignDirection(candidate) { |
| 1884 | candidate = candidate || {}; |
| 1885 | if (candidate.mode) { |
| 1886 | STATE.mode = candidate.mode; |
| 1887 | if (candidate.mode === "custom" && candidate.mode_behavior) { |
| 1888 | STATE.mode_behavior = candidate.mode_behavior; |
| 1889 | } |
| 1890 | } |
| 1891 | if (candidate.visual_style) { |
| 1892 | STATE.visual_style = candidate.visual_style; |
| 1893 | if (candidate.visual_style === "custom" && candidate.visual_style_behavior) { |
| 1894 | STATE.visual_style_behavior = candidate.visual_style_behavior; |
| 1895 | } |
| 1896 | } |
| 1897 | if (candidate.color) { |
| 1898 | STATE.color = { |
| 1899 | name: localized(candidate.color, "name") || candidate.color.name || "", |
| 1900 | palette: Object.assign({}, normPalette(candidate.color)) |
| 1901 | }; |
| 1902 | } |
| 1903 | if (candidate.typography) { |
| 1904 | var typography = normTypography(candidate.typography); |
| 1905 | var previousTypography = STATE.typography || {}; |
| 1906 | STATE.typography = { |
| 1907 | name: localized(typography, "name") || typography.name || "", |
| 1908 | heading: typography.heading || {}, |
| 1909 | body: typography.body || {}, |
| 1910 | // A direction changes font character, not the already-visible |
| 1911 | // reading-mode sizing state. |
| 1912 | body_size: previousTypography.body_size || |
| 1913 | defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose), |
| 1914 | sizes: Object.assign({}, previousTypography.sizes || {}) |
| 1915 | }; |
| 1916 | } |
| 1917 | if (candidate.icons) STATE.icons = normalizeRecId("icons", candidate.icons); |
| 1918 | if (candidate.image_strategy) { |
| 1919 | STATE.image_strategy = normalizedImageStrategy(candidate.image_strategy); |
| 1920 | } |
| 1921 | if (candidate.image_usage) { |
| 1922 | var usage = selectedImageUsageIds(candidate.image_usage); |
| 1923 | if (usage.length) STATE.image_usage = usage; |
| 1924 | } |
| 1925 | renderAll(); |
| 1926 | } |
| 1927 | |
| 1928 | function renderDesignDirections(host) { |
| 1929 | var candidates = designDirectionCandidates(); |
| 1930 | if (!candidates.length) return; |
| 1931 | var sec = section("B", "sec_design_directions", t("design_directions_hint")); |
| 1932 | var grid = el("div", "font-grid design-direction-grid"); |
| 1933 | candidates.forEach(function (candidate, idx) { |
| 1934 | var card = el("div", "font-card design-direction-card"); |
| 1935 | var head = el("div", "font-card-head"); |
| 1936 | head.appendChild(el("span", "font-card-name", |
| 1937 | localized(candidate, "name") || (t("option_prefix") + " " + (idx + 1)))); |
| 1938 | card.appendChild(head); |
| 1939 | if (candidate.visual_style) { |
| 1940 | var preview = el("div", "design-direction-preview"); |
| 1941 | appendVisualStyleImage(preview, candidate.visual_style); |
| 1942 | card.appendChild(preview); |
| 1943 | } |
| 1944 | var meta = []; |
| 1945 | if (candidate.visual_style) meta.push(humanizeId(candidate.visual_style)); |
| 1946 | if (candidate.icons) meta.push(humanizeId(candidate.icons)); |
| 1947 | if (candidate.image_strategy && candidate.image_strategy.rendering) { |
| 1948 | meta.push(comparisonValueLabel("rendering", candidate.image_strategy.rendering)); |
| 1949 | } |
| 1950 | if (meta.length) card.appendChild(el("div", "font-card-meta", meta.join(" · "))); |
| 1951 | var palette = normPalette(candidate.color || {}); |
| 1952 | var swatches = el("div", "palette-swatches design-direction-swatches"); |
| 1953 | PALETTE_ROLES.forEach(function (role) { |
| 1954 | var value = normHex(palette[role]); |
| 1955 | if (!value) return; |
| 1956 | var swatch = el("span", "swatch"); |
| 1957 | swatch.style.background = value; |
| 1958 | swatch.title = role + ": " + value; |
| 1959 | swatches.appendChild(swatch); |
| 1960 | }); |
| 1961 | if (swatches.childElementCount) card.appendChild(swatches); |
| 1962 | var note = localized(candidate, "note"); |
| 1963 | if (note) card.appendChild(el("div", "color-note", note)); |
| 1964 | card.addEventListener("click", function () { applyDesignDirection(candidate); }); |
| 1965 | grid.appendChild(card); |
| 1966 | }); |
| 1967 | sec.appendChild(grid); |
| 1968 | host.appendChild(sec); |
| 1969 | } |
| 1970 | |
| 1971 | function renderNarrativeDirection(host) { |
| 1972 | var sec = section(4, "sec_narrative"); |
| 1973 | var custom = creativeCustomOptions("mode", "mode_behavior", "mode_behavior_placeholder"); |
| 1974 | enumField(sec, CAT.modes, recOrFirst("mode", CAT.modes), |
| 1975 | function () { return STATE.mode; }, function (v) { STATE.mode = v; }, |
| 1976 | { |
| 1977 | allowCustom: !!custom, |
| 1978 | customOnOwnRow: true, |
| 1979 | customSentinel: "custom", |
| 1980 | placeholder: t("mode_behavior_placeholder"), |
| 1981 | aiCustom: custom |
| 1982 | }); |
| 1983 | host.appendChild(sec); |
| 1984 | } |
| 1985 | |
| 1986 | function visualStyleRecommendationSpectrum() { |
| 1987 | var raw = (REC && Array.isArray(REC.visual_style_spectrum)) ? REC.visual_style_spectrum : []; |
| 1988 | if (!raw.length) { |
| 1989 | raw = designDirectionCandidates().map(function (candidate) { |
| 1990 | return { |
| 1991 | id: candidate && candidate.visual_style, |
| 1992 | tag_zh: candidate && candidate.name_zh, |
| 1993 | tag_en: candidate && candidate.name_en, |
| 1994 | tag_ja: candidate && candidate.name_ja, |
| 1995 | note_zh: candidate && candidate.note_zh, |
| 1996 | note_en: candidate && candidate.note_en, |
| 1997 | note_ja: candidate && candidate.note_ja |
| 1998 | }; |
| 1999 | }); |
| 2000 | } |
| 2001 | var spectrum = []; |
| 2002 | var seen = {}; |
| 2003 | raw.some(function (item) { |
| 2004 | var id = normalizeRecId("visual_style", item && item.id); |
| 2005 | if (!id || seen[id]) return false; |
| 2006 | seen[id] = true; |
| 2007 | spectrum.push(Object.assign({}, item, { id: id })); |
| 2008 | return spectrum.length === 3; |
| 2009 | }); |
| 2010 | if (!spectrum.length) { |
| 2011 | var fallbackId = recId("visual_style") || normalizeRecId("visual_style", directionField("visual_style")); |
| 2012 | if (fallbackId) spectrum.push({ id: fallbackId }); |
| 2013 | } |
| 2014 | return spectrum; |
| 2015 | } |
| 2016 | |
| 2017 | function renderVisualDirection(host) { |
| 2018 | var sec = section(5, "sec_visual"); |
| 2019 | var custom = creativeCustomOptions( |
| 2020 | "visual_style", "visual_style_behavior", "visual_style_behavior_placeholder" |
| 2021 | ); |
| 2022 | enumField(sec, CAT.visual_styles, recOrFirst("visual_style", CAT.visual_styles), |
| 2023 | function () { return STATE.visual_style; }, function (v) { STATE.visual_style = v; }, |
| 2024 | { |
| 2025 | allowCustom: !!custom, |
| 2026 | customOnOwnRow: true, |
| 2027 | customSentinel: "custom", |
| 2028 | placeholder: t("visual_style_behavior_placeholder"), |
| 2029 | aiCustom: custom, |
| 2030 | spectrum: visualStyleRecommendationSpectrum(), |
| 2031 | preview: "visual_style", |
| 2032 | chipsClass: "visual-style-grid" |
| 2033 | }); |
| 2034 | host.appendChild(sec); |
| 2035 | } |
| 2036 | |
| 2037 | var PALETTE_ROLES = [ |
| 2038 | "background", |
| 2039 | "secondary_bg", |
| 2040 | "primary", |
| 2041 | "accent", |
| 2042 | "secondary_accent", |
| 2043 | "body_text" |
| 2044 | ]; |
| 2045 | |
| 2046 | function normHex(val) { |
| 2047 | var v = (val || "").trim(); |
| 2048 | if (!/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(v)) return null; |
| 2049 | return v.charAt(0) === "#" ? v : "#" + v; |
| 2050 | } |
| 2051 | function hexOr(val, fallback) { |
| 2052 | return normHex(val) || fallback; |
| 2053 | } |
| 2054 | // Replaced when the combined color+typography preview mounts; the color and |
| 2055 | // typography sections call it after every change so the preview stays live. |
| 2056 | var refreshStylePreview = function () {}; |
| 2057 | // Replaced when the selected generated-image preview mounts. |
| 2058 | var refreshImageStrategyPreview = function () {}; |
| 2059 | // Replaced when the final plan's image-production section mounts; image-use |
| 2060 | // edits call it so the conditional AI path stays synchronized on the page. |
| 2061 | var refreshImageProduction = function () {}; |
| 2062 | // Replaced when the typography section mounts; the canvas section calls it so |
| 2063 | // the body-size hint tracks the chosen canvas height. |
| 2064 | var refreshBodySizeHint = function () {}; |
| 2065 | // Replaced when the typography section mounts; body-size / reading-mode |
| 2066 | // changes call it so unpinned per-role values update locally. |
| 2067 | var refreshSizeInputs = function () {}; |
| 2068 | |
| 2069 | // Per-role size slots the user can edit directly (parallel to color roles). |
| 2070 | // Defaults derive from `body` via mid-band ramp ratios (strategist.md §g); |
| 2071 | // values are px (the system's only unit). |
| 2072 | var SIZE_ROLES = ["title", "subtitle", "annotation"]; |
| 2073 | var SIZE_RATIO = { title: 1.75, subtitle: 1.35, annotation: 0.78 }; |
| 2074 | var TYPOGRAPHY_SIZE_OVERRIDES = { |
| 2075 | body: false, |
| 2076 | title: false, |
| 2077 | subtitle: false, |
| 2078 | annotation: false |
| 2079 | }; |
| 2080 | |
| 2081 | function resetTypographySizeOverrides() { |
| 2082 | Object.keys(TYPOGRAPHY_SIZE_OVERRIDES).forEach(function (role) { |
| 2083 | TYPOGRAPHY_SIZE_OVERRIDES[role] = false; |
| 2084 | }); |
| 2085 | } |
| 2086 | |
| 2087 | function deriveSize(role, bodyVal) { |
| 2088 | var raw = (bodyVal || 0) * (SIZE_RATIO[role] || 1); |
| 2089 | // All px. On PPT, snap the recommended role size to a clean even number so |
| 2090 | // the user sees conventional sizes (body 24 → title 42, subtitle 32), not |
| 2091 | // ratio leftovers. Non-PPT keeps a plain integer — large px, snapping moot. |
| 2092 | if (isPptCanvas(STATE.canvas)) return Math.round(raw / 2) * 2; |
| 2093 | return Math.round(raw); |
| 2094 | } |
| 2095 | |
| 2096 | // Canvas height (viewBox user units) parsed from a catalog `dim` like |
| 2097 | // "1242×1660" or from a custom canvas string containing WxH; null if unknown. |
| 2098 | function canvasHeight(canvasVal) { |
| 2099 | var dim = null; |
| 2100 | (CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; }); |
| 2101 | var m = String(dim || canvasVal || "").match(/(\d{2,5})\s*[×xX*]\s*(\d{2,5})/); |
| 2102 | return m ? parseInt(m[2], 10) : null; |
| 2103 | } |
| 2104 | |
| 2105 | function bodySizeRatioBand(canvasVal) { |
| 2106 | var dim = null; |
| 2107 | (CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; }); |
| 2108 | var raw = String(dim || canvasVal || ""); |
| 2109 | var id = String(canvasVal || "").toLowerCase(); |
| 2110 | var isPpt = id === "ppt169" || id === "ppt43" || |
| 2111 | /1280\s*[×xX*]\s*720/.test(raw) || |
| 2112 | /1024\s*[×xX*]\s*768/.test(raw); |
| 2113 | return isPpt ? { lo: 0.031, hi: 0.047 } : { lo: 0.025, hi: 0.033 }; |
| 2114 | } |
| 2115 | |
| 2116 | // PPT canvases (16:9 / 4:3) take the fixed per-reading-mode body px; |
| 2117 | // social / print canvases scale the body px by canvas height instead. |
| 2118 | function isPptCanvas(canvasVal) { |
| 2119 | var dim = null; |
| 2120 | (CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; }); |
| 2121 | var raw = String(dim || canvasVal || ""); |
| 2122 | var id = String(canvasVal || "").toLowerCase(); |
| 2123 | return id === "ppt169" || id === "ppt43" || |
| 2124 | /1280\s*[×xX*]\s*720/.test(raw) || |
| 2125 | /1024\s*[×xX*]\s*768/.test(raw); |
| 2126 | } |
| 2127 | |
| 2128 | // Body baseline in **px** per reading mode (legacy key: |
| 2129 | // delivery_purpose; see strategist.md §g). The |
| 2130 | // system is px-only — these are the SVG/execution px values, recalibrated for |
| 2131 | // the 1280×720 PPT canvas. No pt layer, no conversion. `def` is the fixed |
| 2132 | // recommendation; lo/hi are a sanity envelope for the out-of-range flag only. |
| 2133 | function deliveryBodyPx(purposeId) { |
| 2134 | if (purposeId === "text") return { lo: 18, hi: 21, def: 20 }; |
| 2135 | if (purposeId === "presentation") return { lo: 28, hi: 32, def: 32 }; |
| 2136 | return { lo: 22, hi: 25, def: 24 }; // balanced — the default |
| 2137 | } |
| 2138 | |
| 2139 | function defaultBodySizeForCanvas(canvasVal, purposeId) { |
| 2140 | if (isPptCanvas(canvasVal)) return deliveryBodyPx(purposeId).def; |
| 2141 | var h = canvasHeight(canvasVal); |
| 2142 | if (!h) return 40; |
| 2143 | var band = bodySizeRatioBand(canvasVal); |
| 2144 | return Math.round(h * (band.lo + band.hi) / 2); |
| 2145 | } |
| 2146 | |
| 2147 | // Resolve the only deterministic same-stage size dependency locally. The |
| 2148 | // backend authors Stage 2 once; changing reading mode or body size updates |
| 2149 | // only unpinned values already visible in this page. |
| 2150 | function syncUnpinnedTypographySizes(resetBodyFromReadingMode) { |
| 2151 | if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} }; |
| 2152 | if (!STATE.typography.sizes) STATE.typography.sizes = {}; |
| 2153 | if (resetBodyFromReadingMode && !TYPOGRAPHY_SIZE_OVERRIDES.body) { |
| 2154 | STATE.typography.body_size = defaultBodySizeForCanvas( |
| 2155 | STATE.canvas, STATE.delivery_purpose |
| 2156 | ); |
| 2157 | } |
| 2158 | var body = parseFloat(STATE.typography.body_size); |
| 2159 | if (!isFinite(body)) { |
| 2160 | body = defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose); |
| 2161 | } |
| 2162 | SIZE_ROLES.forEach(function (role) { |
| 2163 | if (!TYPOGRAPHY_SIZE_OVERRIDES[role]) { |
| 2164 | STATE.typography.sizes[role] = deriveSize(role, body); |
| 2165 | } |
| 2166 | }); |
| 2167 | refreshSizeInputs(); |
| 2168 | refreshBodySizeHint(); |
| 2169 | refreshStylePreview(); |
| 2170 | } |
| 2171 | |
| 2172 | function roundSize(value) { |
| 2173 | return Math.round(value * 100) / 100; |
| 2174 | } |
| 2175 | |
| 2176 | function formatPtFromPx(value) { |
| 2177 | var px = parseFloat(value); |
| 2178 | if (!isFinite(px)) return ""; |
| 2179 | var pt = Math.round(px * 0.75 * 10) / 10; |
| 2180 | return pt % 1 === 0 ? String(Math.round(pt)) : String(pt); |
| 2181 | } |
| 2182 | |
| 2183 | function normalizeTypographyForSubmit(payload) { |
| 2184 | if (!payload.typography || typeof payload.typography !== "object") return; |
| 2185 | var typ = normTypography(payload.typography); |
| 2186 | payload.typography = typ; |
| 2187 | if (isEnglishProject()) { |
| 2188 | ["heading", "body"].forEach(function (role) { |
| 2189 | delete typ[role].english; |
| 2190 | delete typ[role].sample_english; |
| 2191 | }); |
| 2192 | } |
| 2193 | var body = parseFloat(typ.body_size); |
| 2194 | if (!isFinite(body)) { |
| 2195 | // Cleared / invalid body field — fall back so role sizes never submit |
| 2196 | // against an empty anchor. |
| 2197 | body = defaultBodySizeForCanvas(payload.canvas, payload.delivery_purpose); |
| 2198 | } |
| 2199 | // px is the only unit — round and submit as-is. No pt conversion, no |
| 2200 | // body_size_pt / sizes_pt provenance (the system never carries pt). |
| 2201 | typ.body_size = roundSize(body); |
| 2202 | typ.body_size_unit = "px"; |
| 2203 | if (typ.sizes && typeof typ.sizes === "object") { |
| 2204 | Object.keys(typ.sizes).forEach(function (role) { |
| 2205 | var raw = parseFloat(typ.sizes[role]); |
| 2206 | if (isFinite(raw)) typ.sizes[role] = roundSize(raw); |
| 2207 | }); |
| 2208 | } |
| 2209 | if (typographyFamiliesComplete(typ)) delete typ.custom; |
| 2210 | // delivery_purpose is PPT-only; drop it on non-PPT canvases where it has |
| 2211 | // no meaning and was never shown. |
| 2212 | if (!isPptCanvas(payload.canvas)) delete payload.delivery_purpose; |
| 2213 | } |
| 2214 | |
| 2215 | function renderColor(host) { |
| 2216 | var cands = colorRecommendationCandidates(); |
| 2217 | var sec = section(5, "sec_color"); |
| 2218 | var grid = el("div", "color-grid"); |
| 2219 | var hexInputs = {}; |
| 2220 | var hexSwatches = {}; |
| 2221 | var cardSwatchRefs = []; // idx -> {role: swatchEl}, for live override feedback |
| 2222 | var selectedIdx = -1; |
| 2223 | |
| 2224 | function paintSwatch(elem, val) { |
| 2225 | var n = normHex(val); |
| 2226 | elem.style.background = n || "transparent"; |
| 2227 | elem.classList.toggle("hex-swatch-empty", !n); |
| 2228 | } |
| 2229 | function applyHexInputs(pal) { |
| 2230 | PALETTE_ROLES.forEach(function (role) { |
| 2231 | if (hexInputs[role]) hexInputs[role].value = pal[role] || ""; |
| 2232 | if (hexSwatches[role]) paintSwatch(hexSwatches[role], pal[role]); |
| 2233 | }); |
| 2234 | } |
| 2235 | var customInput = el("textarea", "text-input custom-color-input"); |
| 2236 | setNaturalInputDirection(customInput); |
| 2237 | customInput.rows = 2; |
| 2238 | customInput.placeholder = t("custom_color_placeholder"); |
| 2239 | customInput.style.display = "none"; |
| 2240 | |
| 2241 | function selectCard(idx) { |
| 2242 | var c = cands[idx] || {}; |
| 2243 | selectedIdx = idx; |
| 2244 | STATE.color = { |
| 2245 | name: localized(c, "name") || c.name || "", |
| 2246 | palette: Object.assign({}, normPalette(c)) |
| 2247 | }; |
| 2248 | grid.querySelectorAll(".color-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); }); |
| 2249 | customInput.style.display = "none"; |
| 2250 | applyHexInputs(STATE.color.palette); |
| 2251 | refreshStylePreview(); |
| 2252 | } |
| 2253 | |
| 2254 | function selectCustomColor() { |
| 2255 | selectedIdx = -1; |
| 2256 | STATE.color = { name: "custom", custom: customInput.value || "", palette: {} }; |
| 2257 | grid.querySelectorAll(".color-card").forEach(function (card) { card.classList.remove("selected"); }); |
| 2258 | customCard.classList.add("selected"); |
| 2259 | customInput.style.display = "block"; |
| 2260 | customInput.focus(); |
| 2261 | refreshStylePreview(); |
| 2262 | } |
| 2263 | |
| 2264 | cands.forEach(function (c, idx) { |
| 2265 | var pal = normPalette(c); |
| 2266 | var refs = {}; |
| 2267 | var card = el("div", "color-card"); |
| 2268 | var sw = el("div", "swatches"); |
| 2269 | PALETTE_ROLES.forEach(function (role) { |
| 2270 | if (!pal[role]) return; |
| 2271 | var col = el("div", "swatch-col"); |
| 2272 | var s = el("div", "swatch"); s.style.background = pal[role]; |
| 2273 | refs[role] = s; |
| 2274 | col.appendChild(s); |
| 2275 | col.appendChild(el("div", "swatch-role", t("role_" + role))); |
| 2276 | col.appendChild(el("div", "color-hex", normHex(pal[role]) || pal[role])); |
| 2277 | sw.appendChild(col); |
| 2278 | }); |
| 2279 | cardSwatchRefs[idx] = refs; |
| 2280 | card.appendChild(sw); |
| 2281 | card.appendChild(el("div", "color-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1)))); |
| 2282 | if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note"))); |
| 2283 | card.addEventListener("click", function () { selectCard(idx); }); |
| 2284 | grid.appendChild(card); |
| 2285 | }); |
| 2286 | var customCard = el("div", "color-card color-card-custom"); |
| 2287 | customCard.appendChild(el("div", "color-name", t("custom_color"))); |
| 2288 | customCard.addEventListener("click", selectCustomColor); |
| 2289 | grid.appendChild(customCard); |
| 2290 | sec.appendChild(grid); |
| 2291 | customInput.addEventListener("input", function () { |
| 2292 | if (!STATE.color || STATE.color.name !== "custom") selectCustomColor(); |
| 2293 | STATE.color.custom = customInput.value; |
| 2294 | refreshStylePreview(); |
| 2295 | }); |
| 2296 | sec.appendChild(customInput); |
| 2297 | |
| 2298 | var override = el("div", "hex-override"); |
| 2299 | override.appendChild(el("div", "subfield-label", t("hex_override"))); |
| 2300 | var row = el("div", "hex-row"); |
| 2301 | PALETTE_ROLES.forEach(function (role) { |
| 2302 | var wrap = el("div", "hex-cell"); |
| 2303 | wrap.appendChild(el("div", "hex-cell-label", t("role_" + role))); |
| 2304 | var line = el("div", "hex-input-line"); |
| 2305 | var sw = el("div", "hex-swatch hex-swatch-empty"); |
| 2306 | var inp = document.createElement("input"); |
| 2307 | inp.type = "text"; inp.placeholder = "#"; |
| 2308 | inp.addEventListener("input", function () { |
| 2309 | if (!STATE.color) STATE.color = { name: "custom", palette: {} }; |
| 2310 | if (!STATE.color.palette) STATE.color.palette = {}; |
| 2311 | STATE.color.palette[role] = inp.value; |
| 2312 | paintSwatch(sw, inp.value); |
| 2313 | // Reflect a valid override straight onto the selected card so the |
| 2314 | // user sees the change in context, not just in the input row. |
| 2315 | var n = normHex(inp.value); |
| 2316 | if (n && selectedIdx >= 0 && cardSwatchRefs[selectedIdx] && cardSwatchRefs[selectedIdx][role]) { |
| 2317 | cardSwatchRefs[selectedIdx][role].style.background = n; |
| 2318 | } |
| 2319 | refreshStylePreview(); |
| 2320 | }); |
| 2321 | hexInputs[role] = inp; hexSwatches[role] = sw; |
| 2322 | line.appendChild(sw); line.appendChild(inp); |
| 2323 | wrap.appendChild(line); row.appendChild(wrap); |
| 2324 | }); |
| 2325 | override.appendChild(row); |
| 2326 | sec.appendChild(override); |
| 2327 | host.appendChild(sec); |
| 2328 | |
| 2329 | var selIdx = -1; |
| 2330 | if (STATE.color && STATE.color.name && STATE.color.name !== "custom") { |
| 2331 | cands.forEach(function (c, i) { |
| 2332 | if ((localized(c, "name") || c.name) === STATE.color.name) selIdx = i; |
| 2333 | }); |
| 2334 | } |
| 2335 | if (STATE.color && STATE.color.name === "custom") { |
| 2336 | customInput.value = STATE.color.custom || ""; |
| 2337 | selectCustomColor(); |
| 2338 | } else if (selIdx >= 0) { |
| 2339 | selectCard(selIdx); |
| 2340 | } else { |
| 2341 | applyHexInputs((STATE.color && STATE.color.palette) || {}); |
| 2342 | } |
| 2343 | } |
| 2344 | |
| 2345 | function renderIcons(host) { |
| 2346 | var sec = section(6, "sec_icons"); |
| 2347 | enumField(sec, CAT.icons, recOrFirst("icons", CAT.icons), |
| 2348 | function () { return STATE.icons; }, function (v) { STATE.icons = v; refreshStylePreview(); }, |
| 2349 | { allowCustom: true, customOnOwnRow: true, ownRowIds: ["none"] }); |
| 2350 | host.appendChild(sec); |
| 2351 | } |
| 2352 | |
| 2353 | function previewFontStack(primary, fallback) { |
| 2354 | if (!primary) return fallback || ""; |
| 2355 | if (!fallback) return primary; |
| 2356 | return primary + ", " + fallback; |
| 2357 | } |
| 2358 | |
| 2359 | function typographyFamiliesComplete(typography) { |
| 2360 | typography = normTypography(typography || {}); |
| 2361 | return ["heading", "body"].every(function (role) { |
| 2362 | var font = typography[role] || {}; |
| 2363 | var fields = isEnglishProject() ? ["primary", "css"] : |
| 2364 | ["primary", "english", "css"]; |
| 2365 | return fields.every(function (field) { |
| 2366 | return !!String(font[field] || "").trim(); |
| 2367 | }); |
| 2368 | }); |
| 2369 | } |
| 2370 | |
| 2371 | function sampleText(role, field) { |
| 2372 | // Keep comparison copy stable: choices change visual treatment, not content. |
| 2373 | var useEnglish = field === "english" || isEnglishProject(); |
| 2374 | if (role === "heading") { |
| 2375 | return t(useEnglish ? "preview_latin_title" : "preview_big_title"); |
| 2376 | } |
| 2377 | return t(useEnglish ? "preview_latin_body" : "preview_body_intro"); |
| 2378 | } |
| 2379 | |
| 2380 | function fontSample(box, slot, css, role) { |
| 2381 | var line = el("div", "font-sample-line"); |
| 2382 | var primary = el("span", "fs-primary", sampleText(role, "primary")); |
| 2383 | setUiLanguageAttributes(primary); |
| 2384 | var primaryStack = previewFontStack(slot.primary, css); |
| 2385 | if (primaryStack) primary.style.fontFamily = primaryStack; |
| 2386 | if (primaryStack) primary.title = primaryStack; |
| 2387 | line.appendChild(primary); |
| 2388 | if (!isEnglishProject()) { |
| 2389 | var english = el("span", "fs-english", sampleText(role, "english")); |
| 2390 | setEnglishLanguageAttributes(english); |
| 2391 | var englishStack = previewFontStack(slot.english, css); |
| 2392 | if (englishStack) english.style.fontFamily = englishStack; |
| 2393 | if (englishStack) english.title = englishStack; |
| 2394 | line.appendChild(english); |
| 2395 | } |
| 2396 | box.appendChild(line); |
| 2397 | } |
| 2398 | |
| 2399 | function fontChoiceLabel(font) { |
| 2400 | var label = localized(font, "label") || font.id; |
| 2401 | if (String(label).toLowerCase() === String(font.id).toLowerCase()) { |
| 2402 | return String(font.id); |
| 2403 | } |
| 2404 | return label + " · " + font.id; |
| 2405 | } |
| 2406 | |
| 2407 | function typographyFieldLabel(field) { |
| 2408 | if (field === "english") return t("english_font"); |
| 2409 | return t("primary_language_font"); |
| 2410 | } |
| 2411 | |
| 2412 | function typographyChoiceConfigs() { |
| 2413 | var configs = []; |
| 2414 | ["heading", "body"].forEach(function (role) { |
| 2415 | configs.push([role, "primary"]); |
| 2416 | if (!isEnglishProject()) configs.push([role, "english"]); |
| 2417 | }); |
| 2418 | return configs; |
| 2419 | } |
| 2420 | |
| 2421 | function typographySignature(typography) { |
| 2422 | typography = normTypography(typography || {}); |
| 2423 | var fields = isEnglishProject() ? ["primary"] : ["primary", "english"]; |
| 2424 | var values = []; |
| 2425 | ["heading", "body"].forEach(function (role) { |
| 2426 | fields.forEach(function (field) { |
| 2427 | values.push(String((typography[role] || {})[field] || "") |
| 2428 | .trim().toLowerCase()); |
| 2429 | }); |
| 2430 | }); |
| 2431 | return values.join("\u0000"); |
| 2432 | } |
| 2433 | |
| 2434 | function renderTypography(host) { |
| 2435 | var cands = typographyRecommendationCandidates().filter(typographyFamiliesComplete); |
| 2436 | var sec = section(7, "sec_type"); |
| 2437 | var grid = el("div", "font-grid"); |
| 2438 | var customFields = el("div", "custom-typography-fields font-picker-fields"); |
| 2439 | var customInputs = {}; |
| 2440 | var customLegacyNote = el("div", "toggle-desc custom-typography-legacy"); |
| 2441 | var pickerHead = el("div", "font-picker-head"); |
| 2442 | pickerHead.appendChild(el("span", "subfield-label", t("font_selection"))); |
| 2443 | var customStatus = el("span", "font-custom-status", t("customized")); |
| 2444 | pickerHead.appendChild(customStatus); |
| 2445 | customFields.appendChild(pickerHead); |
| 2446 | customFields.appendChild(el("div", "toggle-desc font-picker-hint", t("font_picker_hint"))); |
| 2447 | customFields.appendChild(customLegacyNote); |
| 2448 | |
| 2449 | function syncCustomInputs() { |
| 2450 | var typography = STATE.typography || {}; |
| 2451 | customLegacyNote.textContent = String(typography.custom || "").trim(); |
| 2452 | customLegacyNote.style.display = customLegacyNote.textContent ? "block" : "none"; |
| 2453 | customStatus.style.display = typography.name === "custom" ? "inline-flex" : "none"; |
| 2454 | typographyChoiceConfigs().forEach(function (config) { |
| 2455 | var role = config[0], field = config[1]; |
| 2456 | var control = customInputs[role + "_" + field]; |
| 2457 | if (!control) return; |
| 2458 | var value = String((typography[role] || {})[field] || ""); |
| 2459 | var matchingFont = control.options.filter(function (font) { |
| 2460 | return String(font.id).toLowerCase() === value.toLowerCase(); |
| 2461 | })[0]; |
| 2462 | control.select.value = matchingFont ? matchingFont.id : "__other__"; |
| 2463 | control.other.value = matchingFont ? "" : value; |
| 2464 | control.other.style.display = matchingFont ? "none" : "block"; |
| 2465 | }); |
| 2466 | } |
| 2467 | |
| 2468 | function markCustomTypography() { |
| 2469 | if (!STATE.typography) { |
| 2470 | STATE.typography = { name: "custom", heading: {}, body: {} }; |
| 2471 | } |
| 2472 | STATE.typography.name = "custom"; |
| 2473 | grid.querySelectorAll(".font-card").forEach(function (card) { |
| 2474 | card.classList.remove("selected"); |
| 2475 | }); |
| 2476 | customCard.classList.add("selected"); |
| 2477 | customStatus.style.display = "inline-flex"; |
| 2478 | } |
| 2479 | |
| 2480 | function updateRoleCss(role) { |
| 2481 | if (!STATE.typography[role]) STATE.typography[role] = {}; |
| 2482 | STATE.typography[role].css = derivedFontCss(STATE.typography[role]); |
| 2483 | } |
| 2484 | |
| 2485 | function selectFont(idx) { |
| 2486 | var c = normTypography(cands[idx] || {}); |
| 2487 | var prev = STATE.typography || {}; |
| 2488 | STATE.typography = { |
| 2489 | name: localized(c, "name") || c.name || "", |
| 2490 | heading: c.heading || {}, |
| 2491 | body: c.body || {}, |
| 2492 | // Font cards choose family and character. Reading mode and |
| 2493 | // explicit size inputs own the sizing state. |
| 2494 | body_size: prev.body_size || |
| 2495 | defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose), |
| 2496 | sizes: Object.assign({}, prev.sizes || {}) |
| 2497 | }; |
| 2498 | if (sizeInput) sizeInput.value = STATE.typography.body_size || ""; |
| 2499 | grid.querySelectorAll(".font-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); }); |
| 2500 | syncCustomInputs(); |
| 2501 | refreshSizeInputs(); // fill any role with no value yet; never overwrites existing values |
| 2502 | refreshStylePreview(); |
| 2503 | } |
| 2504 | |
| 2505 | function selectCustomTypography() { |
| 2506 | var prev = STATE.typography || {}; |
| 2507 | STATE.typography = normTypography(prev); |
| 2508 | STATE.typography.name = "custom"; |
| 2509 | STATE.typography.body_size = prev.body_size || ""; |
| 2510 | // Switching font family must not drop any explicit size. |
| 2511 | STATE.typography.sizes = Object.assign({}, prev.sizes || {}); |
| 2512 | markCustomTypography(); |
| 2513 | syncCustomInputs(); |
| 2514 | var firstControl = customInputs.heading_primary; |
| 2515 | if (firstControl) firstControl.select.focus(); |
| 2516 | refreshSizeInputs(); |
| 2517 | refreshStylePreview(); |
| 2518 | } |
| 2519 | |
| 2520 | cands.forEach(function (c, idx) { |
| 2521 | c = normTypography(c); |
| 2522 | var head = c.heading || {}, body = c.body || {}; |
| 2523 | var card = el("div", "font-card"); |
| 2524 | var top = el("div", "font-card-head"); |
| 2525 | top.appendChild(el("span", "font-card-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1)))); |
| 2526 | var metaFields = isEnglishProject() ? ["primary"] : ["primary", "english"]; |
| 2527 | var meta = ["heading", "body"].map(function (role) { |
| 2528 | var slot = role === "heading" ? head : body; |
| 2529 | return t("font_" + role) + " " + metaFields.map(function (field) { |
| 2530 | return typographyFieldLabel(field) + ": " + (slot[field] || "—"); |
| 2531 | }).join(" / "); |
| 2532 | }).join(" · "); |
| 2533 | top.appendChild(el("span", "font-card-meta", meta)); |
| 2534 | card.appendChild(top); |
| 2535 | var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css, "heading"); card.appendChild(hbox); |
| 2536 | var bbox = el("div", "font-sample-body-box"); fontSample(bbox, body, body.css, "body"); card.appendChild(bbox); |
| 2537 | if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note"))); |
| 2538 | card.addEventListener("click", function () { selectFont(idx); }); |
| 2539 | grid.appendChild(card); |
| 2540 | }); |
| 2541 | var customCard = el("div", "font-card font-card-custom"); |
| 2542 | customCard.appendChild(el("div", "font-card-name", t("custom_typography"))); |
| 2543 | customCard.addEventListener("click", selectCustomTypography); |
| 2544 | grid.appendChild(customCard); |
| 2545 | sec.appendChild(grid); |
| 2546 | typographyChoiceConfigs().forEach(function (config) { |
| 2547 | var role = config[0], fieldName = config[1]; |
| 2548 | var field = el("label", "custom-typography-field"); |
| 2549 | field.appendChild(el("span", "hex-cell-label", |
| 2550 | t("font_" + role) + " · " + typographyFieldLabel(fieldName))); |
| 2551 | var options = fontCatalogEntries(fieldName); |
| 2552 | var select = el("select", "font-select"); |
| 2553 | options.forEach(function (font) { |
| 2554 | var option = document.createElement("option"); |
| 2555 | option.value = font.id; |
| 2556 | option.textContent = fontChoiceLabel(font); |
| 2557 | select.appendChild(option); |
| 2558 | }); |
| 2559 | var otherOption = document.createElement("option"); |
| 2560 | otherOption.value = "__other__"; |
| 2561 | otherOption.textContent = t("other_installed_font"); |
| 2562 | select.appendChild(otherOption); |
| 2563 | var input = el("input", "text-input other-font-input"); |
| 2564 | input.type = "text"; |
| 2565 | input.placeholder = t("other_font_placeholder"); |
| 2566 | select.addEventListener("change", function () { |
| 2567 | markCustomTypography(); |
| 2568 | if (!STATE.typography[role]) { |
| 2569 | STATE.typography[role] = { css: "sans-serif" }; |
| 2570 | } |
| 2571 | if (select.value === "__other__") { |
| 2572 | input.value = ""; |
| 2573 | input.style.display = "block"; |
| 2574 | STATE.typography[role][fieldName] = ""; |
| 2575 | input.focus(); |
| 2576 | } else { |
| 2577 | input.value = ""; |
| 2578 | input.style.display = "none"; |
| 2579 | STATE.typography[role][fieldName] = select.value; |
| 2580 | } |
| 2581 | updateRoleCss(role); |
| 2582 | refreshStylePreview(); |
| 2583 | }); |
| 2584 | input.addEventListener("input", function () { |
| 2585 | markCustomTypography(); |
| 2586 | if (!STATE.typography[role]) { |
| 2587 | STATE.typography[role] = { css: "sans-serif" }; |
| 2588 | } |
| 2589 | STATE.typography[role][fieldName] = input.value; |
| 2590 | updateRoleCss(role); |
| 2591 | refreshStylePreview(); |
| 2592 | }); |
| 2593 | customInputs[role + "_" + fieldName] = { |
| 2594 | select: select, |
| 2595 | other: input, |
| 2596 | options: options |
| 2597 | }; |
| 2598 | field.appendChild(select); |
| 2599 | field.appendChild(input); |
| 2600 | customFields.appendChild(field); |
| 2601 | }); |
| 2602 | sec.appendChild(customFields); |
| 2603 | |
| 2604 | var sizeField = el("div", "subfield"); |
| 2605 | sizeField.appendChild(el("div", "subfield-label", t("font_body_size"))); |
| 2606 | sizeField.appendChild(el("div", "toggle-desc body-size-relation", t("body_size_unit_relation"))); |
| 2607 | var sizeRow = el("div", "font-size-row"); |
| 2608 | var sizeInput = el("input", "num-input font-size-input"); |
| 2609 | sizeInput.type = "number"; |
| 2610 | sizeInput.min = "8"; |
| 2611 | sizeInput.max = "96"; |
| 2612 | sizeInput.step = "1"; |
| 2613 | sizeInput.value = (STATE.typography && STATE.typography.body_size) || ""; |
| 2614 | sizeInput.placeholder = isPptCanvas(STATE.canvas) ? "20 / 24 / 32" : "40 / 48"; |
| 2615 | sizeInput.addEventListener("input", function () { |
| 2616 | if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} }; |
| 2617 | STATE.typography.body_size = sizeInput.value; |
| 2618 | TYPOGRAPHY_SIZE_OVERRIDES.body = sizeInput.value !== ""; |
| 2619 | // Body is an explicit local anchor. Recompute only role values the |
| 2620 | // user has not edited; no request leaves the browser. |
| 2621 | syncUnpinnedTypographySizes(false); |
| 2622 | }); |
| 2623 | sizeRow.appendChild(sizeInput); |
| 2624 | sizeRow.appendChild(el("span", "font-size-unit", "px")); |
| 2625 | var sizePtHint = el("div", "toggle-desc body-size-pt"); |
| 2626 | var sizeHint = el("div", "toggle-desc body-size-hint"); |
| 2627 | // PPT body is one fixed px value per reading mode (not a range); non-PPT |
| 2628 | // canvases scale px to canvas height. A manually pinned value is never |
| 2629 | // overwritten by later reading-mode changes. |
| 2630 | // Everything is px — lo/hi are only a sanity envelope for the OOR flag. |
| 2631 | refreshBodySizeHint = function () { |
| 2632 | var txt = t("font_body_size_hint"); |
| 2633 | var lo, hi; |
| 2634 | if (isPptCanvas(STATE.canvas)) { |
| 2635 | var pb = deliveryBodyPx(STATE.delivery_purpose); |
| 2636 | lo = pb.lo; hi = pb.hi; |
| 2637 | txt += " " + t("body_size_hint_purpose").replace("{def}", pb.def); |
| 2638 | } else { |
| 2639 | var h = canvasHeight(STATE.canvas); |
| 2640 | var band = bodySizeRatioBand(STATE.canvas); |
| 2641 | if (h) { |
| 2642 | lo = Math.round(h * band.lo); hi = Math.round(h * band.hi); |
| 2643 | txt += " " + t("body_size_hint_canvas") |
| 2644 | .replace("{lo}", lo).replace("{hi}", hi); |
| 2645 | } |
| 2646 | } |
| 2647 | // Flag (hint only) a value far outside the |
| 2648 | // canvas's usual px range, so an accidental extreme value is visible |
| 2649 | // instead of silently submitting it. |
| 2650 | var cur = parseFloat(STATE.typography && STATE.typography.body_size); |
| 2651 | sizePtHint.textContent = isFinite(cur) |
| 2652 | ? t("body_size_pt_hint").replace("{pt}", formatPtFromPx(cur)) |
| 2653 | : ""; |
| 2654 | if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) { |
| 2655 | txt += " " + t("body_size_hint_oor"); |
| 2656 | } |
| 2657 | sizeHint.textContent = txt; |
| 2658 | }; |
| 2659 | refreshBodySizeHint(); |
| 2660 | sizeField.appendChild(sizeRow); |
| 2661 | sizeField.appendChild(sizePtHint); |
| 2662 | sizeField.appendChild(sizeHint); |
| 2663 | |
| 2664 | // Reading mode and typography are both confirmed in Stage 2. Its |
| 2665 | // compatibility key remains delivery_purpose; the dependency is a local |
| 2666 | // deterministic update, not a second Stage-2 recommendation. |
| 2667 | sec.appendChild(sizeField); |
| 2668 | |
| 2669 | // Per-role size override (parallel to color's per-role HEX override): the |
| 2670 | // ramp derives title / subtitle / annotation from body, but the user may |
| 2671 | // set each explicitly. Values are px (the system's only unit). |
| 2672 | var sizeOverride = el("div", "hex-override"); |
| 2673 | sizeOverride.appendChild(el("div", "subfield-label", t("size_override"))); |
| 2674 | var srow = el("div", "hex-row"); |
| 2675 | var sizeInputs = {}; |
| 2676 | var sizePtHints = {}; |
| 2677 | function refreshRolePtHint(role) { |
| 2678 | var input = sizeInputs[role]; |
| 2679 | var hint = sizePtHints[role]; |
| 2680 | if (!input || !hint) return; |
| 2681 | var pt = formatPtFromPx(input.value); |
| 2682 | hint.textContent = pt ? t("role_size_pt_hint").replace("{pt}", pt) : ""; |
| 2683 | } |
| 2684 | SIZE_ROLES.forEach(function (role) { |
| 2685 | var wrap = el("div", "hex-cell"); |
| 2686 | wrap.appendChild(el("div", "hex-cell-label", t("size_role_" + role))); |
| 2687 | var inputLine = el("div", "role-size-line"); |
| 2688 | var inp = document.createElement("input"); |
| 2689 | inp.type = "number"; inp.min = "6"; inp.max = "200"; inp.step = "1"; |
| 2690 | inp.addEventListener("input", function () { |
| 2691 | if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} }; |
| 2692 | if (!STATE.typography.sizes) STATE.typography.sizes = {}; |
| 2693 | STATE.typography.sizes[role] = inp.value; |
| 2694 | TYPOGRAPHY_SIZE_OVERRIDES[role] = true; |
| 2695 | refreshRolePtHint(role); |
| 2696 | refreshStylePreview(); |
| 2697 | }); |
| 2698 | sizeInputs[role] = inp; |
| 2699 | inputLine.appendChild(inp); |
| 2700 | inputLine.appendChild(el("span", "font-size-unit", "px")); |
| 2701 | wrap.appendChild(inputLine); |
| 2702 | sizePtHints[role] = el("div", "role-size-pt"); |
| 2703 | wrap.appendChild(sizePtHints[role]); |
| 2704 | srow.appendChild(wrap); |
| 2705 | }); |
| 2706 | sizeOverride.appendChild(srow); |
| 2707 | sec.appendChild(sizeOverride); |
| 2708 | |
| 2709 | // Reflect state into the controls. Derivation itself happens only through |
| 2710 | // syncUnpinnedTypographySizes(); a re-render preserves the visible state. |
| 2711 | refreshSizeInputs = function () { |
| 2712 | if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} }; |
| 2713 | if (!STATE.typography.sizes) STATE.typography.sizes = {}; |
| 2714 | sizeInput.value = STATE.typography.body_size || ""; |
| 2715 | var bodyVal = parseFloat(STATE.typography.body_size) || |
| 2716 | (isPptCanvas(STATE.canvas) ? deliveryBodyPx(STATE.delivery_purpose).def : 40); |
| 2717 | SIZE_ROLES.forEach(function (role) { |
| 2718 | var cur = STATE.typography.sizes[role]; |
| 2719 | var hasVal = cur !== undefined && cur !== null && cur !== ""; |
| 2720 | if (!hasVal) STATE.typography.sizes[role] = deriveSize(role, bodyVal); |
| 2721 | if (sizeInputs[role]) sizeInputs[role].value = STATE.typography.sizes[role]; |
| 2722 | refreshRolePtHint(role); |
| 2723 | }); |
| 2724 | }; |
| 2725 | refreshSizeInputs(); |
| 2726 | |
| 2727 | host.appendChild(sec); |
| 2728 | |
| 2729 | var nameMatch = -1; |
| 2730 | var signatureMatch = -1; |
| 2731 | var stateSignature = typographySignature(STATE.typography || {}); |
| 2732 | if (STATE.typography && STATE.typography.name !== "custom") cands.forEach(function (c, i) { |
| 2733 | var sameName = [localized(c, "name"), c.name_zh, c.name_en, c.name_ja] |
| 2734 | .some(function (name) { return name === STATE.typography.name; }); |
| 2735 | if (!sameName && c.name && typeof c.name === "object") { |
| 2736 | sameName = Object.keys(c.name).some(function (key) { |
| 2737 | return c.name[key] === STATE.typography.name; |
| 2738 | }); |
| 2739 | } |
| 2740 | if (sameName && nameMatch < 0) nameMatch = i; |
| 2741 | if (typographySignature(c) === stateSignature && signatureMatch < 0) signatureMatch = i; |
| 2742 | }); |
| 2743 | // Names preserve the selected candidate when several recommendations share |
| 2744 | // one font stack. Signature matching is only a first-match legacy fallback. |
| 2745 | var selIdx = nameMatch >= 0 ? nameMatch : signatureMatch; |
| 2746 | if (selIdx >= 0) selectFont(selIdx); |
| 2747 | else if (STATE.typography && STATE.typography.name === "custom") { |
| 2748 | ["heading", "body"].forEach(function (role) { |
| 2749 | if (!STATE.typography[role]) STATE.typography[role] = {}; |
| 2750 | if (!STATE.typography[role].css) updateRoleCss(role); |
| 2751 | }); |
| 2752 | customCard.classList.add("selected"); |
| 2753 | syncCustomInputs(); |
| 2754 | } else { |
| 2755 | syncCustomInputs(); |
| 2756 | } |
| 2757 | } |
| 2758 | |
| 2759 | function renderFormulaPolicy(host) { |
| 2760 | var sec = section("F", "formula_policy"); |
| 2761 | enumField(sec, CAT.formula_policy, recOrFirst("formula_policy", CAT.formula_policy), |
| 2762 | function () { return STATE.formula_policy; }, function (v) { STATE.formula_policy = v; }); |
| 2763 | host.appendChild(sec); |
| 2764 | } |
| 2765 | |
| 2766 | // Combined color + typography + icon preview — not a separate confirmation, just a |
| 2767 | // live "overall impression" of the style choices made above. Kept |
| 2768 | // deliberately abstract (a style chip, not a slide layout); page layout |
| 2769 | // preview is the live-preview server's job (Step 6). |
| 2770 | function renderStylePreview(host) { |
| 2771 | var wrap = el("div", "style-preview"); |
| 2772 | var label = el("div", "style-preview-label"); |
| 2773 | label.appendChild(el("span", "spl-title", t("style_preview_label"))); |
| 2774 | // The "rough feel, not a slide layout" caveat sits in the label in the |
| 2775 | // UI font — never rendered in the candidate's body font, so it cannot |
| 2776 | // pose as sample content. |
| 2777 | label.appendChild(el("span", "spl-note", t("style_preview_body"))); |
| 2778 | wrap.appendChild(label); |
| 2779 | var card = el("div", "style-preview-card"); |
| 2780 | var textcol = el("div", "sp-textcol"); |
| 2781 | var title = el("div", "sp-title"); |
| 2782 | var titlePrimary = el("span", "sp-title-primary"); |
| 2783 | var titleEnglish = el("span", "sp-title-english"); |
| 2784 | title.appendChild(titlePrimary); title.appendChild(titleEnglish); |
| 2785 | var bodyRow = el("div", "sp-body"); |
| 2786 | var accentBar = el("span", "sp-accent-bar"); |
| 2787 | var bodyWrap = el("div", "sp-body-wrap"); |
| 2788 | var bodyPrimary = el("span", "sp-body-primary"); |
| 2789 | var bodyEnglish = el("span", "sp-body-english"); |
| 2790 | setEnglishLanguageAttributes(titleEnglish); |
| 2791 | setEnglishLanguageAttributes(bodyEnglish); |
| 2792 | bodyWrap.appendChild(bodyPrimary); bodyWrap.appendChild(bodyEnglish); |
| 2793 | bodyRow.appendChild(accentBar); bodyRow.appendChild(bodyWrap); |
| 2794 | textcol.appendChild(title); textcol.appendChild(bodyRow); |
| 2795 | var content = el("div", "sp-content"); |
| 2796 | var chip = el("div", "sp-chip"); |
| 2797 | var chipDot = el("span", "sp-chip-dot"); |
| 2798 | var chipLabel = el("span", "sp-chip-label"); |
| 2799 | chip.appendChild(chipDot); chip.appendChild(chipLabel); |
| 2800 | card.appendChild(textcol); card.appendChild(content); card.appendChild(chip); |
| 2801 | wrap.appendChild(card); |
| 2802 | host.appendChild(wrap); |
| 2803 | // The strip is mounted inside the top bar on Stage 2, so it stays visible |
| 2804 | // while the center form scrolls. |
| 2805 | wrap.style.top = "0px"; |
| 2806 | |
| 2807 | function paint() { |
| 2808 | var pal = (STATE.color && STATE.color.palette) || {}; |
| 2809 | var typ = STATE.typography || {}; |
| 2810 | var head = typ.heading || {}, body = typ.body || {}; |
| 2811 | var bg = hexOr(pal.background, "#ffffff"); |
| 2812 | var sbg = hexOr(pal.secondary_bg, bg); |
| 2813 | var pri = hexOr(pal.primary, "#1a3a6b"); |
| 2814 | var acc = hexOr(pal.accent, pri); |
| 2815 | var sacc = hexOr(pal.secondary_accent, acc); |
| 2816 | var txt = hexOr(pal.body_text, "#1d2430"); |
| 2817 | // body_size is px everywhere — preview it directly, no conversion. |
| 2818 | var rawSize = parseFloat(typ.body_size) || (isPptCanvas(STATE.canvas) ? 24 : 18); |
| 2819 | var bodyPx = Math.max(12, Math.min(34, rawSize)); |
| 2820 | var headPrimaryStack = previewFontStack(head.primary, head.css); |
| 2821 | var headEnglishStack = previewFontStack(head.english, head.css); |
| 2822 | var bodyPrimaryStack = previewFontStack(body.primary, body.css); |
| 2823 | var bodyEnglishStack = previewFontStack(body.english, body.css); |
| 2824 | |
| 2825 | card.style.background = bg; |
| 2826 | titlePrimary.textContent = sampleText("heading", "primary"); |
| 2827 | setUiLanguageAttributes(titlePrimary); |
| 2828 | titleEnglish.textContent = sampleText("heading", "english"); |
| 2829 | title.style.color = pri; |
| 2830 | title.style.fontSize = Math.round(bodyPx * 1.7) + "px"; |
| 2831 | titlePrimary.style.fontFamily = headPrimaryStack || ""; |
| 2832 | titleEnglish.style.fontFamily = headEnglishStack || ""; |
| 2833 | bodyPrimary.textContent = sampleText("body", "primary"); |
| 2834 | setUiLanguageAttributes(bodyPrimary); |
| 2835 | bodyEnglish.textContent = sampleText("body", "english"); |
| 2836 | bodyWrap.style.color = txt; |
| 2837 | bodyWrap.style.fontSize = bodyPx + "px"; |
| 2838 | bodyPrimary.style.fontFamily = bodyPrimaryStack || ""; |
| 2839 | bodyEnglish.style.fontFamily = bodyEnglishStack || ""; |
| 2840 | titleEnglish.style.display = isEnglishProject() ? "none" : ""; |
| 2841 | bodyEnglish.style.display = isEnglishProject() ? "none" : ""; |
| 2842 | accentBar.style.background = acc; |
| 2843 | content.style.color = txt; |
| 2844 | content.style.fontFamily = bodyPrimaryStack || ""; |
| 2845 | content.innerHTML = stylePreviewContentMarkup(STATE.icons); |
| 2846 | setUiLanguageAttributes(content); |
| 2847 | chip.style.background = sbg; |
| 2848 | chipDot.style.background = sacc; |
| 2849 | chipLabel.textContent = t("role_secondary_bg"); |
| 2850 | chipLabel.style.color = txt; |
| 2851 | chipLabel.style.fontFamily = bodyPrimaryStack || ""; |
| 2852 | } |
| 2853 | refreshStylePreview = paint; |
| 2854 | paint(); |
| 2855 | } |
| 2856 | |
| 2857 | function renderImageStrategyPreview(host) { |
| 2858 | var wrap = el("div", "style-preview image-strategy-left-preview"); |
| 2859 | var label = el("div", "style-preview-label"); |
| 2860 | label.appendChild(el("span", "spl-title", t("image_strategy"))); |
| 2861 | label.appendChild(el("span", "spl-note", t("image_strategy_reference_hint"))); |
| 2862 | wrap.appendChild(label); |
| 2863 | var card = el("div", "style-preview-card image-strategy-preview-card"); |
| 2864 | var visual = el("div", "image-strategy-preview-visual"); |
| 2865 | var copy = el("div", "direction-preview-copy"); |
| 2866 | var title = el("div", "direction-preview-title"); |
| 2867 | var desc = el("div", "direction-preview-desc"); |
| 2868 | copy.appendChild(title); |
| 2869 | copy.appendChild(desc); |
| 2870 | card.appendChild(visual); |
| 2871 | card.appendChild(copy); |
| 2872 | wrap.appendChild(card); |
| 2873 | host.appendChild(wrap); |
| 2874 | function paint() { |
| 2875 | var show = needsGeneratedImagesForUsage(STATE.image_usage); |
| 2876 | wrap.hidden = !show; |
| 2877 | if (!show) return; |
| 2878 | var strategy = STATE.image_strategy || {}; |
| 2879 | visual.innerHTML = ""; |
| 2880 | var row = appendImageStrategyPreviews(visual, strategy); |
| 2881 | visual.classList.toggle("image-strategy-preview-empty", !row); |
| 2882 | if (!row) visual.appendChild(el("div", "toggle-desc", |
| 2883 | strategy.rendering === "custom" ? t("image_strategy_no_reference") : |
| 2884 | t("image_strategy_select_placeholder"))); |
| 2885 | title.textContent = strategy.name || |
| 2886 | (strategy.rendering === "custom" ? t("image_strategy_ai_custom") : |
| 2887 | (strategy.rendering ? comparisonValueLabel("rendering", strategy.rendering) : |
| 2888 | t("image_strategy_select_placeholder"))); |
| 2889 | var parts = []; |
| 2890 | if (strategy.rendering) { |
| 2891 | parts.push(t("image_strategy_rendering") + ": " + |
| 2892 | comparisonValueLabel("rendering", strategy.rendering)); |
| 2893 | } |
| 2894 | if (strategy.visual) parts.push(t("image_strategy_visual") + ": " + strategy.visual); |
| 2895 | if (strategy.mood) parts.push(t("image_strategy_mood") + ": " + strategy.mood); |
| 2896 | if (strategy.behavior) parts.push(strategy.behavior); |
| 2897 | desc.textContent = parts.join(" · ") || t("image_strategy_reference_hint"); |
| 2898 | } |
| 2899 | refreshImageStrategyPreview = paint; |
| 2900 | paint(); |
| 2901 | } |
| 2902 | |
| 2903 | function findCatalogOption(list, id) { |
| 2904 | var flat = []; |
| 2905 | (list || []).forEach(function (item) { |
| 2906 | if (item && item.items) flat = flat.concat(item.items); |
| 2907 | else flat.push(item); |
| 2908 | }); |
| 2909 | for (var i = 0; i < flat.length; i += 1) { |
| 2910 | if (flat[i] && flat[i].id === id) return flat[i]; |
| 2911 | } |
| 2912 | return null; |
| 2913 | } |
| 2914 | |
| 2915 | function stylePreviewRows() { |
| 2916 | return [ |
| 2917 | [t("preview_point_1_title"), t("preview_point_1_text")], |
| 2918 | [t("preview_point_2_title"), t("preview_point_2_text")], |
| 2919 | [t("preview_point_3_title"), t("preview_point_3_text")] |
| 2920 | ]; |
| 2921 | } |
| 2922 | |
| 2923 | function stylePreviewContentMarkup(iconId) { |
| 2924 | var rows = stylePreviewRows(); |
| 2925 | var icons = stylePreviewIconSamples(iconId, rows.length); |
| 2926 | return rows.map(function (row, idx) { |
| 2927 | return '<div class="sp-content-row">' + |
| 2928 | '<span class="sp-content-icon">' + icons[idx] + '</span>' + |
| 2929 | '<span class="sp-content-copy"><b>' + escapeHtml(row[0]) + '</b><small>' + escapeHtml(row[1]) + '</small></span>' + |
| 2930 | '</div>'; |
| 2931 | }).join(""); |
| 2932 | } |
| 2933 | |
| 2934 | function stylePreviewIconSamples(iconId, count) { |
| 2935 | if (iconId === "emoji") return ["📊", "💡", "✅"].slice(0, count).map(function (x) { |
| 2936 | return '<span class="sp-icon-emoji">' + x + '</span>'; |
| 2937 | }); |
| 2938 | if (iconId === "none") return new Array(count).fill('<span class="sp-icon-none-dot"></span>'); |
| 2939 | var samples = ICON_PREVIEWS[iconId] || []; |
| 2940 | var out = []; |
| 2941 | for (var i = 0; i < count; i += 1) { |
| 2942 | var sample = samples[i % Math.max(samples.length, 1)]; |
| 2943 | out.push(sample ? '<span class="sp-icon-mark" title="' + escapeHtml(sample.name || "") + '">' + (sample.svg || "") + '</span>' : '<span class="sp-icon-none-dot"></span>'); |
| 2944 | } |
| 2945 | return out; |
| 2946 | } |
| 2947 | |
| 2948 | function renderImageDirection(host) { |
| 2949 | var sec = section(8, "sec_images"); |
| 2950 | var usageChips = el("div", "chips"); |
| 2951 | var usageNote = el("div", "subfield"); |
| 2952 | usageNote.appendChild(el("div", "subfield-label", t("image_usage_notes"))); |
| 2953 | var usageNoteInput = el("textarea", "text-input image-usage-notes-input"); |
| 2954 | setNaturalInputDirection(usageNoteInput); |
| 2955 | usageNoteInput.placeholder = t("image_usage_notes_placeholder"); |
| 2956 | usageNoteInput.value = STATE.image_notes || ""; |
| 2957 | usageNoteInput.addEventListener("input", function () { STATE.image_notes = usageNoteInput.value; }); |
| 2958 | usageNote.appendChild(usageNoteInput); |
| 2959 | |
| 2960 | var strategySub = el("div", "subfield image-strategy-subfield"); |
| 2961 | strategySub.appendChild(el("div", "subfield-label", t("image_strategy"))); |
| 2962 | strategySub.appendChild(el("div", "toggle-desc", t("image_strategy_reference_hint"))); |
| 2963 | var recommendedStrategies = imageStrategyRecommendationCandidates(); |
| 2964 | var strategyCands = imageStrategySelectableCandidates(); |
| 2965 | var hasRecommendedStrategies = recommendedStrategies.length > 0; |
| 2966 | var customStrategy = STATE.image_strategy_custom || imageStrategyCustomCandidate(); |
| 2967 | var presetPicker = el("div", "image-strategy-picker"); |
| 2968 | var presetSelect = el("select", "font-select image-strategy-select"); |
| 2969 | var customCard = null; |
| 2970 | var syncCustomStrategy = function () {}; |
| 2971 | var selectCustomImageStrategy = function () {}; |
| 2972 | |
| 2973 | function selectImageStrategy(idx) { |
| 2974 | if (!strategyCands[idx]) return; |
| 2975 | STATE.image_strategy = normalizedImageStrategy(strategyCands[idx]); |
| 2976 | presetSelect.value = String(idx); |
| 2977 | if (customCard) customCard.classList.remove("selected"); |
| 2978 | syncCustomStrategy(false); |
| 2979 | refreshImageStrategyPreview(); |
| 2980 | } |
| 2981 | |
| 2982 | function imageStrategyCandidateIndex(strategy) { |
| 2983 | if (!strategy) return -1; |
| 2984 | for (var i = 0; i < strategyCands.length; i += 1) { |
| 2985 | if (strategyCands[i] && strategyCands[i].rendering === strategy.rendering) return i; |
| 2986 | } |
| 2987 | return -1; |
| 2988 | } |
| 2989 | |
| 2990 | function strategyOptionLabel(candidate, idx) { |
| 2991 | var renderingLabel = comparisonValueLabel("rendering", candidate.rendering); |
| 2992 | var candidateName = localized(candidate, "name") || renderingLabel || |
| 2993 | (t("option_prefix") + " " + (idx + 1)); |
| 2994 | return candidateName !== renderingLabel ? |
| 2995 | candidateName + " · " + renderingLabel : candidateName; |
| 2996 | } |
| 2997 | |
| 2998 | function appendStrategyOptions(label, start, end) { |
| 2999 | if (start >= end) return; |
| 3000 | var group = document.createElement("optgroup"); |
| 3001 | group.label = label; |
| 3002 | for (var idx = start; idx < end; idx += 1) { |
| 3003 | var option = document.createElement("option"); |
| 3004 | option.value = String(idx); |
| 3005 | option.textContent = strategyOptionLabel(strategyCands[idx], idx); |
| 3006 | group.appendChild(option); |
| 3007 | } |
| 3008 | presetSelect.appendChild(group); |
| 3009 | } |
| 3010 | |
| 3011 | var placeholderOption = document.createElement("option"); |
| 3012 | placeholderOption.value = ""; |
| 3013 | placeholderOption.textContent = t("image_strategy_select_placeholder"); |
| 3014 | placeholderOption.disabled = true; |
| 3015 | placeholderOption.selected = true; |
| 3016 | presetSelect.appendChild(placeholderOption); |
| 3017 | appendStrategyOptions(t("image_strategy_recommended_group"), 0, recommendedStrategies.length); |
| 3018 | appendStrategyOptions(t("image_strategy_all_group"), recommendedStrategies.length, strategyCands.length); |
| 3019 | presetSelect.disabled = !strategyCands.length; |
| 3020 | presetSelect.addEventListener("change", function () { |
| 3021 | selectImageStrategy(parseInt(presetSelect.value, 10)); |
| 3022 | }); |
| 3023 | presetPicker.appendChild(presetSelect); |
| 3024 | if (!strategyCands.length) { |
| 3025 | presetPicker.appendChild(el("div", "toggle-desc", t("image_strategy_empty"))); |
| 3026 | } |
| 3027 | strategySub.appendChild(presetPicker); |
| 3028 | |
| 3029 | if (customStrategy) { |
| 3030 | customStrategy = normalizedImageStrategy(customStrategy); |
| 3031 | STATE.image_strategy_custom = customStrategy; |
| 3032 | customCard = el("div", "font-card image-strategy-custom-card ai-custom-candidate"); |
| 3033 | var customTop = el("div", "font-card-head"); |
| 3034 | customTop.appendChild(el("span", "font-card-name", |
| 3035 | customStrategy.name || t("image_strategy_ai_custom"))); |
| 3036 | customTop.appendChild(el("span", "font-card-meta", t("image_strategy_ai_custom_desc"))); |
| 3037 | customCard.appendChild(customTop); |
| 3038 | [ |
| 3039 | ["image_strategy_visual", customStrategy.visual], |
| 3040 | ["image_strategy_mood", customStrategy.mood] |
| 3041 | ].forEach(function (row) { |
| 3042 | if (row[1]) customCard.appendChild(el("div", "color-note", t(row[0]) + ":" + row[1])); |
| 3043 | }); |
| 3044 | var customCopy = el("div", "ai-custom-candidate-copy", |
| 3045 | customStrategy.behavior || t("image_strategy_custom_placeholder")); |
| 3046 | customCard.appendChild(customCopy); |
| 3047 | var customInput = el("textarea", "text-input image-strategy-custom-input"); |
| 3048 | setNaturalInputDirection(customInput); |
| 3049 | customInput.rows = 4; |
| 3050 | customInput.placeholder = t("image_strategy_custom_placeholder"); |
| 3051 | customInput.value = customStrategy.behavior; |
| 3052 | customInput.style.display = "none"; |
| 3053 | customCard.appendChild(customInput); |
| 3054 | |
| 3055 | syncCustomStrategy = function (selected) { |
| 3056 | customCopy.textContent = customStrategy.behavior || t("image_strategy_custom_placeholder"); |
| 3057 | customCopy.style.display = selected ? "none" : "block"; |
| 3058 | customInput.style.display = selected ? "block" : "none"; |
| 3059 | if (selected && customInput.value !== customStrategy.behavior) { |
| 3060 | customInput.value = customStrategy.behavior || ""; |
| 3061 | } |
| 3062 | }; |
| 3063 | |
| 3064 | selectCustomImageStrategy = function () { |
| 3065 | STATE.image_strategy = normalizedImageStrategy(customStrategy); |
| 3066 | presetSelect.value = ""; |
| 3067 | customCard.classList.add("selected"); |
| 3068 | syncCustomStrategy(true); |
| 3069 | refreshImageStrategyPreview(); |
| 3070 | }; |
| 3071 | |
| 3072 | customInput.addEventListener("click", function (event) { event.stopPropagation(); }); |
| 3073 | customInput.addEventListener("input", function () { |
| 3074 | customStrategy.behavior = customInput.value; |
| 3075 | STATE.image_strategy_custom = normalizedImageStrategy(customStrategy); |
| 3076 | selectCustomImageStrategy(); |
| 3077 | }); |
| 3078 | customCard.addEventListener("click", function () { |
| 3079 | selectCustomImageStrategy(); |
| 3080 | customInput.focus(); |
| 3081 | }); |
| 3082 | syncCustomStrategy(false); |
| 3083 | strategySub.appendChild(customCard); |
| 3084 | } |
| 3085 | |
| 3086 | var recommendedIds = selectedImageUsageIds(recValue("image_usage")); |
| 3087 | if (!recommendedIds.length) recommendedIds = [defaultImageUsageId()]; |
| 3088 | var usageChipById = {}; |
| 3089 | function refreshUsageChips() { |
| 3090 | Object.keys(usageChipById).forEach(function (id) { |
| 3091 | usageChipById[id].classList.toggle("selected", STATE.image_usage.indexOf(id) >= 0); |
| 3092 | }); |
| 3093 | var noImages = STATE.image_usage.indexOf("none") >= 0; |
| 3094 | usageNote.style.display = noImages ? "none" : "block"; |
| 3095 | strategySub.style.display = needsGeneratedImagesForUsage(STATE.image_usage) ? "block" : "none"; |
| 3096 | refreshImageStrategyPreview(); |
| 3097 | } |
| 3098 | function toggleImageUsage(id) { |
| 3099 | var current = STATE.image_usage.slice(); |
| 3100 | if (id === "none") { |
| 3101 | current = current.indexOf("none") >= 0 ? [] : ["none"]; |
| 3102 | } else { |
| 3103 | current = current.filter(function (item) { return item !== "none"; }); |
| 3104 | if (current.indexOf(id) >= 0) { |
| 3105 | current = current.filter(function (item) { return item !== id; }); |
| 3106 | } else { |
| 3107 | current.push(id); |
| 3108 | } |
| 3109 | } |
| 3110 | STATE.image_usage = current; |
| 3111 | refreshUsageChips(); |
| 3112 | refreshImageProduction(); |
| 3113 | } |
| 3114 | (CAT.image_usage || []).forEach(function (option) { |
| 3115 | var label = optionLabel(option); |
| 3116 | var desc = optionDesc(option); |
| 3117 | if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc; |
| 3118 | var chip = el("div", "chip"); |
| 3119 | chip.appendChild(el("span", "chip-text", label)); |
| 3120 | if (recommendedIds.indexOf(option.id) >= 0) { |
| 3121 | chip.classList.add("recommended"); |
| 3122 | chip.appendChild(el("span", "rec-badge", "★ " + t("recommended"))); |
| 3123 | } |
| 3124 | chip.addEventListener("click", function () { toggleImageUsage(option.id); }); |
| 3125 | usageChipById[option.id] = chip; |
| 3126 | usageChips.appendChild(chip); |
| 3127 | }); |
| 3128 | sec.appendChild(usageChips); |
| 3129 | sec.appendChild(usageNote); |
| 3130 | sec.appendChild(strategySub); |
| 3131 | |
| 3132 | if (STATE.image_strategy && STATE.image_strategy.rendering === "custom" && customCard) { |
| 3133 | selectCustomImageStrategy(); |
| 3134 | } else if (STATE.image_strategy && imageStrategyCandidateIndex(STATE.image_strategy) >= 0) { |
| 3135 | selectImageStrategy(imageStrategyCandidateIndex(STATE.image_strategy)); |
| 3136 | } else if (needsGeneratedImagesForUsage(STATE.image_usage) && |
| 3137 | hasRecommendedStrategies && strategyCands.length) { |
| 3138 | selectImageStrategy(imageStrategySelectedIndex()); |
| 3139 | } |
| 3140 | refreshUsageChips(); |
| 3141 | host.appendChild(sec); |
| 3142 | } |
| 3143 | |
| 3144 | function renderImageProduction(host) { |
| 3145 | var sec = section("P", "sec_image_production", t("image_production_hint")); |
| 3146 | var body = el("div", "image-production-body"); |
| 3147 | sec.appendChild(body); |
| 3148 | refreshImageProduction = function () { |
| 3149 | body.innerHTML = ""; |
| 3150 | var summary = el("div", "subfield"); |
| 3151 | summary.appendChild(el("div", "subfield-label", t("image_source_summary"))); |
| 3152 | var chips = el("div", "chips locked-summary-chips"); |
| 3153 | (STATE.image_usage || []).forEach(function (id) { |
| 3154 | var option = findCatalogOption(CAT.image_usage, id); |
| 3155 | chips.appendChild(el("div", "chip selected locked-summary-chip", |
| 3156 | option ? optionLabel(option) : humanizeId(id))); |
| 3157 | }); |
| 3158 | summary.appendChild(chips); |
| 3159 | body.appendChild(summary); |
| 3160 | if (needsGeneratedImagesForUsage(STATE.image_usage)) { |
| 3161 | var pathField = el("div", "subfield"); |
| 3162 | pathField.appendChild(el("div", "subfield-label", t("image_ai_path"))); |
| 3163 | enumField(pathField, CAT.image_ai_path, recOrFirst("image_ai_path", CAT.image_ai_path), |
| 3164 | function () { return STATE.image_ai_path; }, function (value) { STATE.image_ai_path = value; }); |
| 3165 | body.appendChild(pathField); |
| 3166 | } |
| 3167 | }; |
| 3168 | refreshImageProduction(); |
| 3169 | host.appendChild(sec); |
| 3170 | } |
| 3171 | |
| 3172 | function renderMode(host) { |
| 3173 | var sec = section("M", "sec_mode"); |
| 3174 | function refresh() { |
| 3175 | setSectionNote(sec, STATE.generation_mode === "split" ? t("mode_split_desc") : t("mode_continuous_desc")); |
| 3176 | } |
| 3177 | enumField(sec, CAT.generation_mode, recOrFirst("generation_mode", CAT.generation_mode), |
| 3178 | function () { return STATE.generation_mode; }, function (v) { STATE.generation_mode = v; refresh(); }); |
| 3179 | refresh(); |
| 3180 | host.appendChild(sec); |
| 3181 | } |
| 3182 | |
| 3183 | function renderProactiveExecution(host) { |
| 3184 | var sec = section("A", "sec_proactive_execution", t("proactive_execution_hint")); |
| 3185 | var opts = [{ id: "off", label: t("off_default") }, { id: "on", label: t("on") }]; |
| 3186 | |
| 3187 | function addToggle(labelKey, descKey, stateKey, fallback) { |
| 3188 | var field = el("div", "subfield"); |
| 3189 | field.appendChild(el("div", "subfield-label", t(labelKey))); |
| 3190 | field.appendChild(el("div", "toggle-desc", t(descKey))); |
| 3191 | var recommended = booleanRecommendation(stateKey, fallback); |
| 3192 | enumField(field, opts, recommended ? "on" : "off", |
| 3193 | function () { return STATE[stateKey] ? "on" : "off"; }, |
| 3194 | function (value) { |
| 3195 | STATE[stateKey] = value === "on"; |
| 3196 | }); |
| 3197 | sec.appendChild(field); |
| 3198 | } |
| 3199 | |
| 3200 | addToggle( |
| 3201 | "proactive_speaker_notes", |
| 3202 | "proactive_speaker_notes_desc", |
| 3203 | "proactive_speaker_notes", |
| 3204 | true |
| 3205 | ); |
| 3206 | addToggle( |
| 3207 | "proactive_custom_animations", |
| 3208 | "proactive_custom_animations_desc", |
| 3209 | "proactive_custom_animations", |
| 3210 | false |
| 3211 | ); |
| 3212 | addToggle( |
| 3213 | "proactive_narration_audio", |
| 3214 | "proactive_narration_audio_desc", |
| 3215 | "proactive_narration_audio", |
| 3216 | false |
| 3217 | ); |
| 3218 | host.appendChild(sec); |
| 3219 | } |
| 3220 | |
| 3221 | function renderRefine(host) { |
| 3222 | var sec = section("R", "sec_refine"); |
| 3223 | var opts = [{ id: "off", label: t("off_default") }, { id: "on", label: t("on") }]; |
| 3224 | function refresh() { |
| 3225 | setSectionNote(sec, STATE.refine_spec ? t("refine_on_desc") : t("refine_off_desc")); |
| 3226 | } |
| 3227 | enumField(sec, opts, STATE.refine_spec ? "on" : "off", |
| 3228 | function () { return STATE.refine_spec ? "on" : "off"; }, |
| 3229 | function (v) { STATE.refine_spec = (v === "on"); refresh(); }); |
| 3230 | refresh(); |
| 3231 | host.appendChild(sec); |
| 3232 | } |
| 3233 | |
| 3234 | // Two-stage confirmation: communication contract, then complete final plan. |
| 3235 | var STAGE = 1; |
| 3236 | |
| 3237 | function stageNumber(data) { |
| 3238 | var raw = data && data.stage; |
| 3239 | raw = String(raw == null ? "" : raw).toLowerCase(); |
| 3240 | if (raw === "stage1") return 1; |
| 3241 | if (raw === "stage2") return 2; |
| 3242 | return 0; |
| 3243 | } |
| 3244 | |
| 3245 | function stageTitle(stage) { |
| 3246 | if (stage === 1) return t("stage_anchors"); |
| 3247 | if (stage === 2) return t("stage_final_plan"); |
| 3248 | return t("page_title"); |
| 3249 | } |
| 3250 | |
| 3251 | function renderForStage(stage) { |
| 3252 | var host = document.getElementById("sections"); |
| 3253 | host.innerHTML = ""; |
| 3254 | _secCounter = 0; |
| 3255 | var heading = document.querySelector("#topbar .topbar-titles h1"); |
| 3256 | if (heading) heading.textContent = stageTitle(stage); |
| 3257 | var hint = document.getElementById("topbar-hint"); |
| 3258 | if (hint) hint.textContent = stage === 1 ? t("stage1_current_value_hint") : t("topbar_hint"); |
| 3259 | // Detach the previous preview's repaint closures before the sections |
| 3260 | // re-render: color/typography auto-select would otherwise call them and |
| 3261 | // write to now-detached nodes until renderStylePreview remounts them. |
| 3262 | refreshStylePreview = function () {}; |
| 3263 | refreshImageStrategyPreview = function () {}; |
| 3264 | refreshImageProduction = function () {}; |
| 3265 | refreshBodySizeHint = function () {}; |
| 3266 | refreshSizeInputs = function () {}; |
| 3267 | var previewHost = document.getElementById("topbar-preview"); |
| 3268 | if (previewHost) previewHost.innerHTML = ""; |
| 3269 | if (stage === 1) { |
| 3270 | // Stage 1 closes the communication contract and its design basis in |
| 3271 | // one submission. Detailed template controls stay hidden until the |
| 3272 | // user explicitly chooses template-backed design. |
| 3273 | renderCommunication(host); |
| 3274 | renderDelivery(host); |
| 3275 | renderCanvas(host); |
| 3276 | renderTemplateSelection(host); |
| 3277 | } else if (stage === 2) { |
| 3278 | if (previewHost) renderStylePreview(previewHost); |
| 3279 | if (previewHost) renderImageStrategyPreview(previewHost); |
| 3280 | // Stage 2 confirms one coherent deck solution. Bundles provide a |
| 3281 | // coordinated starting point; individual controls remain editable. |
| 3282 | renderTemplateApplication(host); |
| 3283 | renderDesignDirections(host); |
| 3284 | renderNarrativeDirection(host); |
| 3285 | renderVisualDirection(host); |
| 3286 | renderReadingMode(host); |
| 3287 | renderPages(host); |
| 3288 | var styleGroup = el("div", "style-group"); |
| 3289 | renderColor(styleGroup); |
| 3290 | renderIcons(styleGroup); |
| 3291 | renderTypography(styleGroup); |
| 3292 | host.appendChild(styleGroup); |
| 3293 | renderImageDirection(host); |
| 3294 | renderImageProduction(host); |
| 3295 | renderFormulaPolicy(host); |
| 3296 | renderProactiveExecution(host); |
| 3297 | renderMode(host); |
| 3298 | renderRefine(host); |
| 3299 | } |
| 3300 | updateActionBar(stage); |
| 3301 | } |
| 3302 | |
| 3303 | function renderAll() { renderForStage(STAGE); } |
| 3304 | |
| 3305 | function updateActionBar(stage) { |
| 3306 | var btn = document.getElementById("btn-confirm"); |
| 3307 | btn.disabled = false; |
| 3308 | if (stage === 1) btn.textContent = t("btn_confirm_contract"); |
| 3309 | else if (stage === 2) btn.textContent = t("btn_confirm_final_plan"); |
| 3310 | else btn.textContent = t("btn_confirm"); |
| 3311 | } |
| 3312 | |
| 3313 | // ---- state init (once) ---------------------------------------------- |
| 3314 | function firstId(list) { |
| 3315 | if (!list || !list.length) return undefined; |
| 3316 | if (list[0].items) return (list[0].items[0] || {}).id; |
| 3317 | return list[0].id; |
| 3318 | } |
| 3319 | function pick(field, catList) { |
| 3320 | var recommended = recId(field); |
| 3321 | if (recommended == null || recommended === "") { |
| 3322 | recommended = normalizeRecId(field, directionField(field)); |
| 3323 | } |
| 3324 | return recommended != null && recommended !== "" ? recommended : firstId(catList); |
| 3325 | } |
| 3326 | |
| 3327 | function initCreativeSelection(field, catalog, behaviorKey) { |
| 3328 | var value = pick(field, catalog); |
| 3329 | var behavior = customCandidateBehavior(field); |
| 3330 | if (value && value !== "custom" && !findCatalogOption(catalog, value)) { |
| 3331 | behavior = String(value); |
| 3332 | value = "custom"; |
| 3333 | } |
| 3334 | STATE[field] = value; |
| 3335 | STATE[behaviorKey] = behavior; |
| 3336 | } |
| 3337 | |
| 3338 | function initStage1State() { |
| 3339 | STATE.primary_language = recommendationLanguage(); |
| 3340 | STATE.canvas = pick("canvas", CAT.canvas); |
| 3341 | STATE.audience = (REC.audience && REC.audience.value) || ""; |
| 3342 | STATE.communication_intent = (REC.communication_intent && REC.communication_intent.value) || ""; |
| 3343 | STATE.audience_outcome = (REC.audience_outcome && REC.audience_outcome.value) || ""; |
| 3344 | STATE.core_message = (REC.core_message && REC.core_message.value) || ""; |
| 3345 | STATE.delivery_context = (REC.delivery_context && REC.delivery_context.value) || ""; |
| 3346 | STATE.artifact_afterlife = (REC.artifact_afterlife && REC.artifact_afterlife.value) || ""; |
| 3347 | STATE.content_divergence = (REC.content_divergence && REC.content_divergence.value) || ""; // free text; blank = balanced default |
| 3348 | } |
| 3349 | |
| 3350 | // Stage-2 fields are (re-)read from the recommendations. At boot they come from |
| 3351 | // the active stage file; after a stage-1 confirm enterStage() calls this again |
| 3352 | // with the newly authored candidates. Stage-1 STATE is preserved |
| 3353 | // across the single-session transition — this never resets the contract. |
| 3354 | function initStage2State() { |
| 3355 | [ |
| 3356 | "audience", |
| 3357 | "communication_intent", |
| 3358 | "audience_outcome", |
| 3359 | "core_message", |
| 3360 | "delivery_context", |
| 3361 | "artifact_afterlife", |
| 3362 | "content_divergence" |
| 3363 | ].forEach(function (field) { |
| 3364 | var value = recValue(field); |
| 3365 | if (value && typeof value === "object") value = value.value; |
| 3366 | if (value != null) STATE[field] = String(value); |
| 3367 | }); |
| 3368 | STATE.primary_language = recommendationLanguage(); |
| 3369 | resetTypographySizeOverrides(); |
| 3370 | var templateApplication = templateApplicationRecommendation(); |
| 3371 | if (templateApplication != null) { |
| 3372 | STATE.template_application = templateApplication; |
| 3373 | } else if (stageNumber(REC) === 2) { |
| 3374 | delete STATE.template_application; |
| 3375 | } |
| 3376 | // Reading mode is a design-density tool, not part of the communication |
| 3377 | // purpose. Keep the legacy delivery_purpose key for JSON compatibility. |
| 3378 | STATE.delivery_purpose = recId("delivery_purpose") || |
| 3379 | directionField("delivery_purpose") || STATE.delivery_purpose || "balanced"; |
| 3380 | STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : (STATE.page_count || ""); |
| 3381 | initCreativeSelection("mode", CAT.modes, "mode_behavior"); |
| 3382 | initCreativeSelection("visual_style", CAT.visual_styles, "visual_style_behavior"); |
| 3383 | var cc = colorRecommendationCandidates(); |
| 3384 | var csel = (REC.color && REC.color.selected != null) ? REC.color.selected : |
| 3385 | (designDirectionSpec().selected || 0); |
| 3386 | var c0 = cc[Math.min(csel, Math.max(cc.length - 1, 0))] || {}; |
| 3387 | STATE.color = { |
| 3388 | name: localized(c0, "name") || c0.name || "", |
| 3389 | palette: Object.assign({}, normPalette(c0)) |
| 3390 | }; |
| 3391 | |
| 3392 | STATE.icons = pick("icons", CAT.icons); |
| 3393 | |
| 3394 | var tc = typographyRecommendationCandidates(); |
| 3395 | var tsel = (REC.typography && REC.typography.selected != null) ? REC.typography.selected : |
| 3396 | (designDirectionSpec().selected || 0); |
| 3397 | var t0 = normTypography(tc[Math.min(tsel, Math.max(tc.length - 1, 0))] || {}); |
| 3398 | STATE.typography = { |
| 3399 | name: localized(t0, "name") || t0.name || "", |
| 3400 | heading: t0.heading || {}, |
| 3401 | body: t0.body || {}, |
| 3402 | body_size: t0.body_size || typographyBodySize(REC.typography), |
| 3403 | sizes: Object.assign({}, t0.sizes || {}) |
| 3404 | }; |
| 3405 | if (t0.custom) STATE.typography.custom = t0.custom; |
| 3406 | |
| 3407 | // Guarantee a body baseline even when a candidate omitted body_size, on |
| 3408 | // any canvas (PPT → px default by purpose, non-PPT → px from canvas height), |
| 3409 | // so role sizes never derive from an empty anchor. |
| 3410 | if (STATE.typography && !STATE.typography.body_size) { |
| 3411 | STATE.typography.body_size = defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose); |
| 3412 | } |
| 3413 | // A freshly authored Stage 2 starts from one deterministic reading-mode |
| 3414 | // baseline. |
| 3415 | if (stageNumber(REC) === 2) syncUnpinnedTypographySizes(true); |
| 3416 | var rawImageUsage = recValue("image_usage") || directionField("image_usage"); |
| 3417 | STATE.image_usage = selectedImageUsageIds(rawImageUsage); |
| 3418 | if (!STATE.image_usage.length) { |
| 3419 | STATE.image_usage = [defaultImageUsageId()]; |
| 3420 | } |
| 3421 | STATE.image_notes = imageUsageNotesRecommendation(rawImageUsage); |
| 3422 | STATE.image_strategy_custom = imageStrategyCustomCandidate(); |
| 3423 | var strategyCandidates = imageStrategyRecommendationCandidates(); |
| 3424 | var directionStrategy = directionField("image_strategy"); |
| 3425 | var customStrategyRecommended = recId("image_strategy") === "custom"; |
| 3426 | if ((customStrategyRecommended || |
| 3427 | (directionStrategy && directionStrategy.rendering === "custom")) && |
| 3428 | STATE.image_strategy_custom) { |
| 3429 | STATE.image_strategy = normalizedImageStrategy(STATE.image_strategy_custom); |
| 3430 | } else if (strategyCandidates.length) { |
| 3431 | STATE.image_strategy = normalizedImageStrategy( |
| 3432 | strategyCandidates[imageStrategySelectedIndex()] || strategyCandidates[0] |
| 3433 | ); |
| 3434 | } else if (directionStrategy) { |
| 3435 | STATE.image_strategy = normalizedImageStrategy(directionStrategy); |
| 3436 | } |
| 3437 | } |
| 3438 | |
| 3439 | function initProductionState() { |
| 3440 | STATE.formula_policy = pick("formula_policy", CAT.formula_policy); |
| 3441 | STATE.image_ai_path = pick("image_ai_path", CAT.image_ai_path); |
| 3442 | STATE.proactive_speaker_notes = booleanRecommendation("proactive_speaker_notes", true); |
| 3443 | STATE.proactive_custom_animations = booleanRecommendation("proactive_custom_animations", false); |
| 3444 | STATE.proactive_narration_audio = booleanRecommendation("proactive_narration_audio", false); |
| 3445 | |
| 3446 | STATE.generation_mode = pick("generation_mode", CAT.generation_mode); |
| 3447 | STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec)); |
| 3448 | } |
| 3449 | |
| 3450 | function initState() { |
| 3451 | initStage1State(); |
| 3452 | } |
| 3453 | |
| 3454 | // ---- confirm + close ------------------------------------------------- |
| 3455 | function showConfirmedOverlay() { |
| 3456 | var ov = document.getElementById("confirmed-overlay"); |
| 3457 | ov.querySelector(".cf-title").textContent = t("confirmed_title"); |
| 3458 | ov.querySelector(".cf-hint").textContent = t("confirmed_hint"); |
| 3459 | ov.style.display = "flex"; |
| 3460 | } |
| 3461 | |
| 3462 | // ---- staged submit + next-stage transitions ------------------------- |
| 3463 | function communicationPayload() { |
| 3464 | var payload = { |
| 3465 | canvas: STATE.canvas, |
| 3466 | audience: STATE.audience, |
| 3467 | communication_intent: STATE.communication_intent, |
| 3468 | audience_outcome: STATE.audience_outcome, |
| 3469 | core_message: STATE.core_message, |
| 3470 | delivery_context: STATE.delivery_context, |
| 3471 | artifact_afterlife: STATE.artifact_afterlife, |
| 3472 | content_divergence: STATE.content_divergence |
| 3473 | }; |
| 3474 | if (STATE.primary_language && STATE.primary_language !== "und") { |
| 3475 | payload.primary_language = STATE.primary_language; |
| 3476 | } |
| 3477 | return payload; |
| 3478 | } |
| 3479 | |
| 3480 | function stage1Payload() { |
| 3481 | var payload = communicationPayload(); |
| 3482 | payload.stage = "stage1"; |
| 3483 | payload.template_selection = { |
| 3484 | mode: TEMPLATE_MODE, |
| 3485 | selection_keys: TEMPLATE_MODE === "templates" |
| 3486 | ? TEMPLATE_SELECTED_KEYS.slice() : [] |
| 3487 | }; |
| 3488 | return payload; |
| 3489 | } |
| 3490 | |
| 3491 | function normalizeCreativePayload(payload) { |
| 3492 | if (payload.mode !== "custom") delete payload.mode_behavior; |
| 3493 | if (payload.visual_style !== "custom") delete payload.visual_style_behavior; |
| 3494 | var imageStrategy = payload.image_strategy; |
| 3495 | if (imageStrategy && imageStrategy.rendering === "custom") { |
| 3496 | if (!imageStrategy.behavior && imageStrategy.custom) { |
| 3497 | imageStrategy.behavior = imageStrategy.custom; |
| 3498 | } |
| 3499 | delete imageStrategy.custom; |
| 3500 | } else if (imageStrategy) { |
| 3501 | delete imageStrategy.behavior; |
| 3502 | delete imageStrategy.custom; |
| 3503 | } |
| 3504 | delete payload.image_strategy_custom; |
| 3505 | return payload; |
| 3506 | } |
| 3507 | |
| 3508 | function customSelectionsValid(payload) { |
| 3509 | var imageStrategy = payload.image_strategy || {}; |
| 3510 | var valid = payload.mode !== "custom" || String(payload.mode_behavior || "").trim(); |
| 3511 | valid = valid && (payload.visual_style !== "custom" || |
| 3512 | String(payload.visual_style_behavior || "").trim()); |
| 3513 | valid = valid && (imageStrategy.rendering !== "custom" || |
| 3514 | String(imageStrategy.behavior || "").trim()); |
| 3515 | if (!valid) { |
| 3516 | document.getElementById("confirm-status").textContent = t("custom_behavior_required"); |
| 3517 | } |
| 3518 | return !!valid; |
| 3519 | } |
| 3520 | |
| 3521 | function imageStrategyValid(payload) { |
| 3522 | if (!needsGeneratedImagesForUsage(payload.image_usage)) return true; |
| 3523 | var imageStrategy = payload.image_strategy || {}; |
| 3524 | var rendering = String(imageStrategy.rendering || "").trim(); |
| 3525 | if (!rendering) { |
| 3526 | document.getElementById("confirm-status").textContent = t("image_strategy_required"); |
| 3527 | return false; |
| 3528 | } |
| 3529 | var presetIds = imageStrategyCatalogCandidates().map(function (candidate) { |
| 3530 | return candidate.rendering; |
| 3531 | }); |
| 3532 | if (rendering !== "custom" && presetIds.length && presetIds.indexOf(rendering) < 0) { |
| 3533 | document.getElementById("confirm-status").textContent = t("image_strategy_invalid"); |
| 3534 | return false; |
| 3535 | } |
| 3536 | return true; |
| 3537 | } |
| 3538 | |
| 3539 | function positiveNumber(value) { |
| 3540 | var number = parseFloat(value); |
| 3541 | return isFinite(number) && number > 0; |
| 3542 | } |
| 3543 | |
| 3544 | function designSystemValid(payload) { |
| 3545 | var color = payload.color || {}; |
| 3546 | var palette = color.palette || {}; |
| 3547 | var completePalette = PALETTE_ROLES.every(function (role) { |
| 3548 | return !!normHex(palette[role]); |
| 3549 | }); |
| 3550 | var customPalette = color.name === "custom" && String(color.custom || "").trim(); |
| 3551 | |
| 3552 | var typography = payload.typography || {}; |
| 3553 | var completeFamilies = typographyFamiliesComplete(typography); |
| 3554 | var sizes = typography.sizes || {}; |
| 3555 | var completeSizes = positiveNumber(typography.body_size) && |
| 3556 | ["title", "subtitle", "annotation"].every(function (role) { |
| 3557 | return positiveNumber(sizes[role]); |
| 3558 | }); |
| 3559 | var valid = (completePalette || customPalette) && |
| 3560 | completeFamilies && completeSizes; |
| 3561 | if (!valid) { |
| 3562 | var message = color.name === "custom" && !customPalette |
| 3563 | ? t("custom_color_required") |
| 3564 | : t("design_system_required"); |
| 3565 | document.getElementById("confirm-status").textContent = message; |
| 3566 | } |
| 3567 | return !!valid; |
| 3568 | } |
| 3569 | |
| 3570 | function submitStage(payload, nextStage) { |
| 3571 | var btn = document.getElementById("btn-confirm"); |
| 3572 | btn.disabled = true; |
| 3573 | fetch("/api/confirm", { |
| 3574 | method: "POST", |
| 3575 | headers: { "Content-Type": "application/json" }, |
| 3576 | body: JSON.stringify(payload) |
| 3577 | }).then(function (r) { |
| 3578 | if (!r.ok) throw new Error("stage submit failed"); |
| 3579 | showDeriving(); |
| 3580 | pollForStage(nextStage); |
| 3581 | }).catch(function () { |
| 3582 | btn.disabled = false; |
| 3583 | document.getElementById("confirm-status").textContent = t("error_retry"); |
| 3584 | }); |
| 3585 | } |
| 3586 | |
| 3587 | function imageUsageValid(value) { |
| 3588 | var ids = selectedImageUsageIds(value); |
| 3589 | if (!ids.length) { |
| 3590 | document.getElementById("confirm-status").textContent = t("image_usage_required"); |
| 3591 | return false; |
| 3592 | } |
| 3593 | if (ids.indexOf("none") >= 0 && ids.length > 1) { |
| 3594 | document.getElementById("confirm-status").textContent = t("image_usage_none_exclusive"); |
| 3595 | return false; |
| 3596 | } |
| 3597 | return true; |
| 3598 | } |
| 3599 | |
| 3600 | function templateSelectionValid() { |
| 3601 | var valid = TEMPLATE_MODE === "free_design" || |
| 3602 | (TEMPLATE_MODE === "templates" && TEMPLATE_SELECTED_KEYS.length > 0); |
| 3603 | if (!valid) { |
| 3604 | document.getElementById("confirm-status").textContent = |
| 3605 | t("template_selection_required"); |
| 3606 | } |
| 3607 | return valid; |
| 3608 | } |
| 3609 | |
| 3610 | function submitStage1() { |
| 3611 | if (!templateSelectionValid()) return; |
| 3612 | submitStage(stage1Payload(), 2); |
| 3613 | } |
| 3614 | |
| 3615 | function submitStage2() { |
| 3616 | confirm(); |
| 3617 | } |
| 3618 | |
| 3619 | function showDeriving() { |
| 3620 | document.getElementById("sections").style.display = "none"; |
| 3621 | document.getElementById("actionbar").style.display = "none"; |
| 3622 | var l = document.getElementById("loading"); |
| 3623 | l.textContent = t("deriving"); |
| 3624 | l.style.display = "block"; |
| 3625 | } |
| 3626 | |
| 3627 | // Poll session state first. It is derived from recommendation stage files |
| 3628 | // and result.json, so a recovered server can tell the existing page exactly when |
| 3629 | // the next stage is ready. |
| 3630 | function pollForStage(nextStage) { |
| 3631 | fetchJson("/api/session", "session") |
| 3632 | .then(function (session) { |
| 3633 | var readyStage = Number(session && session.recommendation_stage_number || 0); |
| 3634 | if (readyStage < nextStage) { |
| 3635 | setTimeout(function () { pollForStage(nextStage); }, 1200); |
| 3636 | return null; |
| 3637 | } |
| 3638 | return fetchJson("/api/recommendations", "recommendations").then(function (data) { |
| 3639 | var serverStage = stageNumber(data); |
| 3640 | if (data && typeof serverStage === "number" && serverStage >= nextStage) { |
| 3641 | enterStage(data, serverStage); |
| 3642 | } |
| 3643 | else { setTimeout(function () { pollForStage(nextStage); }, 1200); } |
| 3644 | return null; |
| 3645 | }); |
| 3646 | }).catch(function (err) { |
| 3647 | var l = document.getElementById("loading"); |
| 3648 | if (l) l.textContent = t("connection_lost") + " " + (err && err.message ? err.message : ""); |
| 3649 | setTimeout(function () { pollForStage(nextStage); }, 1500); |
| 3650 | }); |
| 3651 | } |
| 3652 | |
| 3653 | function enterStage(data, stage) { |
| 3654 | REC = data; |
| 3655 | if (stage === 2) { |
| 3656 | initStage2State(); |
| 3657 | initProductionState(); |
| 3658 | } |
| 3659 | STAGE = stage; |
| 3660 | document.getElementById("loading").style.display = "none"; |
| 3661 | document.getElementById("sections").style.display = "block"; |
| 3662 | document.getElementById("actionbar").style.display = "flex"; |
| 3663 | document.getElementById("confirm-status").textContent = ""; |
| 3664 | renderForStage(stage); |
| 3665 | } |
| 3666 | |
| 3667 | function confirm() { |
| 3668 | var btn = document.getElementById("btn-confirm"); |
| 3669 | var payload = JSON.parse(JSON.stringify(STATE)); |
| 3670 | normalizeTypographyForSubmit(payload); |
| 3671 | payload.stage = "final"; |
| 3672 | payload.image_usage = selectedImageUsageIds(payload.image_usage); |
| 3673 | if (!imageUsageValid(payload.image_usage)) return; |
| 3674 | if (!String(payload.image_notes || "").trim()) delete payload.image_notes; |
| 3675 | if (!needsGeneratedImagesForUsage(payload.image_usage)) { |
| 3676 | delete payload.image_ai_path; |
| 3677 | delete payload.image_strategy; |
| 3678 | } else { |
| 3679 | payload.image_strategy = normalizedImageStrategy(payload.image_strategy); |
| 3680 | } |
| 3681 | normalizeCreativePayload(payload); |
| 3682 | if (!imageStrategyValid(payload)) return; |
| 3683 | if (!designSystemValid(payload)) return; |
| 3684 | if (!customSelectionsValid(payload)) return; |
| 3685 | btn.disabled = true; |
| 3686 | fetch("/api/confirm", { |
| 3687 | method: "POST", |
| 3688 | headers: { "Content-Type": "application/json" }, |
| 3689 | body: JSON.stringify(payload) |
| 3690 | }).then(function (r) { |
| 3691 | if (!r.ok) throw new Error("confirm failed"); |
| 3692 | showConfirmedOverlay(); |
| 3693 | fetch("/api/shutdown", { |
| 3694 | method: "POST", |
| 3695 | headers: { "Content-Type": "application/json" }, |
| 3696 | body: JSON.stringify({ reason: "confirmed" }) |
| 3697 | }).catch(function () { /* server already gone — fine */ }); |
| 3698 | setTimeout(function () { try { window.close(); } catch (e) { /* ignore */ } }, 400); |
| 3699 | }).catch(function () { |
| 3700 | btn.disabled = false; |
| 3701 | document.getElementById("confirm-status").textContent = t("error_retry"); |
| 3702 | }); |
| 3703 | } |
| 3704 | |
| 3705 | // ---- boot ------------------------------------------------------------ |
| 3706 | function showError(msg) { |
| 3707 | document.getElementById("loading").style.display = "none"; |
| 3708 | var e = document.getElementById("error"); |
| 3709 | e.style.display = "block"; |
| 3710 | e.textContent = msg; |
| 3711 | } |
| 3712 | |
| 3713 | function fetchJson(url, label) { |
| 3714 | return fetch(url, { cache: "no-store" }).then(function (r) { |
| 3715 | return r.text().then(function (text) { |
| 3716 | var data = null; |
| 3717 | if (text) { |
| 3718 | try { data = JSON.parse(text); } |
| 3719 | catch (e) { |
| 3720 | if (r.ok) throw new Error((label || url) + ": invalid JSON"); |
| 3721 | } |
| 3722 | } |
| 3723 | if (!r.ok) { |
| 3724 | var serverMsg = data && data.error ? data.error : (text || r.statusText || r.status); |
| 3725 | throw new Error((label || url) + ": " + serverMsg); |
| 3726 | } |
| 3727 | return data || {}; |
| 3728 | }); |
| 3729 | }); |
| 3730 | } |
| 3731 | |
| 3732 | function loadCatalogs() { |
| 3733 | return fetchJson("/api/catalogs", "catalogs") |
| 3734 | .catch(function () { return fetchJson("/static/catalogs.json", "static catalogs"); }); |
| 3735 | } |
| 3736 | |
| 3737 | function loadIconPreviews() { |
| 3738 | return fetchJson("/api/icon-previews", "icon previews") |
| 3739 | .catch(function () { return {}; }); |
| 3740 | } |
| 3741 | |
| 3742 | function loadAiImageComparison() { |
| 3743 | return fetchJson("/api/ai-image-comparison", "AI image comparison") |
| 3744 | .catch(function () { return {}; }); |
| 3745 | } |
| 3746 | |
| 3747 | function applyServerLanguage(data) { |
| 3748 | var requested = data && data.lang; |
| 3749 | if (requested !== "zh" && requested !== "en" && requested !== "ja") return; |
| 3750 | var hasStored = false; |
| 3751 | try { hasStored = !!window.localStorage.getItem("ppt_lang"); } catch (e) { /* ignore */ } |
| 3752 | if (hasStored) return; |
| 3753 | LANG = requested; |
| 3754 | applyStaticTranslations(); |
| 3755 | var toggleBtn = document.getElementById("btn-lang-toggle"); |
| 3756 | if (toggleBtn) refreshLangToggle(toggleBtn); |
| 3757 | } |
| 3758 | |
| 3759 | function loadStrategistUi(forceStage) { |
| 3760 | return Promise.all([ |
| 3761 | loadCatalogs(), |
| 3762 | fetchJson("/api/recommendations", "recommendations"), |
| 3763 | loadIconPreviews(), |
| 3764 | loadAiImageComparison() |
| 3765 | ]).then(function (res) { |
| 3766 | CAT = res[0]; |
| 3767 | REC = res[1]; |
| 3768 | ICON_PREVIEWS = res[2] || {}; |
| 3769 | AI_IMAGE_COMPARISON = res[3] || {}; |
| 3770 | applyServerLanguage(REC); |
| 3771 | var activeStage = forceStage || stageNumber(REC); |
| 3772 | if (activeStage === 1) { |
| 3773 | if (!REC.template_options || typeof REC.template_options !== "object" || |
| 3774 | Array.isArray(REC.template_options)) { |
| 3775 | throw new Error("Stage 1 recommendations must include template_options"); |
| 3776 | } |
| 3777 | initTemplateOptions(REC.template_options); |
| 3778 | } |
| 3779 | initState(); |
| 3780 | enterStage(REC, activeStage); |
| 3781 | return REC; |
| 3782 | }); |
| 3783 | } |
| 3784 | |
| 3785 | function boot() { |
| 3786 | applyStaticTranslations(); |
| 3787 | var toggleBtn = document.getElementById("btn-lang-toggle"); |
| 3788 | var langMenu = document.getElementById("lang-menu"); |
| 3789 | refreshLangToggle(toggleBtn); |
| 3790 | var setMenuOpen = function (open) { |
| 3791 | langMenu.hidden = !open; |
| 3792 | toggleBtn.setAttribute("aria-expanded", open ? "true" : "false"); |
| 3793 | if (open) { |
| 3794 | var sel = langMenu.querySelector("li.selected") || langMenu.querySelector("li[data-lang]"); |
| 3795 | if (sel) sel.focus(); |
| 3796 | } |
| 3797 | }; |
| 3798 | var chooseLang = function (v) { |
| 3799 | setMenuOpen(false); |
| 3800 | toggleBtn.focus(); |
| 3801 | if (v !== "ja" && v !== "en" && v !== "zh") return; |
| 3802 | if (v === LANG) return; |
| 3803 | LANG = v; |
| 3804 | try { window.localStorage.setItem("ppt_lang", LANG); } catch (e2) { /* ignore */ } |
| 3805 | applyStaticTranslations(); |
| 3806 | refreshLangToggle(toggleBtn); |
| 3807 | if (REC && CAT) { |
| 3808 | renderAll(); // STATE persists → selections preserved |
| 3809 | } |
| 3810 | }; |
| 3811 | toggleBtn.addEventListener("click", function (e) { |
| 3812 | e.stopPropagation(); |
| 3813 | setMenuOpen(langMenu.hidden); |
| 3814 | }); |
| 3815 | toggleBtn.addEventListener("keydown", function (e) { |
| 3816 | if (e.key === "Escape" && !langMenu.hidden) { |
| 3817 | e.stopPropagation(); |
| 3818 | setMenuOpen(false); |
| 3819 | } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && langMenu.hidden) { |
| 3820 | e.preventDefault(); |
| 3821 | e.stopPropagation(); |
| 3822 | setMenuOpen(true); |
| 3823 | } |
| 3824 | }); |
| 3825 | langMenu.addEventListener("click", function (e) { |
| 3826 | e.stopPropagation(); |
| 3827 | var li = e.target && e.target.closest ? e.target.closest("li[data-lang]") : null; |
| 3828 | if (li) chooseLang(li.getAttribute("data-lang")); |
| 3829 | else setMenuOpen(false); |
| 3830 | }); |
| 3831 | langMenu.addEventListener("keydown", function (e) { |
| 3832 | e.stopPropagation(); // page-level shortcuts must not fire while the menu is open |
| 3833 | var items = Array.prototype.slice.call(langMenu.querySelectorAll("li[data-lang]")); |
| 3834 | var idx = items.indexOf(document.activeElement); |
| 3835 | if (e.key === "Escape") { |
| 3836 | setMenuOpen(false); |
| 3837 | toggleBtn.focus(); |
| 3838 | } else if (e.key === "ArrowDown") { |
| 3839 | e.preventDefault(); |
| 3840 | (items[idx + 1] || items[0]).focus(); |
| 3841 | } else if (e.key === "ArrowUp") { |
| 3842 | e.preventDefault(); |
| 3843 | (items[idx - 1] || items[items.length - 1]).focus(); |
| 3844 | } else if (e.key === "Home") { |
| 3845 | e.preventDefault(); |
| 3846 | items[0].focus(); |
| 3847 | } else if (e.key === "End") { |
| 3848 | e.preventDefault(); |
| 3849 | items[items.length - 1].focus(); |
| 3850 | } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { |
| 3851 | e.preventDefault(); |
| 3852 | } else if (e.key === "Enter" || e.key === " ") { |
| 3853 | e.preventDefault(); |
| 3854 | if (idx >= 0) chooseLang(items[idx].getAttribute("data-lang")); |
| 3855 | } |
| 3856 | }); |
| 3857 | toggleBtn.parentElement.addEventListener("focusout", function (e) { |
| 3858 | if (!langMenu.hidden && !toggleBtn.parentElement.contains(e.relatedTarget)) setMenuOpen(false); |
| 3859 | }); |
| 3860 | document.addEventListener("click", function () { |
| 3861 | if (!langMenu.hidden) setMenuOpen(false); |
| 3862 | }); |
| 3863 | document.getElementById("btn-confirm").addEventListener("click", function () { |
| 3864 | if (STAGE === 1) submitStage1(); |
| 3865 | else if (STAGE === 2) submitStage2(); |
| 3866 | else confirm(); |
| 3867 | }); |
| 3868 | |
| 3869 | // Session remains the first network read so completed runs can close |
| 3870 | // cleanly. Active runs always load the current Strategist stage; Stage 1 |
| 3871 | // carries its template catalog inside the recommendation payload. |
| 3872 | fetchJson("/api/session", "session").catch(function () { |
| 3873 | return { phase: "strategist" }; |
| 3874 | }).then(function (session) { |
| 3875 | if (session && session.status === "done") { |
| 3876 | document.getElementById("loading").style.display = "none"; |
| 3877 | showConfirmedOverlay(); |
| 3878 | return null; |
| 3879 | } |
| 3880 | return loadStrategistUi().catch(function (err) { |
| 3881 | showError(t("load_error") + " " + (err && err.message ? err.message : "")); |
| 3882 | return null; |
| 3883 | }); |
| 3884 | }); |
| 3885 | } |
| 3886 | |
| 3887 | if (document.readyState === "loading") { |
| 3888 | document.addEventListener("DOMContentLoaded", boot); |
| 3889 | } else { |
| 3890 | boot(); |
| 3891 | } |
| 3892 | })(); |
| 3893 |