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