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