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