4536 lines
228 KiB
JavaScript
4536 lines
228 KiB
JavaScript
/* PPT Master - Strategist confirmation UI
|
||
* Stage 1 confirms the communication contract and the free-design/template
|
||
* choice together. Stage 2 combines the coherent deck solution and production
|
||
* mechanics, then writes the final result.
|
||
* Finite fields use /static/catalogs.json; coordinated design directions seed
|
||
* mode, visual style, color, typography, icons, and generated-image rendering,
|
||
* while template-application prose stays conditional. Final confirm saves the
|
||
* flattened current values to result.json.
|
||
*/
|
||
(function () {
|
||
"use strict";
|
||
|
||
// ---- i18n ------------------------------------------------------------
|
||
var MESSAGES = {
|
||
en: {
|
||
page_title: "PPT Master - Confirm Design",
|
||
topbar_hint: "Answer the open questions, or pick and customize recommendations, then continue.",
|
||
stage_anchors: "Stage 1 · Communication contract",
|
||
stage_final_plan: "Stage 2 · Final plan & production",
|
||
loading: "Loading…",
|
||
load_error: "Could not load the current recommendation stage. The AI must write it before launch.",
|
||
btn_confirm: "Confirm",
|
||
btn_confirm_contract: "Confirm contract & continue →",
|
||
btn_confirm_final_plan: "Confirm final plan →",
|
||
deriving: "Generating the downstream options from your choices…",
|
||
template_selection_required: "Choose free design or use templates. When using templates, select at least one workspace.",
|
||
connection_lost: "Connection to the confirm server was interrupted; retrying. If this keeps failing, return to the chat for confirmation.",
|
||
confirmed_title: "✓ Confirmed",
|
||
confirmed_hint: "Your choices are saved. You can close this page and return to the chat.",
|
||
lang_toggle_title: "Switch language",
|
||
sec_template_choice: "Design basis",
|
||
template_choice_hint: "Choose how this deck should establish its design system.",
|
||
template_free_title: "Design from the current content",
|
||
template_free_desc: "Use no reusable template workspace. The Strategist will derive the visual system from this project.",
|
||
template_use_title: "Use templates",
|
||
template_use_desc: "Select one or more reusable Brand, Style, Layout, Deck, or specified workspaces.",
|
||
sec_template_library: "Template combination",
|
||
template_library_hint: "Choose at most one registered template for each type. Brand, Style, Layout, and Deck can be combined.",
|
||
sec_template_explicit: "Specified templates",
|
||
template_explicit_hint: "Choose at most one exact workspace supplied for this run. Its source path is shown for verification.",
|
||
template_kind_brand: "Brand",
|
||
template_kind_style: "Style",
|
||
template_kind_layout: "Layout",
|
||
template_kind_deck: "Deck",
|
||
template_source_library: "Library",
|
||
template_source_explicit: "Specified path",
|
||
template_source_path: "Source path",
|
||
template_select_none: "None",
|
||
template_none_registered: "No registered templates",
|
||
template_none_explicit: "No specified templates for this run",
|
||
sec_canvas: "Canvas format",
|
||
sec_pages: "Page count",
|
||
sec_audience: "Target audience",
|
||
sec_communication: "What this presentation must accomplish",
|
||
sec_delivery: "How it will be used and what must remain",
|
||
sec_narrative: "Narrative direction",
|
||
sec_visual: "Visual direction",
|
||
sec_color: "Color scheme",
|
||
sec_icons: "Icon usage",
|
||
sec_type: "Typography",
|
||
sec_images: "Image usage",
|
||
sec_image_production: "Image production",
|
||
sec_proactive_execution: "Proactive execution",
|
||
sec_mode: "Generation mode",
|
||
sec_refine: "Review the Design Spec first",
|
||
sec_design_directions: "Coherent design directions",
|
||
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.",
|
||
direction_active: "Applied",
|
||
direction_adjusted: "Adjusted",
|
||
direction_apply_hint: "Click to apply this complete direction.",
|
||
direction_restore: "Restore authored direction",
|
||
scheme_component_options: "Project-specific custom choices · select a card to edit",
|
||
sec_template_application: "Template application",
|
||
template_application_hint: "The AI recommends how to apply the installed template to this deck. Revise the plan directly in natural language.",
|
||
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.",
|
||
sub_mode: "Narrative mode",
|
||
sub_visual: "Visual style",
|
||
sub_divergence: "Material divergence (how freely to reshape vs. stay close to the source)",
|
||
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.",
|
||
communication_intent: "What should this presentation accomplish?",
|
||
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.",
|
||
placeholder_communication_intent: "e.g. Report progress and surface risk first, then secure a decision on the next investment.",
|
||
audience_outcome: "Desired audience outcome / success condition",
|
||
placeholder_audience_outcome: "What should the audience know, understand, believe, decide, or do afterward?",
|
||
core_message: "Core message / decision ask / action",
|
||
placeholder_core_message: "Which claims, requests, or actions must land even if little else is remembered?",
|
||
delivery_context: "Delivery context (name the primary)",
|
||
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.",
|
||
placeholder_delivery_context: "e.g. Primary: presenter-led 20-minute leadership review. Secondary: reader-led approval copy shared afterward.",
|
||
artifact_afterlife: "Artifact afterlife",
|
||
placeholder_artifact_afterlife: "e.g. approval, review, audit, archive, hand-off, or reuse; leave blank when no later use is expected.",
|
||
stage1_current_value_hint: "Editable fields contain recommendations. Keep, revise, or clear them; confirmation saves the current text exactly, including blank values.",
|
||
content_divergence_locked_hint: "This profile preserves the source wording and page structure, so this field is fixed.",
|
||
custom: "Custom",
|
||
custom_placeholder: "Type your own…",
|
||
ai_custom_candidate: "AI custom proposal",
|
||
ai_custom_candidate_hint: "Always visible for comparison. It is not selected by default; select it to edit.",
|
||
custom_behavior_required: "The selected AI custom proposal cannot be blank.",
|
||
custom_color_required: "Describe the custom color scheme before continuing.",
|
||
design_system_required: "Choose a complete palette and typography system before continuing.",
|
||
mode_behavior_placeholder: "Describe the act sequence, title voice, page rhythm, and presentation posture.",
|
||
visual_style_behavior_placeholder: "Describe shape language, composition, decoration density, whitespace, typography character, and texture.",
|
||
recommended: "Recommended",
|
||
placeholder_audience: "Who is this deck for?",
|
||
placeholder_pages: "e.g. 12-15",
|
||
hex_override: "Custom HEX override:",
|
||
formula_policy: "Formula rendering policy",
|
||
image_ai_path: "AI image source",
|
||
image_strategy: "Generated image style",
|
||
image_strategy_empty: "No preset style references are available. You can still use a custom style.",
|
||
image_strategy_required: "Choose a generated-image preset or describe a custom style.",
|
||
image_strategy_invalid: "The selected generated-image preset is not available.",
|
||
image_strategy_select_placeholder: "Choose a generated-image preset…",
|
||
image_strategy_recommended_group: "Recommended for this deck",
|
||
image_strategy_all_group: "All preset styles",
|
||
image_strategy_rendering: "Rendering",
|
||
image_strategy_visual: "Visual",
|
||
image_strategy_mood: "Mood",
|
||
image_strategy_ai_custom: "AI custom proposal",
|
||
image_strategy_ai_custom_desc: "A novel or multi-reference rendering proposal. Select it to edit.",
|
||
image_strategy_custom_placeholder: "Describe the exact generated-image direction, subjects, composition, style cues, or things to avoid.",
|
||
image_strategy_reference_hint: "Reference images show rendering only. Final AI images inherit the deck color scheme selected above.",
|
||
image_strategy_no_reference: "No reference image for this custom choice.",
|
||
image_source_summary: "Selected image sources",
|
||
image_production_hint: "Image sources and rendering are selected above. Resolve only the production path here.",
|
||
image_usage_notes: "Additional image requirements",
|
||
image_usage_notes_placeholder: "e.g. realistic handwashing scenes; avoid cartoon germs; keep product photos untouched.",
|
||
image_usage_required: "Select at least one image usage option.",
|
||
image_usage_none_exclusive: "No images cannot be combined with other image options.",
|
||
proactive_execution_hint: "These defaults apply only when you have not explicitly instructed otherwise. Your latest explicit instruction always takes priority.",
|
||
proactive_speaker_notes: "Proactively generate speaker notes",
|
||
proactive_speaker_notes_desc: "On by default. The agent generates speaker notes without a separate request.",
|
||
proactive_custom_animations: "Proactively create custom animations",
|
||
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.",
|
||
proactive_narration_audio: "Proactively generate narration audio",
|
||
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.",
|
||
font_heading: "Heading",
|
||
font_body: "Body",
|
||
font_selection: "Font selection",
|
||
primary_language_font: "Primary-language font",
|
||
english_font: "English font",
|
||
font_picker_hint: "Choosing a recommendation fills these selectors. Changing any font marks the typography as customized.",
|
||
other_installed_font: "Other installed font…",
|
||
other_font_placeholder: "Exact installed font name",
|
||
customized: "Customized",
|
||
font_body_size: "Body baseline size",
|
||
font_body_size_hint: "All type sizes derive from this body baseline.",
|
||
body_size_unit_relation: "SVG px to PPT pt: 1px = 0.75pt.",
|
||
body_size_pt_hint: "Approximately {pt} pt (1px = 0.75pt; saved as px).",
|
||
role_size_pt_hint: "≈ {pt} pt",
|
||
body_size_hint_canvas: "This canvas suggests ~{lo}–{hi}px (from its effective canvas span).",
|
||
body_size_hint_purpose: "This reading mode recommends {def}px — one fixed size, not a range.",
|
||
body_size_hint_oor: "(Current value is outside the usual range for this canvas — check the unit is right and that it fits.)",
|
||
delivery_purpose: "Reading mode",
|
||
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.",
|
||
size_override: "Per-role size override:",
|
||
size_role_title: "title",
|
||
size_role_subtitle: "subtitle",
|
||
size_role_annotation: "annotation",
|
||
custom_typography: "Custom typography",
|
||
custom_color: "Custom color",
|
||
custom_color_placeholder: "Describe your colors in words, e.g. deep navy primary, warm orange accent, white background — or paste HEX values…",
|
||
role_background: "bg",
|
||
role_secondary_bg: "2nd bg",
|
||
role_primary: "primary",
|
||
role_accent: "accent",
|
||
role_secondary_accent: "2nd accent",
|
||
role_body_text: "body text",
|
||
cjk: "CJK",
|
||
latin: "Latin",
|
||
sample_heading_cjk: "主题方案标题",
|
||
sample_heading_latin: "Presentation Title",
|
||
sample_body_cjk: "关键信息摘要",
|
||
sample_body_latin: "Key message summary",
|
||
style_preview_label: "Overall impression (color + typography + icons)",
|
||
style_preview_body: "· rough feel only, not the actual slide layout",
|
||
no_icons: "No icons",
|
||
preview_big_title: "Big Title",
|
||
preview_section_title: "Section Title",
|
||
preview_latin_title: "Section Title",
|
||
preview_body_intro: "Body copy shows the baseline text rhythm and contrast.",
|
||
preview_latin_body: "Body text sample for checking Latin typography.",
|
||
preview_point_1_title: "Body content",
|
||
preview_point_1_text: "Use this area to judge paragraph density and line spacing.",
|
||
preview_point_2_title: "Key point",
|
||
preview_point_2_text: "Icons are placed next to real text instead of floating alone.",
|
||
preview_point_3_title: "Conclusion",
|
||
preview_point_3_text: "The combination should stay readable at presentation scale.",
|
||
mode_continuous_desc: "Generate the whole deck in one pass.",
|
||
mode_split_desc: "Stop after the spec; resume SVG generation in a fresh window.",
|
||
refine_off_desc: "Write the Design Spec and execution lock in sequence, then auto-proceed.",
|
||
refine_on_desc: "Stop after the Design Spec. Revise any part in chat; approval then creates the execution lock and continues generation.",
|
||
off_default: "Off",
|
||
on: "On",
|
||
option_prefix: "Option",
|
||
error_retry: "Error - retry"
|
||
},
|
||
ja: {
|
||
page_title: "PPT Master - デザイン確認",
|
||
topbar_hint: "自由記述の質問に答えるか、提案を選択・調整して次へ進んでください。",
|
||
stage_anchors: "ステージ 1 · コミュニケーション契約",
|
||
stage_final_plan: "ステージ 2 · 最終プランと制作",
|
||
loading: "読み込み中…",
|
||
load_error: "現在の推奨ステージを読み込めませんでした。起動前にAIが書き込む必要があります。",
|
||
btn_confirm: "確定",
|
||
btn_confirm_contract: "契約内容を確定して次へ →",
|
||
btn_confirm_final_plan: "最終プランを確定 →",
|
||
deriving: "選択内容をもとに後続の選択肢を生成しています…",
|
||
template_selection_required: "自由デザインまたはテンプレート利用を選んでください。テンプレート利用時は、1つ以上のワークスペースを選択してください。",
|
||
connection_lost: "確認ページのサーバー接続が中断されました。再試行しています。失敗が続く場合はチャットで確認してください。",
|
||
confirmed_title: "✓ 確定しました",
|
||
confirmed_hint: "選択内容を保存しました。このページを閉じてチャットに戻ってください。",
|
||
lang_toggle_title: "言語を切り替え",
|
||
sec_template_choice: "デザインの基準",
|
||
template_choice_hint: "この資料のデザインシステムをどう決めるか選択します。",
|
||
template_free_title: "現在の内容からデザインする",
|
||
template_free_desc: "再利用テンプレートを使わず、Strategist がこのプロジェクトからビジュアルシステムを組み立てます。",
|
||
template_use_title: "テンプレートを使用",
|
||
template_use_desc: "Brand、Style、Layout、Deck、または指定ワークスペースから1つ以上選択します。",
|
||
sec_template_library: "テンプレートの組み合わせ",
|
||
template_library_hint: "登録済みテンプレートは種類ごとに1件まで選択でき、Brand、Style、Layout、Deck を組み合わせられます。",
|
||
sec_template_explicit: "指定テンプレート",
|
||
template_explicit_hint: "この実行で指定された正確なワークスペースを1件まで選択できます。確認用に参照元パスを表示します。",
|
||
template_kind_brand: "Brand",
|
||
template_kind_style: "Style",
|
||
template_kind_layout: "Layout",
|
||
template_kind_deck: "Deck",
|
||
template_source_library: "ライブラリ",
|
||
template_source_explicit: "指定パス",
|
||
template_source_path: "参照元パス",
|
||
template_select_none: "なし",
|
||
template_none_registered: "登録済みテンプレートはありません",
|
||
template_none_explicit: "この実行で指定されたテンプレートはありません",
|
||
sec_canvas: "キャンバス形式",
|
||
sec_pages: "ページ数",
|
||
sec_audience: "想定読者",
|
||
sec_communication: "このプレゼンで何を実現するか",
|
||
sec_delivery: "どう使い、何を残すか",
|
||
sec_narrative: "ナラティブ方針",
|
||
sec_visual: "ビジュアル方針",
|
||
sec_color: "配色",
|
||
sec_icons: "アイコンの使用",
|
||
sec_type: "タイポグラフィ",
|
||
sec_images: "画像の使用",
|
||
sec_image_production: "画像制作",
|
||
sec_proactive_execution: "能動的な実行",
|
||
sec_mode: "生成モード",
|
||
sec_refine: "先に設計仕様を確認",
|
||
sec_design_directions: "統合デザイン方針",
|
||
design_directions_hint: "おすすめの全体案が最初に適用されています。別案を選ぶか、下の各項目を微調整できます。調整後は「元の案に戻す」で最初の組み合わせを復元できます。",
|
||
direction_active: "適用中",
|
||
direction_adjusted: "調整済み",
|
||
direction_apply_hint: "クリックすると、この全体案を適用します。",
|
||
direction_restore: "元の案に戻す",
|
||
scheme_component_options: "プロジェクト専用カスタム案 · カードを選んで編集",
|
||
sec_template_application: "テンプレートの適用方法",
|
||
template_application_hint: "AIが現在の内容に合わせたテンプレートの使い方を提案します。自然言語で直接修正できます。",
|
||
placeholder_template_application: "使用・省略・反復・並べ替えするページやプロトタイプ、保持する要素、差し替え・再構成できる内容を記述します。",
|
||
sub_mode: "ナラティブモード",
|
||
sub_visual: "ビジュアルスタイル",
|
||
sub_divergence: "素材からの発散度(どこまで自由に再構成するか、原文に忠実か)",
|
||
placeholder_divergence: "自分の言葉でどうぞ — 例:「文書に忠実に」「元素材の範囲内で自由に再構成・展開」。空欄ならバランス型になります。",
|
||
communication_intent: "このプレゼンで何を実現したいですか?",
|
||
communication_intent_hint: "自由記述です。情報共有・説明・説得・意思決定・合意形成・教育・報告と説明責任・行動喚起・記録と引き継ぎを必要に応じて組み合わせ、必要なら優先順位や順序も書いてください。ラベルを選ぶ必要はありません。",
|
||
placeholder_communication_intent: "例:まず進捗とリスクを報告し、そのうえで次の投資判断を得る。",
|
||
audience_outcome: "聴衆に期待する変化・成功条件",
|
||
placeholder_audience_outcome: "終了後、聴衆は何を知り、理解し、信じ、決め、行動できる状態になるべきですか?",
|
||
core_message: "中核メッセージ/意思決定の依頼/行動",
|
||
placeholder_core_message: "ほかの内容が忘れられても、必ず残すべき主張・依頼・行動は何ですか?",
|
||
delivery_context: "利用状況(主モードを明記)",
|
||
delivery_context_hint: "発表者主導、読者主導、ハイブリッド、録画/自動再生を区別してください。ハイブリッドでは主モードと、維持すべき副用途を明記します。",
|
||
placeholder_delivery_context: "例:主は発表者付き20分の経営レビュー。副は会議後に単独で読む承認資料。",
|
||
artifact_afterlife: "資料の利用後",
|
||
placeholder_artifact_afterlife: "例:承認、レビュー、監査、保管、引き継ぎ、再利用。後続利用がなければ空欄で構いません。",
|
||
stage1_current_value_hint: "編集可能な欄には提案が入っています。そのまま使う・修正する・空にすることができ、確定時の現在値を空欄も含めてそのまま保存します。",
|
||
content_divergence_locked_hint: "このプロファイルは原文とページ構成を保持するため、この項目は固定されています。",
|
||
custom: "カスタム",
|
||
custom_placeholder: "自由に入力…",
|
||
ai_custom_candidate: "AIカスタム案",
|
||
ai_custom_candidate_hint: "比較できるよう常に全文を表示します。初期選択はされず、選択後に編集できます。",
|
||
custom_behavior_required: "選択したAIカスタム案を空欄にはできません。",
|
||
custom_color_required: "続行する前に、カスタム配色の説明を入力してください。",
|
||
design_system_required: "続行する前に、完全な配色と書体システムを選択してください。",
|
||
mode_behavior_placeholder: "構成の流れ、タイトルの語り口、ページのリズム、表現姿勢を記述します。",
|
||
visual_style_behavior_placeholder: "形状言語、構図、装飾密度、余白、書体の性格、質感を記述します。",
|
||
recommended: "おすすめ",
|
||
placeholder_audience: "この資料は誰に向けたもの?",
|
||
placeholder_pages: "例:12-15",
|
||
hex_override: "カスタムHEXで上書き:",
|
||
formula_policy: "数式レンダリング方針",
|
||
image_ai_path: "AI画像の生成元",
|
||
image_strategy: "生成画像のスタイル",
|
||
image_strategy_empty: "プリセットのスタイル見本を利用できません。カスタムスタイルは引き続き使用できます。",
|
||
image_strategy_required: "生成画像のプリセットを選ぶか、カスタムスタイルを記述してください。",
|
||
image_strategy_invalid: "選択した生成画像プリセットは利用できません。",
|
||
image_strategy_select_placeholder: "生成画像のプリセットを選択…",
|
||
image_strategy_recommended_group: "この資料へのおすすめ",
|
||
image_strategy_all_group: "すべてのプリセットスタイル",
|
||
image_strategy_rendering: "レンダリング",
|
||
image_strategy_visual: "ビジュアル",
|
||
image_strategy_mood: "ムード",
|
||
image_strategy_ai_custom: "AIカスタム案",
|
||
image_strategy_ai_custom_desc: "新規または複数の既存表現を統合したレンダリング案です。選択後に編集できます。",
|
||
image_strategy_custom_placeholder: "生成画像の方向性、被写体、構図、スタイル要素、避けたい要素を具体的に入力してください。",
|
||
image_strategy_reference_hint: "参照画像はレンダリングのみを示します。最終AI画像の色は上で選んだデッキ配色を継承します。",
|
||
image_strategy_no_reference: "このカスタム選択には参照画像がありません。",
|
||
image_source_summary: "選択中の画像ソース",
|
||
image_production_hint: "画像ソースとレンダリングは上で選択済みです。ここでは制作経路だけを決めます。",
|
||
image_usage_notes: "画像に関する補足要件",
|
||
image_usage_notes_placeholder: "例:リアルな手洗いシーンを優先、漫画調の菌のイラストは避ける、製品写真はそのまま使う。",
|
||
image_usage_required: "画像の使用方法を少なくとも1つ選択してください。",
|
||
image_usage_none_exclusive: "「画像なし」は他の画像オプションと同時に選択できません。",
|
||
proactive_execution_hint: "これらの初期設定は、明示的な指示がない場合にのみ適用されます。最新の明示的な指示が常に優先されます。",
|
||
proactive_speaker_notes: "発表者ノートを能動的に生成",
|
||
proactive_speaker_notes_desc: "初期設定はオンです。個別の依頼がなくても発表者ノートを生成します。",
|
||
proactive_custom_animations: "カスタムアニメーションを能動的に作成",
|
||
proactive_custom_animations_desc: "初期設定はオフです。ストラテジストの動きの提案は維持され、オンにすると個別の依頼がなくてもカスタムアニメーションを作成します。",
|
||
proactive_narration_audio: "ナレーション音声を能動的に生成",
|
||
proactive_narration_audio_desc: "初期設定はオフです。この選択は発表者ノートの設定を書き換えません。ナレーションに必要なノートの有効状態は、ストラテジストが設計仕様で解決します。",
|
||
font_heading: "見出し",
|
||
font_body: "本文",
|
||
font_selection: "フォント選択",
|
||
primary_language_font: "主要言語のフォント",
|
||
english_font: "英語フォント",
|
||
font_picker_hint: "提案を選ぶと下の選択欄に反映されます。いずれかのフォントを変更するとカスタマイズ済みになります。",
|
||
other_installed_font: "その他のインストール済みフォント…",
|
||
other_font_placeholder: "インストール済みフォントの正確な名前",
|
||
customized: "カスタマイズ済み",
|
||
font_body_size: "本文の基準サイズ",
|
||
font_body_size_hint: "すべての文字サイズはこの本文基準から導出されます。",
|
||
body_size_unit_relation: "SVG px と PPT pt の換算:1px = 0.75pt。",
|
||
body_size_pt_hint: "約 {pt} pt(1px = 0.75pt 換算、保存は px)。",
|
||
role_size_pt_hint: "約 {pt} pt",
|
||
body_size_hint_canvas: "このキャンバスの目安は約{lo}–{hi}px(有効キャンバス尺度から算出)。",
|
||
body_size_hint_purpose: "この閲覧モードの推奨は{def}px — 範囲ではなく固定値です。",
|
||
body_size_hint_oor: "(現在の値はこのキャンバスの通常範囲外です — 単位とサイズ感を確認してください。)",
|
||
delivery_purpose: "閲覧モード",
|
||
delivery_purpose_hint: "情報を主にページと話者のどちらに担わせるかを決めます。近距離閲覧は完全な文と細部で自立させ、プレゼン型は1枚1メッセージで短い主張と視覚的根拠を中心にします。",
|
||
size_override: "役割ごとのサイズ上書き:",
|
||
size_role_title: "タイトル",
|
||
size_role_subtitle: "サブタイトル",
|
||
size_role_annotation: "注釈",
|
||
custom_typography: "カスタムタイポグラフィ",
|
||
custom_color: "カスタム配色",
|
||
custom_color_placeholder: "配色を言葉で説明 — 例:濃紺をメインに暖色オレンジのアクセント、背景は白 — またはHEX値を貼り付け…",
|
||
role_background: "背景",
|
||
role_secondary_bg: "第2背景",
|
||
role_primary: "メイン",
|
||
role_accent: "アクセント",
|
||
role_secondary_accent: "第2アクセント",
|
||
role_body_text: "本文文字",
|
||
cjk: "和文",
|
||
latin: "欧文",
|
||
sample_heading_cjk: "プレゼンテーションの表題",
|
||
sample_heading_latin: "Presentation Title",
|
||
sample_body_cjk: "キーメッセージの要約",
|
||
sample_body_latin: "Key message summary",
|
||
style_preview_label: "全体の印象(配色 + タイポグラフィ + アイコン)",
|
||
style_preview_body: "· 雰囲気の確認用で、実際のレイアウトではありません",
|
||
no_icons: "アイコンなし",
|
||
preview_big_title: "大見出し",
|
||
preview_section_title: "章タイトル",
|
||
preview_latin_title: "Section Title",
|
||
preview_body_intro: "本文の基準サイズとコントラストを確認するための文です。",
|
||
preview_latin_body: "Body text sample for checking Latin typography.",
|
||
preview_point_1_title: "本文内容",
|
||
preview_point_1_text: "段落密度と行間の見え方をここで確認します。",
|
||
preview_point_2_title: "要点説明",
|
||
preview_point_2_text: "アイコンは単独ではなく、実際の文章の横に配置します。",
|
||
preview_point_3_title: "結論・提案",
|
||
preview_point_3_text: "投影時にも読みやすい組み合わせかを判断します。",
|
||
mode_continuous_desc: "デッキ全体を一気に生成します。",
|
||
mode_split_desc: "設計仕様の作成後に停止し、別ウィンドウでSVG生成を再開します。",
|
||
refine_off_desc: "設計仕様と実行ロックを順番に作成し、そのまま自動で進みます。",
|
||
refine_on_desc: "設計仕様の作成後に停止します。チャットで任意の箇所を修正し、承認後に実行ロックを作成して生成を続けます。",
|
||
off_default: "オフ",
|
||
on: "オン",
|
||
option_prefix: "案",
|
||
error_retry: "エラー - 再試行"
|
||
},
|
||
zh: {
|
||
page_title: "确认设计方案",
|
||
topbar_hint: "回答开放问题,或选择并调整推荐项,然后继续。",
|
||
stage_anchors: "第一阶段 · 沟通契约",
|
||
stage_final_plan: "第二阶段 · 最终方案与制作",
|
||
loading: "加载中…",
|
||
load_error: "无法加载推荐文件,需在启动前写入。",
|
||
btn_confirm: "确认",
|
||
btn_confirm_contract: "确认沟通契约并继续 →",
|
||
btn_confirm_final_plan: "确认最终方案 →",
|
||
deriving: "正在根据你的选择生成下游选项…",
|
||
template_selection_required: "请选择自由设计或使用模板;选择使用模板时,至少选择一个工作区。",
|
||
connection_lost: "确认页服务连接中断,正在重试;如果持续失败,请回到聊天窗口走聊天确认。",
|
||
confirmed_title: "✓ 已确认",
|
||
confirmed_hint: "选择已保存,可关闭此页并回到聊天窗口。",
|
||
lang_toggle_title: "切换语言",
|
||
sec_template_choice: "设计基础",
|
||
template_choice_hint: "选择这份演示文稿如何建立设计系统。",
|
||
template_free_title: "根据当前内容从零设计",
|
||
template_free_desc: "不使用可复用模板工作区,由 Strategist 根据当前项目推导视觉系统。",
|
||
template_use_title: "使用模板",
|
||
template_use_desc: "选择一个或多个 Brand、Style、Layout、Deck 或指定工作区。",
|
||
sec_template_library: "模板组合",
|
||
template_library_hint: "每种已注册模板最多选择一个;Brand、Style、Layout、Deck 可以组合使用。",
|
||
sec_template_explicit: "指定模板",
|
||
template_explicit_hint: "本次运行明确提供的精确工作区最多选择一个;显示来源路径供你核对。",
|
||
template_kind_brand: "Brand",
|
||
template_kind_style: "Style",
|
||
template_kind_layout: "Layout",
|
||
template_kind_deck: "Deck",
|
||
template_source_library: "模板库",
|
||
template_source_explicit: "指定地址",
|
||
template_source_path: "来源路径",
|
||
template_select_none: "无",
|
||
template_none_registered: "暂无已注册模板",
|
||
template_none_explicit: "本次运行没有指定模板",
|
||
sec_canvas: "画布格式",
|
||
sec_pages: "页数",
|
||
sec_audience: "目标受众",
|
||
sec_communication: "这份演示要完成什么",
|
||
sec_delivery: "如何使用、之后留下什么",
|
||
sec_narrative: "叙事方向",
|
||
sec_visual: "视觉方向",
|
||
sec_color: "色彩方案",
|
||
sec_icons: "图标使用",
|
||
sec_type: "字体方案",
|
||
sec_images: "图片使用",
|
||
sec_image_production: "图片生产",
|
||
sec_proactive_execution: "主动执行",
|
||
sec_mode: "生成模式",
|
||
sec_refine: "先审核设计规范",
|
||
sec_design_directions: "成套设计方向",
|
||
design_directions_hint: "AI 最倾向的成套方案已默认应用;你可以改选其他方案,或在下方微调各项。调整后可用“恢复原方案”还原整套预设。",
|
||
direction_active: "已应用",
|
||
direction_adjusted: "已调整",
|
||
direction_apply_hint: "点击应用这套完整方案。",
|
||
direction_restore: "恢复原方案",
|
||
scheme_component_options: "项目专属自定义方案 · 选中卡片后可编辑",
|
||
sec_template_application: "模板应用方式",
|
||
template_application_hint: "AI 会根据当前内容推荐如何使用已安装模板;你可以直接用自然语言修改。",
|
||
placeholder_template_application: "说明使用、跳过、重复或重排哪些模板页面/原型,哪些内容必须保留,哪些可以替换或重组。",
|
||
sub_mode: "叙事模式",
|
||
sub_visual: "视觉风格",
|
||
sub_divergence: "材料发散度(多大程度重塑,还是贴近源材料)",
|
||
placeholder_divergence: "用你自己的话写,例如「严格贴着文档来」/「在源材料范围内自由重组并展开」。留空则按平衡处理。",
|
||
communication_intent: "这份演示文稿需要完成什么?",
|
||
communication_intent_hint: "开放回答,可按需组合:告知、解释、说服、决策、对齐、教学、汇报与问责、动员、留档与交接。必要时说明主次或先后,不需要选择标签。",
|
||
placeholder_communication_intent: "例如:先汇报进展并暴露风险,再推动管理层决定下一阶段投入。",
|
||
audience_outcome: "期望的受众变化 / 成功条件",
|
||
placeholder_audience_outcome: "结束后,受众应该知道、理解、相信、决定或采取什么行动?",
|
||
core_message: "核心信息 / 决策请求 / 行动",
|
||
placeholder_core_message: "即使其他内容没有被记住,受众至少需要接住哪些主张、请求或行动?",
|
||
delivery_context: "传递场景(明确主要模式)",
|
||
delivery_context_hint: "区分演讲者主导、读者主导、混合、录制/自动播放;混合场景要说明哪一种主导,以及还要兼顾什么次要用途。",
|
||
placeholder_delivery_context: "例如:主要为有主讲的 20 分钟管理层现场评审;次要为会后独立阅读的审批材料。",
|
||
artifact_afterlife: "演示后的成果用途",
|
||
placeholder_artifact_afterlife: "例如:审批、评审、审计、留档、交接或复用;没有后续用途时可留空。",
|
||
stage1_current_value_hint: "可编辑字段中是推荐内容。你可以保留、修改或清空;确认时会按当前内容原样保存,空白也会保持为空。",
|
||
content_divergence_locked_hint: "当前流程要求原文和页面结构保持不变,因此该字段已锁定。",
|
||
custom: "自定义",
|
||
custom_placeholder: "输入自定义内容…",
|
||
ai_custom_candidate: "AI 自定义方案",
|
||
ai_custom_candidate_hint: "始终展示完整内容用于比较;默认不选中,选择后可编辑。",
|
||
custom_behavior_required: "已选择的 AI 自定义方案不能为空。",
|
||
custom_color_required: "请先填写自定义配色说明,再继续确认。",
|
||
design_system_required: "请先选择完整的配色与字体方案,再继续确认。",
|
||
mode_behavior_placeholder: "描述叙事阶段、标题语气、页面节奏和表达姿态。",
|
||
visual_style_behavior_placeholder: "描述形状语言、构图、装饰密度、留白、字体气质和纹理。",
|
||
recommended: "推荐",
|
||
placeholder_audience: "这份演示文稿面向谁?",
|
||
placeholder_pages: "如:12-15",
|
||
hex_override: "自定义色值覆盖:",
|
||
formula_policy: "公式渲染策略",
|
||
image_ai_path: "生成配图来源",
|
||
image_strategy: "生成图风格",
|
||
image_strategy_empty: "当前没有可用的预设风格参考,仍可使用自定义风格。",
|
||
image_strategy_required: "请选择一种生成图预设,或填写自定义风格。",
|
||
image_strategy_invalid: "所选生成图预设当前不可用。",
|
||
image_strategy_select_placeholder: "选择生成图预设…",
|
||
image_strategy_recommended_group: "本项目推荐",
|
||
image_strategy_all_group: "全部预设风格",
|
||
image_strategy_rendering: "渲染风格",
|
||
image_strategy_visual: "视觉",
|
||
image_strategy_mood: "情绪",
|
||
image_strategy_ai_custom: "AI 自定义方案",
|
||
image_strategy_ai_custom_desc: "一套全新或综合多个已有风格的渲染方案;选择后可以编辑。",
|
||
image_strategy_custom_placeholder: "描述生成图的具体方向、主体、构图、风格关键词或需要避免的内容。",
|
||
image_strategy_reference_hint: "参考图只展示渲染风格;最终 AI 图片直接继承上方已选的整套 PPT 配色。",
|
||
image_strategy_no_reference: "自定义选择没有参考图。",
|
||
image_source_summary: "已选图片来源",
|
||
image_production_hint: "图片来源和渲染方向已在上方选择;这里仅决定实际生产路径。",
|
||
image_usage_notes: "图片补充要求",
|
||
image_usage_notes_placeholder: "例如:优先真实洗手场景;不要卡通病菌;产品照片保持原样。",
|
||
image_usage_required: "请至少选择一种图片使用方式。",
|
||
image_usage_none_exclusive: "「不使用图片」不能和其它图片选项同时选择。",
|
||
proactive_execution_hint: "这些默认开关只在你没有明确要求时生效;你最新的明确指令始终优先。",
|
||
proactive_speaker_notes: "主动生成演讲者备注",
|
||
proactive_speaker_notes_desc: "默认开启。无需另行要求,Agent 也会生成演讲者备注。",
|
||
proactive_custom_animations: "主动生成自定义动画",
|
||
proactive_custom_animations_desc: "默认关闭。策略师的动画建议仍会保留;开启后,Agent 可在没有另行要求时实际制作自定义动画。",
|
||
proactive_narration_audio: "主动生成旁白音频",
|
||
proactive_narration_audio_desc: "默认关闭。这里保留原始选择,不改写演讲者备注开关;策略师会在设计规范中解析旁白所需的最终备注状态。",
|
||
font_heading: "标题",
|
||
font_body: "正文",
|
||
font_selection: "字体选择",
|
||
primary_language_font: "主要语言字体",
|
||
english_font: "英文字体",
|
||
font_picker_hint: "选择推荐方案会同步下方字体;修改任一下拉或手动字体后会标记为已自定义。",
|
||
other_installed_font: "其他已安装字体…",
|
||
other_font_placeholder: "输入精确的已安装字体名称",
|
||
customized: "已自定义",
|
||
font_body_size: "正文基准字号",
|
||
font_body_size_hint: "所有字号按这个正文基准推导。",
|
||
body_size_unit_relation: "SVG px 与 PPT pt 的换算:1px = 0.75pt。",
|
||
body_size_pt_hint: "约 {pt} pt(按 1px = 0.75pt 换算;提交仍保存 px)。",
|
||
role_size_pt_hint: "约 {pt} pt",
|
||
body_size_hint_canvas: "当前画布建议 ~{lo}–{hi}px(按有效画布跨度计算)。",
|
||
body_size_hint_purpose: "该阅读模式推荐 {def}px(单一固定值,非区间)。",
|
||
body_size_hint_oor: "(当前数值超出该画布的常用范围——请确认单位无误、是否合适。)",
|
||
delivery_purpose: "阅读模式",
|
||
delivery_purpose_hint: "决定信息主要由页面还是讲者承担:近读型用完整句、短段落和细节自洽;演讲型一页一意,以简短主张和视觉证据为主。",
|
||
size_override: "逐角色字号覆盖:",
|
||
size_role_title: "标题",
|
||
size_role_subtitle: "副标题",
|
||
size_role_annotation: "注释",
|
||
custom_typography: "自定义字体方案",
|
||
custom_color: "自定义配色",
|
||
custom_color_placeholder: "用文字描述配色,如:深蓝主色、暖橙强调、白色背景——或直接粘贴 HEX 值…",
|
||
role_background: "背景",
|
||
role_secondary_bg: "次级背景",
|
||
role_primary: "主色",
|
||
role_accent: "强调",
|
||
role_secondary_accent: "次强调",
|
||
role_body_text: "正文文字",
|
||
cjk: "中文",
|
||
latin: "西文",
|
||
sample_heading_cjk: "主题方案标题",
|
||
sample_heading_latin: "Presentation Title",
|
||
sample_body_cjk: "关键信息摘要",
|
||
sample_body_latin: "Key message summary",
|
||
style_preview_label: "整体形象(配色 + 字体 + 图标)",
|
||
style_preview_body: "· 仅大致形象,非实际版式",
|
||
no_icons: "无图标",
|
||
preview_big_title: "大标题",
|
||
preview_section_title: "章节标题",
|
||
preview_latin_title: "Section Title",
|
||
preview_body_intro: "正文内容用于判断基础字号、行距和颜色对比。",
|
||
preview_latin_body: "Body text sample for checking Latin typography.",
|
||
preview_point_1_title: "正文内容",
|
||
preview_point_1_text: "这里展示普通段落的密度和阅读节奏。",
|
||
preview_point_2_title: "要点说明",
|
||
preview_point_2_text: "图标和文字放在一起,判断真实使用效果。",
|
||
preview_point_3_title: "结论建议",
|
||
preview_point_3_text: "组合效果需要在演示场景下保持清晰可读。",
|
||
mode_continuous_desc: "一次性连续生成整份演示文稿。",
|
||
mode_split_desc: "写完设计规范后停止,另开窗口继续生成页面。",
|
||
refine_off_desc: "依次生成设计规范和执行锁,然后自动继续。",
|
||
refine_on_desc: "生成设计规范后暂停;你可在聊天中修改任何部分,明确确认后再生成执行锁并继续制作。",
|
||
off_default: "关",
|
||
on: "开",
|
||
option_prefix: "方案",
|
||
error_retry: "出错,请重试"
|
||
},
|
||
"zh-TW": {
|
||
page_title: "確認設計方案",
|
||
topbar_hint: "回答開放問題,或選擇並調整推薦項,然後繼續。",
|
||
stage_anchors: "第一階段 · 溝通契約",
|
||
stage_final_plan: "第二階段 · 最終方案與製作",
|
||
loading: "載入中…",
|
||
load_error: "無法載入推薦檔案,需在啟動前寫入。",
|
||
btn_confirm: "確認",
|
||
btn_confirm_contract: "確認溝通契約並繼續 →",
|
||
btn_confirm_final_plan: "確認最終方案 →",
|
||
deriving: "正在根據你的選擇生成下游選項…",
|
||
template_selection_required: "請選擇自由設計或使用範本;選擇使用範本時,至少選擇一個工作區。",
|
||
connection_lost: "確認頁服務連線中斷,正在重試;如果持續失敗,請回到聊天視窗走聊天確認。",
|
||
confirmed_title: "✓ 已確認",
|
||
confirmed_hint: "選擇已儲存,可關閉此頁並回到聊天視窗。",
|
||
lang_toggle_title: "切換語言",
|
||
sec_template_choice: "設計基礎",
|
||
template_choice_hint: "選擇這份簡報如何建立設計系統。",
|
||
template_free_title: "根據目前內容從零設計",
|
||
template_free_desc: "不使用可重複使用的範本工作區,由 Strategist 根據目前專案推導視覺系統。",
|
||
template_use_title: "使用範本",
|
||
template_use_desc: "選擇一個或多個 Brand、Style、Layout、Deck 或指定工作區。",
|
||
sec_template_library: "範本組合",
|
||
template_library_hint: "每種已註冊範本最多選擇一個;Brand、Style、Layout、Deck 可以組合使用。",
|
||
sec_template_explicit: "指定範本",
|
||
template_explicit_hint: "本次執行明確提供的精確工作區最多選擇一個;顯示來源路徑供你核對。",
|
||
template_kind_brand: "Brand",
|
||
template_kind_style: "Style",
|
||
template_kind_layout: "Layout",
|
||
template_kind_deck: "Deck",
|
||
template_source_library: "範本庫",
|
||
template_source_explicit: "指定地址",
|
||
template_source_path: "來源路徑",
|
||
template_select_none: "無",
|
||
template_none_registered: "尚無已註冊範本",
|
||
template_none_explicit: "本次執行沒有指定範本",
|
||
sec_canvas: "畫布格式",
|
||
sec_pages: "頁數",
|
||
sec_audience: "目標受眾",
|
||
sec_communication: "這份簡報要完成什麼",
|
||
sec_delivery: "如何使用、之後留下什麼",
|
||
sec_narrative: "敘事方向",
|
||
sec_visual: "視覺方向",
|
||
sec_color: "色彩方案",
|
||
sec_icons: "圖示使用",
|
||
sec_type: "字型方案",
|
||
sec_images: "圖片使用",
|
||
sec_image_production: "圖片產製",
|
||
sec_proactive_execution: "主動執行",
|
||
sec_mode: "生成模式",
|
||
sec_refine: "先審閱設計規範",
|
||
sec_design_directions: "成套設計方向",
|
||
design_directions_hint: "AI 最傾向的成套方案已預設套用;你可以改選其他方案,或在下方微調各項。調整後可用「還原原始方案」還原整套預設。",
|
||
direction_active: "已套用",
|
||
direction_adjusted: "已調整",
|
||
direction_apply_hint: "按一下即可套用這套完整方案。",
|
||
direction_restore: "還原原始方案",
|
||
scheme_component_options: "專案專屬自訂方案 · 選取卡片後可編輯",
|
||
sec_template_application: "範本套用方式",
|
||
template_application_hint: "AI 會根據目前內容推薦如何使用已安裝範本;你可以直接用自然語言修改。",
|
||
placeholder_template_application: "說明使用、跳過、重複或重排哪些範本頁面/原型,哪些內容必須保留,哪些可以替換或重組。",
|
||
sub_mode: "敘事模式",
|
||
sub_visual: "視覺風格",
|
||
sub_divergence: "材料發散度(多大程度重塑,還是貼近源材料)",
|
||
placeholder_divergence: "用你自己的話寫,例如「嚴格貼著文件來」/「在源材料範圍內自由重組並展開」。留空則按平衡處理。",
|
||
communication_intent: "這份簡報需要完成什麼?",
|
||
communication_intent_hint: "開放回答,可按需組合:告知、解釋、說服、決策、對齊、教學、報告與問責、動員、留檔與交接。必要時說明主次或先後,不需要選擇標籤。",
|
||
placeholder_communication_intent: "例如:先報告進展並暴露風險,再推動管理階層決定下一階段投入。",
|
||
audience_outcome: "期望的受眾變化 / 成功條件",
|
||
placeholder_audience_outcome: "結束後,受眾應該知道、理解、相信、決定或採取什麼行動?",
|
||
core_message: "核心資訊 / 決策請求 / 行動",
|
||
placeholder_core_message: "即使其他內容沒有被記住,受眾至少需要接住哪些主張、請求或行動?",
|
||
delivery_context: "傳遞場景(明確主要模式)",
|
||
delivery_context_hint: "區分演講者主導、讀者主導、混合、錄製/自動播放;混合場景要說明哪一種主導,以及還要兼顧什麼次要用途。",
|
||
placeholder_delivery_context: "例如:主要為有主講的 20 分鐘管理階層現場評審;次要為會後獨立閱讀的簽核文件。",
|
||
artifact_afterlife: "簡報後的成果用途",
|
||
placeholder_artifact_afterlife: "例如:簽核、評審、稽核、留檔、交接或重複使用;沒有後續用途時可留空。",
|
||
stage1_current_value_hint: "可編輯欄位中是推薦內容。你可以保留、修改或清空;確認時會按目前內容原樣儲存,空白也會保持為空。",
|
||
content_divergence_locked_hint: "目前流程要求原文和頁面結構保持不變,因此該欄位已鎖定。",
|
||
custom: "自訂",
|
||
custom_placeholder: "輸入自訂內容…",
|
||
ai_custom_candidate: "AI 自訂方案",
|
||
ai_custom_candidate_hint: "始終展示完整內容用於比較;預設不選取,選擇後可編輯。",
|
||
custom_behavior_required: "已選擇的 AI 自訂方案不能為空。",
|
||
custom_color_required: "請先填寫自訂配色說明,再繼續確認。",
|
||
design_system_required: "請先選擇完整的配色與字型方案,再繼續確認。",
|
||
mode_behavior_placeholder: "描述敘事階段、標題語氣、頁面節奏和表達姿態。",
|
||
visual_style_behavior_placeholder: "描述形狀語言、構圖、裝飾密度、留白、字型氣質和紋理。",
|
||
recommended: "推薦",
|
||
placeholder_audience: "這份簡報面向誰?",
|
||
placeholder_pages: "如:12-15",
|
||
hex_override: "自訂色值覆蓋:",
|
||
formula_policy: "公式渲染策略",
|
||
image_ai_path: "生成配圖來源",
|
||
image_strategy: "生成圖風格",
|
||
image_strategy_empty: "目前沒有可用的預設風格參考,仍可使用自訂風格。",
|
||
image_strategy_required: "請選擇一種生成圖預設,或填寫自訂風格。",
|
||
image_strategy_invalid: "所選生成圖預設目前不可用。",
|
||
image_strategy_select_placeholder: "選擇生成圖預設…",
|
||
image_strategy_recommended_group: "本專案推薦",
|
||
image_strategy_all_group: "全部預設風格",
|
||
image_strategy_rendering: "渲染風格",
|
||
image_strategy_visual: "視覺",
|
||
image_strategy_mood: "情緒",
|
||
image_strategy_ai_custom: "AI 自訂方案",
|
||
image_strategy_ai_custom_desc: "一套全新或綜合多個已有風格的渲染方案;選擇後可以編輯。",
|
||
image_strategy_custom_placeholder: "描述生成圖的具體方向、主體、構圖、風格關鍵字或需要避免的內容。",
|
||
image_strategy_reference_hint: "參考圖只展示渲染風格;最終 AI 圖片直接繼承上方已選的整套 PPT 配色。",
|
||
image_strategy_no_reference: "自訂選擇沒有參考圖。",
|
||
image_source_summary: "已選圖片來源",
|
||
image_production_hint: "圖片來源和渲染方向已在上方選擇;這裡僅決定實際產製路徑。",
|
||
image_usage_notes: "圖片補充要求",
|
||
image_usage_notes_placeholder: "例如:優先真實洗手場景;不要卡通病菌;產品照片保持原樣。",
|
||
image_usage_required: "請至少選擇一種圖片使用方式。",
|
||
image_usage_none_exclusive: "「不使用圖片」不能和其他圖片選項同時選擇。",
|
||
proactive_execution_hint: "這些預設開關只在你沒有明確要求時生效;你最新的明確指令始終優先。",
|
||
proactive_speaker_notes: "主動生成演講者備註",
|
||
proactive_speaker_notes_desc: "預設開啟。無需另行要求,Agent 也會生成演講者備註。",
|
||
proactive_custom_animations: "主動生成自訂動畫",
|
||
proactive_custom_animations_desc: "預設關閉。策略師的動畫建議仍會保留;開啟後,Agent 可在沒有另行要求時實際製作自訂動畫。",
|
||
proactive_narration_audio: "主動生成旁白音訊",
|
||
proactive_narration_audio_desc: "預設關閉。這裡保留原始選擇,不改寫演講者備註開關;策略師會在設計規範中解析旁白所需的最終備註狀態。",
|
||
font_heading: "標題",
|
||
font_body: "正文",
|
||
font_selection: "字型選擇",
|
||
primary_language_font: "主要語言字型",
|
||
english_font: "英文字型",
|
||
font_picker_hint: "選擇推薦方案會同步下方字型;修改任一下拉或手動字型後會標記為已自訂。",
|
||
other_installed_font: "其他已安裝字型…",
|
||
other_font_placeholder: "輸入精確的已安裝字型名稱",
|
||
customized: "已自訂",
|
||
font_body_size: "正文基準字級",
|
||
font_body_size_hint: "所有字級按這個正文基準推導。",
|
||
body_size_unit_relation: "SVG px 與 PPT pt 的換算:1px = 0.75pt。",
|
||
body_size_pt_hint: "約 {pt} pt(按 1px = 0.75pt 換算;提交仍儲存 px)。",
|
||
role_size_pt_hint: "約 {pt} pt",
|
||
body_size_hint_canvas: "目前畫布建議 ~{lo}–{hi}px(依有效畫布跨距計算)。",
|
||
body_size_hint_purpose: "該閱讀模式推薦 {def}px(單一固定值,非區間)。",
|
||
body_size_hint_oor: "(目前數值超出該畫布的常用範圍——請確認單位無誤、是否合適。)",
|
||
delivery_purpose: "閱讀模式",
|
||
delivery_purpose_hint: "決定資訊主要由頁面還是講者承擔:近讀型用完整句、短段落和細節自洽;演講型一頁一意,以簡短主張和視覺證據為主。",
|
||
size_override: "逐角色字級覆蓋:",
|
||
size_role_title: "標題",
|
||
size_role_subtitle: "副標題",
|
||
size_role_annotation: "註解",
|
||
custom_typography: "自訂字型方案",
|
||
custom_color: "自訂配色",
|
||
custom_color_placeholder: "用文字描述配色,如:深藍主色、暖橙強調、白色背景——或直接貼上 HEX 值…",
|
||
role_background: "背景",
|
||
role_secondary_bg: "次級背景",
|
||
role_primary: "主色",
|
||
role_accent: "強調",
|
||
role_secondary_accent: "次強調",
|
||
role_body_text: "正文文字",
|
||
cjk: "中文",
|
||
latin: "西文",
|
||
sample_heading_cjk: "主題方案標題",
|
||
sample_heading_latin: "Presentation Title",
|
||
sample_body_cjk: "關鍵資訊摘要",
|
||
sample_body_latin: "Key message summary",
|
||
style_preview_label: "整體形象(配色 + 字型 + 圖示)",
|
||
style_preview_body: "· 僅大致形象,非實際版式",
|
||
no_icons: "無圖示",
|
||
preview_big_title: "大標題",
|
||
preview_section_title: "章節標題",
|
||
preview_latin_title: "Section Title",
|
||
preview_body_intro: "正文內容用於判斷基礎字級、行距和顏色對比。",
|
||
preview_latin_body: "Body text sample for checking Latin typography.",
|
||
preview_point_1_title: "正文內容",
|
||
preview_point_1_text: "這裡展示普通段落的密度和閱讀節奏。",
|
||
preview_point_2_title: "要點說明",
|
||
preview_point_2_text: "圖示和文字放在一起,判斷真實使用效果。",
|
||
preview_point_3_title: "結論建議",
|
||
preview_point_3_text: "組合效果需要在簡報場景下保持清晰可讀。",
|
||
mode_continuous_desc: "一次性連續生成整份簡報。",
|
||
mode_split_desc: "寫完設計規範後停止,另開視窗繼續生成頁面。",
|
||
refine_off_desc: "依次生成設計規範和執行鎖,然後自動繼續。",
|
||
refine_on_desc: "生成設計規範後暫停;你可在聊天中修改任何部分,明確確認後再生成執行鎖並繼續製作。",
|
||
off_default: "關",
|
||
on: "開",
|
||
option_prefix: "方案",
|
||
error_retry: "出錯,請重試"
|
||
}
|
||
};
|
||
|
||
var LANG = (function () {
|
||
try {
|
||
var stored = window.localStorage.getItem("ppt_lang");
|
||
if (stored === "zh" || stored === "en" || stored === "ja" ||
|
||
stored === "zh-TW") return stored;
|
||
} catch (e) { /* ignore */ }
|
||
var nav = (navigator.language || navigator.userLanguage || "en").toLowerCase();
|
||
if (nav.indexOf("zh") === 0) {
|
||
if (/\bhans\b/.test(nav)) return "zh";
|
||
if (/\bhant\b/.test(nav) || /\b(tw|hk|mo)\b/.test(nav)) return "zh-TW";
|
||
return "zh";
|
||
}
|
||
if (nav.indexOf("ja") === 0) return "ja";
|
||
return "en";
|
||
})();
|
||
|
||
function t(key) {
|
||
var dict = MESSAGES[LANG] || MESSAGES.en;
|
||
return dict[key] != null ? dict[key] : key;
|
||
}
|
||
|
||
// Preserve the existing locale order, then accept zh_tw-only candidate
|
||
// prose from any persisted UI language so browser and server validation agree.
|
||
// Entries are FIELD SUFFIXES, not BCP-47 tags: "zh-TW" data lives in
|
||
// `<base>_zh_tw` keys, so a hyphenless suffix is used here and in langField().
|
||
var LANG_FALLBACK = {
|
||
zh: ["zh", "en", "ja", "zh_tw"],
|
||
en: ["en", "zh", "ja", "zh_tw"],
|
||
ja: ["ja", "en", "zh", "zh_tw"],
|
||
"zh-TW": ["zh_tw", "zh", "en", "ja"]
|
||
};
|
||
// Suffix used to look up localized catalog/recommendation fields.
|
||
var LANG_FIELD = { "zh-TW": "zh_tw" };
|
||
|
||
function langField(lang) {
|
||
return LANG_FIELD[lang] || lang;
|
||
}
|
||
var IMAGE_COMPARISON_LABELS = {
|
||
rendering: {
|
||
"vector-illustration": { zh: "矢量插画", zh_tw: "向量插畫", en: "Vector illustration", ja: "ベクターイラスト" },
|
||
flat: { zh: "扁平插画", zh_tw: "扁平插畫", en: "Flat illustration", ja: "フラットイラスト" },
|
||
"3d-isometric": { zh: "3D 等距", zh_tw: "3D 等距", en: "3D isometric", ja: "3Dアイソメトリック" },
|
||
"digital-dashboard": { zh: "数字仪表盘", zh_tw: "數位儀表板", en: "Digital dashboard", ja: "デジタルダッシュボード" },
|
||
"corporate-photo": { zh: "企业摄影", zh_tw: "企業攝影", en: "Corporate photo", ja: "企業写真" },
|
||
blueprint: { zh: "蓝图线稿", zh_tw: "藍圖線稿", en: "Blueprint", ja: "ブループリント" },
|
||
editorial: { zh: "编辑杂志", zh_tw: "編輯雜誌", en: "Editorial", ja: "エディトリアル" },
|
||
"sketch-notes": { zh: "手绘笔记", zh_tw: "手繪筆記", en: "Sketch notes", ja: "スケッチノート" },
|
||
"ink-notes": { zh: "墨线笔记", zh_tw: "墨線筆記", en: "Ink notes", ja: "インクノート" },
|
||
chalkboard: { zh: "粉笔黑板", zh_tw: "粉筆黑板", en: "Chalkboard", ja: "チョークボード" },
|
||
watercolor: { zh: "水彩", zh_tw: "水彩", en: "Watercolor", ja: "水彩" },
|
||
"warm-scene": { zh: "暖调场景", zh_tw: "暖調場景", en: "Warm scene", ja: "暖色シーン" },
|
||
"screen-print": { zh: "丝网印刷", zh_tw: "絲網印刷", en: "Screen print", ja: "スクリーンプリント" },
|
||
"fantasy-animation": { zh: "幻想动画", zh_tw: "幻想動畫", en: "Fantasy animation", ja: "ファンタジーアニメ" },
|
||
"pixel-art": { zh: "像素艺术", zh_tw: "像素藝術", en: "Pixel art", ja: "ピクセルアート" },
|
||
nature: { zh: "自然有机", zh_tw: "自然有機", en: "Nature", ja: "自然・オーガニック" },
|
||
"minimalist-swiss": { zh: "瑞士极简", zh_tw: "瑞士極簡", en: "Minimalist Swiss", ja: "スイスミニマル" },
|
||
glassmorphism: { zh: "玻璃拟态", zh_tw: "玻璃擬態", en: "Glassmorphism", ja: "グラスモーフィズム" },
|
||
"vintage-poster": { zh: "复古海报", zh_tw: "復古海報", en: "Vintage poster", ja: "ヴィンテージポスター" },
|
||
"paper-cut": { zh: "剪纸拼贴", zh_tw: "剪紙拼貼", en: "Paper cut", ja: "ペーパーカット" }
|
||
}
|
||
};
|
||
|
||
function localized(obj, base) {
|
||
if (!obj) return "";
|
||
var langKey = base + "_" + langField(LANG);
|
||
if (obj[langKey] != null) return obj[langKey];
|
||
var order = LANG_FALLBACK[LANG] || LANG_FALLBACK.en;
|
||
var i;
|
||
if (obj[base] != null) {
|
||
if (typeof obj[base] === "object") {
|
||
for (i = 0; i < order.length; i++) {
|
||
if (obj[base][order[i]]) return obj[base][order[i]];
|
||
}
|
||
return "";
|
||
}
|
||
return obj[base];
|
||
}
|
||
for (i = 0; i < order.length; i++) {
|
||
if (obj[base + "_" + order[i]]) return obj[base + "_" + order[i]];
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function optionLabel(option) {
|
||
return localized(option, "label") || String(option && option.id);
|
||
}
|
||
|
||
function optionDesc(option) {
|
||
return localized(option, "desc");
|
||
}
|
||
|
||
function groupLabel(group) {
|
||
return localized(group, "group");
|
||
}
|
||
|
||
function humanizeId(value) {
|
||
return String(value || "")
|
||
.replace(/[_-]+/g, " ")
|
||
.replace(/\b[a-z]/g, function (match) { return match.toUpperCase(); });
|
||
}
|
||
|
||
function langMappedLabel(kind, id) {
|
||
if (!id) return "";
|
||
if (id === "custom") return t("custom");
|
||
var entry = IMAGE_COMPARISON_LABELS[kind] && IMAGE_COMPARISON_LABELS[kind][id];
|
||
if (!entry) return "";
|
||
var order = LANG_FALLBACK[LANG] || LANG_FALLBACK.en;
|
||
for (var i = 0; i < order.length; i += 1) {
|
||
if (entry[order[i]]) return entry[order[i]];
|
||
}
|
||
return entry.en || "";
|
||
}
|
||
|
||
function comparisonValueLabel(kind, id) {
|
||
return langMappedLabel(kind, id) || humanizeId(id);
|
||
}
|
||
|
||
function applyStaticTranslations() {
|
||
document.documentElement.setAttribute("lang", LANG === "zh" ? "zh-CN" : (LANG === "zh-TW" ? "zh-TW" : (LANG === "ja" ? "ja" : "en")));
|
||
document.querySelectorAll("[data-i18n]").forEach(function (node) {
|
||
node.textContent = t(node.getAttribute("data-i18n"));
|
||
});
|
||
}
|
||
|
||
var LANG_NAMES = { zh: "中文", en: "English", ja: "日本語", "zh-TW": "繁體中文" };
|
||
|
||
function refreshLangToggle(toggleBtn) {
|
||
// Custom dropdown (OS-independent): button shows the CURRENT language.
|
||
var cur = document.getElementById("lang-current");
|
||
if (cur) cur.textContent = LANG_NAMES[LANG] || LANG;
|
||
toggleBtn.title = t("lang_toggle_title");
|
||
document.querySelectorAll("#lang-menu li").forEach(function (li) {
|
||
var selected = li.getAttribute("data-lang") === LANG;
|
||
li.classList.toggle("selected", selected);
|
||
li.setAttribute("aria-selected", selected ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
// ---- state -----------------------------------------------------------
|
||
var CAT = null; // catalogs.json — finite option universe
|
||
var REC = null; // current recommendation stage — AI picks + candidates
|
||
var ICON_PREVIEWS = {}; // /api/icon-previews — real SVG samples from templates/icons
|
||
var AI_IMAGE_COMPARISON = {}; // /api/ai-image-comparison — preset rendering catalog
|
||
var STATE = {};
|
||
var ACTIVE_DIRECTION_ID = "";
|
||
var ACTIVE_DIRECTION_BASELINE = "";
|
||
var ACTIVE_COMPONENT_DIRECTION_IDS = {};
|
||
var refreshDesignDirectionState = function () {};
|
||
var DIRECTION_COMPONENT_PAINTERS = [];
|
||
|
||
function refreshDirectionComponentStates() {
|
||
DIRECTION_COMPONENT_PAINTERS.forEach(function (paint) { paint(); });
|
||
}
|
||
var TEMPLATE_KINDS = ["brand", "style", "layout", "deck"];
|
||
var TEMPLATE_OPTIONS = null;
|
||
var TEMPLATE_CANDIDATES = [];
|
||
var TEMPLATE_SELECTED_KEYS = [];
|
||
var TEMPLATE_SELECTIONS = { brand: "", style: "", layout: "", deck: "", explicit: "" };
|
||
var TEMPLATE_MODE = "";
|
||
var REC_ALIASES = {
|
||
icons: {
|
||
line: "tabler-outline",
|
||
filled: "tabler-filled",
|
||
monochrome: "chunk-filled"
|
||
},
|
||
image_usage: {
|
||
search: "web"
|
||
},
|
||
image_ai_path: {
|
||
default: "auto",
|
||
builtin: "host-native"
|
||
}
|
||
};
|
||
|
||
// ---- DOM helpers -----------------------------------------------------
|
||
function el(tag, cls, text) {
|
||
var node = document.createElement(tag);
|
||
if (cls) node.className = cls;
|
||
if (text != null) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function fitTextareaToContent(input) {
|
||
if (!input) return;
|
||
window.requestAnimationFrame(function () {
|
||
if (!input.isConnected || input.offsetParent === null) return;
|
||
input.style.height = "auto";
|
||
var borderHeight = input.offsetHeight - input.clientHeight;
|
||
input.style.height = (input.scrollHeight + borderHeight) + "px";
|
||
});
|
||
}
|
||
|
||
function previewNode(kind, id) {
|
||
var node = el("div", "option-preview option-preview-" + kind);
|
||
node.setAttribute("aria-hidden", "true");
|
||
if (kind === "visual_style") {
|
||
appendVisualStyleImage(node, id);
|
||
return node;
|
||
}
|
||
var markup = kind === "icons" ? iconStylePreview(id) : "";
|
||
if (!markup) return null;
|
||
node.innerHTML = markup;
|
||
return node;
|
||
}
|
||
|
||
function visualStylePreviewSrc(id) {
|
||
return "/static/style_previews/" + encodeURIComponent(id || "") + ".svg";
|
||
}
|
||
|
||
function appendVisualStyleImage(parent, id) {
|
||
var img = document.createElement("img");
|
||
img.alt = "";
|
||
img.loading = "lazy";
|
||
img.src = visualStylePreviewSrc(id);
|
||
img.onerror = function () {
|
||
parent.innerHTML = visualStylePreview(id);
|
||
};
|
||
parent.appendChild(img);
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value == null ? "" : value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function visualStylePreview(id) {
|
||
var label = escapeHtml(humanizeId(id) || t("sub_visual"));
|
||
var fallback = escapeHtml(t("error_retry"));
|
||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">' +
|
||
'<rect width="1280" height="720" fill="#F8FAFC"/>' +
|
||
'<path d="M0 160H1280M0 360H1280M0 560H1280M220 0V720M640 0V720M1060 0V720" stroke="#E2E8F0" stroke-width="2"/>' +
|
||
'<rect x="116" y="96" width="1048" height="528" rx="28" fill="#FFFFFF" stroke="#CBD5E1" stroke-width="3"/>' +
|
||
'<rect x="164" y="148" width="360" height="34" rx="17" fill="#111827"/>' +
|
||
'<rect x="164" y="228" width="520" height="20" rx="10" fill="#CBD5E1"/>' +
|
||
'<rect x="164" y="274" width="440" height="20" rx="10" fill="#E2E8F0"/>' +
|
||
'<rect x="760" y="188" width="292" height="236" rx="22" fill="#EFF6FF" stroke="#BFDBFE" stroke-width="3"/>' +
|
||
'<path d="M808 378L874 308L942 348L1012 258" fill="none" stroke="#2563EB" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>' +
|
||
'<circle cx="1012" cy="258" r="22" fill="#0F172A"/>' +
|
||
'<rect x="164" y="464" width="220" height="24" rx="12" fill="#94A3B8"/>' +
|
||
'<text x="164" y="548" fill="#475569" font-family="Arial, sans-serif" font-size="34">' + label + '</text>' +
|
||
'<text x="164" y="594" fill="#94A3B8" font-family="Arial, sans-serif" font-size="24">' + fallback + '</text>' +
|
||
'</svg>';
|
||
}
|
||
|
||
function iconStylePreview(id) {
|
||
var common = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 100"';
|
||
var samples = ICON_PREVIEWS[id] || [];
|
||
if (samples.length) {
|
||
var sample = samples[0] || {};
|
||
return '<div class="real-icon-preview real-icon-preview-' + escapeHtml(id) + '">' +
|
||
'<div class="real-icon-sample"><div class="real-icon-mark">' +
|
||
(sample.svg || "") +
|
||
'</div><span class="real-icon-label">' + escapeHtml(sample.name || "") + '</span></div>' +
|
||
'</div>';
|
||
}
|
||
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>';
|
||
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>';
|
||
return "";
|
||
}
|
||
|
||
function comparisonImageUrl(kind, id) {
|
||
var value = String(id || "").trim();
|
||
if (!value || value === "custom") return "";
|
||
if (!/^[A-Za-z0-9_.-]+$/.test(value)) return "";
|
||
return "/ai-image-comparison/" + kind + "/" + encodeURIComponent(value) + ".png";
|
||
}
|
||
|
||
function appendImageStrategyPreviews(card, candidate) {
|
||
if (candidate.rendering === "custom") return;
|
||
var previews = [
|
||
[t("image_strategy_rendering"), comparisonImageUrl("rendering", candidate.rendering)]
|
||
].filter(function (item) { return item[1]; });
|
||
if (!previews.length) return;
|
||
var row = el("div", "image-strategy-previews");
|
||
previews.forEach(function (item) {
|
||
var frame = el("div", "image-strategy-preview");
|
||
var img = document.createElement("img");
|
||
img.alt = item[0];
|
||
img.loading = "lazy";
|
||
img.src = item[1];
|
||
img.onerror = function () {
|
||
frame.remove();
|
||
if (!row.childElementCount) row.remove();
|
||
};
|
||
frame.appendChild(img);
|
||
frame.appendChild(el("span", "image-strategy-preview-label", item[0]));
|
||
row.appendChild(frame);
|
||
});
|
||
card.appendChild(row);
|
||
return row;
|
||
}
|
||
|
||
// Section numbers run 1..N within the stage currently rendered; the counter is
|
||
// reset at the top of renderForStage. The legacy `num` arg is ignored so each
|
||
// stage numbers its own sections cleanly (stage 2 is not a continuation of 1).
|
||
var _secCounter = 0;
|
||
function section(num, titleKey, noteText) {
|
||
_secCounter += 1;
|
||
var sec = el("div", "section");
|
||
var head = el("div", "section-head");
|
||
head.appendChild(el("span", "section-num", String(_secCounter)));
|
||
head.appendChild(el("span", "section-title", t(titleKey)));
|
||
if (noteText) head.appendChild(el("span", "section-note", noteText));
|
||
sec.appendChild(head);
|
||
return sec;
|
||
}
|
||
|
||
function setSectionNote(sec, text) {
|
||
var head = sec.querySelector(".section-head");
|
||
var note = head.querySelector(".section-note");
|
||
if (!note) {
|
||
note = el("span", "section-note");
|
||
head.appendChild(note);
|
||
}
|
||
note.textContent = text;
|
||
}
|
||
|
||
// ---- Stage 1 template selection --------------------------------------
|
||
function normalizeTemplateCandidates(raw, source, kind) {
|
||
if (!Array.isArray(raw)) return [];
|
||
return raw.filter(function (candidate) {
|
||
return candidate && candidate.key != null && String(candidate.key).trim();
|
||
}).map(function (candidate) {
|
||
var normalized = Object.assign({}, candidate);
|
||
normalized.key = String(candidate.key);
|
||
normalized.source = source;
|
||
if (kind) normalized.kind = kind;
|
||
return normalized;
|
||
});
|
||
}
|
||
|
||
function initTemplateOptions(data) {
|
||
var library = data && data.library && typeof data.library === "object"
|
||
? data.library : {};
|
||
var defaultMode = data && data.default_mode;
|
||
if (defaultMode !== "free_design" && defaultMode !== "templates") {
|
||
throw new Error("template_options.default_mode must be free_design or templates");
|
||
}
|
||
var normalized = {
|
||
lang: data && data.lang,
|
||
default_mode: defaultMode,
|
||
library: {},
|
||
explicit: [],
|
||
preselected_keys: Array.isArray(data && data.preselected_keys)
|
||
? data.preselected_keys.map(String) : []
|
||
};
|
||
TEMPLATE_CANDIDATES = [];
|
||
TEMPLATE_KINDS.forEach(function (kind) {
|
||
var candidates = normalizeTemplateCandidates(library[kind], "library", kind);
|
||
normalized.library[kind] = candidates;
|
||
TEMPLATE_CANDIDATES = TEMPLATE_CANDIDATES.concat(candidates);
|
||
});
|
||
normalized.explicit = normalizeTemplateCandidates(data && data.explicit, "explicit", "");
|
||
TEMPLATE_CANDIDATES = TEMPLATE_CANDIDATES.concat(normalized.explicit);
|
||
var candidatesByKey = Object.create(null);
|
||
TEMPLATE_CANDIDATES.forEach(function (candidate) {
|
||
if (candidatesByKey[candidate.key]) {
|
||
throw new Error("Duplicate template option key: " + candidate.key);
|
||
}
|
||
candidatesByKey[candidate.key] = candidate;
|
||
});
|
||
TEMPLATE_SELECTIONS = emptyTemplateSelections();
|
||
normalized.preselected_keys.filter(function (key, index, all) {
|
||
return all.indexOf(key) === index;
|
||
}).forEach(function (key) {
|
||
var candidate = candidatesByKey[key];
|
||
var slot = templateSelectionSlot(candidate);
|
||
if (!candidate || !slot) {
|
||
throw new Error("Invalid preselected template key: " + key);
|
||
}
|
||
if (TEMPLATE_SELECTIONS[slot]) {
|
||
throw new Error("Multiple preselected templates for slot: " + slot);
|
||
}
|
||
TEMPLATE_SELECTIONS[slot] = key;
|
||
});
|
||
syncTemplateSelectionState();
|
||
TEMPLATE_MODE = normalized.default_mode;
|
||
TEMPLATE_OPTIONS = normalized;
|
||
}
|
||
|
||
function emptyTemplateSelections() {
|
||
return { brand: "", style: "", layout: "", deck: "", explicit: "" };
|
||
}
|
||
|
||
function templateSelectionSlot(candidate) {
|
||
if (!candidate) return "";
|
||
if (candidate.source === "explicit") return "explicit";
|
||
if (candidate.source === "library" && TEMPLATE_KINDS.indexOf(candidate.kind) >= 0) {
|
||
return candidate.kind;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function templateCandidateByKey(key) {
|
||
key = String(key || "");
|
||
for (var i = 0; i < TEMPLATE_CANDIDATES.length; i += 1) {
|
||
if (TEMPLATE_CANDIDATES[i].key === key) return TEMPLATE_CANDIDATES[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function syncTemplateSelectionState() {
|
||
TEMPLATE_SELECTED_KEYS = TEMPLATE_KINDS.map(function (kind) {
|
||
return TEMPLATE_SELECTIONS[kind];
|
||
});
|
||
TEMPLATE_SELECTED_KEYS.push(TEMPLATE_SELECTIONS.explicit);
|
||
TEMPLATE_SELECTED_KEYS = TEMPLATE_SELECTED_KEYS.filter(Boolean);
|
||
}
|
||
|
||
function templateCandidateTitle(candidate) {
|
||
return localized(candidate, "label") || candidate.label || candidate.id || candidate.key;
|
||
}
|
||
|
||
function templateCandidateSummary(candidate) {
|
||
return localized(candidate, "summary") || candidate.summary || "";
|
||
}
|
||
|
||
function templateKindLabel(kind) {
|
||
return t("template_kind_" + kind);
|
||
}
|
||
|
||
function chooseFreeDesign() {
|
||
TEMPLATE_SELECTIONS = emptyTemplateSelections();
|
||
syncTemplateSelectionState();
|
||
TEMPLATE_MODE = "free_design";
|
||
updateTemplateSelectionControls();
|
||
}
|
||
|
||
function chooseTemplateMode() {
|
||
TEMPLATE_MODE = "templates";
|
||
updateTemplateSelectionControls();
|
||
}
|
||
|
||
function chooseTemplateForSlot(slot, key) {
|
||
TEMPLATE_SELECTIONS[slot] = String(key || "");
|
||
syncTemplateSelectionState();
|
||
TEMPLATE_MODE = "templates";
|
||
updateTemplateSelectionControls();
|
||
}
|
||
|
||
function templateOptionTitle(candidate) {
|
||
var details = [];
|
||
var summary = templateCandidateSummary(candidate);
|
||
if (summary) details.push(summary);
|
||
if (candidate.workspace_root) details.push(candidate.workspace_root);
|
||
return details.join(" · ");
|
||
}
|
||
|
||
function renderTemplateSelectField(slot, label, candidates) {
|
||
var field = el("div", "template-select-field template-select-field-" + slot);
|
||
var selectId = "template-select-" + slot;
|
||
var fieldLabel = el("label", "template-select-label", label);
|
||
fieldLabel.setAttribute("for", selectId);
|
||
field.appendChild(fieldLabel);
|
||
|
||
var select = el("select", "template-select");
|
||
select.id = selectId;
|
||
select.setAttribute("data-template-slot", slot);
|
||
var none = el("option", "", t("template_select_none"));
|
||
none.value = "";
|
||
select.appendChild(none);
|
||
candidates.forEach(function (candidate) {
|
||
var option = el("option", "", templateCandidateTitle(candidate));
|
||
option.value = candidate.key;
|
||
var title = templateOptionTitle(candidate);
|
||
if (title) option.title = title;
|
||
select.appendChild(option);
|
||
});
|
||
select.value = TEMPLATE_SELECTIONS[slot] || "";
|
||
select.disabled = !candidates.length;
|
||
select.addEventListener("change", function () {
|
||
chooseTemplateForSlot(slot, select.value);
|
||
});
|
||
field.appendChild(select);
|
||
|
||
if (slot === "explicit") {
|
||
field.appendChild(el(
|
||
"div",
|
||
"template-select-help",
|
||
candidates.length ? t("template_explicit_hint") : t("template_none_explicit")
|
||
));
|
||
var path = el("div", "template-selected-path");
|
||
path.id = "template-explicit-path";
|
||
path.appendChild(el("span", "template-selected-path-label", t("template_source_path") + ":"));
|
||
var code = el("code", "template-selected-path-value");
|
||
code.id = "template-explicit-path-value";
|
||
path.appendChild(code);
|
||
field.appendChild(path);
|
||
}
|
||
return field;
|
||
}
|
||
|
||
function renderTemplateModeChoice(id, mode, titleKey, descKey, onSelect) {
|
||
var selected = TEMPLATE_MODE === mode;
|
||
var choice = el("button", "template-mode-choice" + (selected ? " selected" : ""));
|
||
choice.id = id;
|
||
choice.type = "button";
|
||
choice.setAttribute("aria-pressed", selected ? "true" : "false");
|
||
choice.appendChild(el("span", "template-choice-radio"));
|
||
var copy = el("span", "template-choice-copy");
|
||
copy.appendChild(el("span", "template-choice-title", t(titleKey)));
|
||
copy.appendChild(el("span", "template-choice-desc", t(descKey)));
|
||
choice.appendChild(copy);
|
||
choice.addEventListener("click", onSelect);
|
||
return choice;
|
||
}
|
||
|
||
function renderTemplateSelection(host) {
|
||
if (!TEMPLATE_OPTIONS) return;
|
||
var sec = section("T", "sec_template_choice", t("template_choice_hint"));
|
||
var choices = el("div", "template-mode-choices");
|
||
choices.appendChild(renderTemplateModeChoice(
|
||
"template-free-choice", "free_design", "template_free_title",
|
||
"template_free_desc", chooseFreeDesign
|
||
));
|
||
var useChoice = renderTemplateModeChoice(
|
||
"template-use-choice", "templates", "template_use_title",
|
||
"template_use_desc", chooseTemplateMode
|
||
);
|
||
useChoice.setAttribute("aria-controls", "template-selector-panel");
|
||
choices.appendChild(useChoice);
|
||
sec.appendChild(choices);
|
||
|
||
var panel = el("div", "template-selector-panel");
|
||
panel.id = "template-selector-panel";
|
||
panel.appendChild(el("div", "template-selector-hint", t("template_library_hint")));
|
||
var grid = el("div", "template-select-grid");
|
||
TEMPLATE_KINDS.forEach(function (kind) {
|
||
var candidates = TEMPLATE_OPTIONS.library[kind] || [];
|
||
grid.appendChild(renderTemplateSelectField(kind, templateKindLabel(kind), candidates));
|
||
});
|
||
grid.appendChild(renderTemplateSelectField(
|
||
"explicit",
|
||
t("template_source_explicit"),
|
||
TEMPLATE_OPTIONS.explicit || []
|
||
));
|
||
panel.appendChild(grid);
|
||
sec.appendChild(panel);
|
||
host.appendChild(sec);
|
||
updateTemplateSelectionControls();
|
||
}
|
||
|
||
function updateTemplateExplicitPath() {
|
||
var path = document.getElementById("template-explicit-path");
|
||
var value = document.getElementById("template-explicit-path-value");
|
||
if (!path || !value) return;
|
||
var candidate = templateCandidateByKey(TEMPLATE_SELECTIONS.explicit);
|
||
var workspaceRoot = candidate && candidate.workspace_root ? candidate.workspace_root : "";
|
||
path.hidden = !workspaceRoot;
|
||
value.textContent = workspaceRoot;
|
||
value.title = workspaceRoot;
|
||
}
|
||
|
||
function updateTemplateSelectionControls() {
|
||
var freeChoice = document.getElementById("template-free-choice");
|
||
var useChoice = document.getElementById("template-use-choice");
|
||
var selectorPanel = document.getElementById("template-selector-panel");
|
||
var freeSelected = TEMPLATE_MODE === "free_design";
|
||
var templatesSelected = TEMPLATE_MODE === "templates";
|
||
if (freeChoice) {
|
||
freeChoice.classList.toggle("selected", freeSelected);
|
||
freeChoice.setAttribute("aria-pressed", freeSelected ? "true" : "false");
|
||
}
|
||
if (useChoice) {
|
||
useChoice.classList.toggle("selected", templatesSelected);
|
||
useChoice.setAttribute("aria-pressed", templatesSelected ? "true" : "false");
|
||
useChoice.setAttribute("aria-expanded", templatesSelected ? "true" : "false");
|
||
}
|
||
if (selectorPanel) selectorPanel.hidden = !templatesSelected;
|
||
TEMPLATE_KINDS.concat(["explicit"]).forEach(function (slot) {
|
||
var select = document.getElementById("template-select-" + slot);
|
||
if (select) select.value = TEMPLATE_SELECTIONS[slot] || "";
|
||
});
|
||
updateTemplateExplicitPath();
|
||
var status = document.getElementById("confirm-status");
|
||
if (status) status.textContent = "";
|
||
}
|
||
|
||
function normalizeRecId(field, value) {
|
||
if (Array.isArray(value)) return normalizeRecId(field, value[0]);
|
||
if (value == null || value === "") return value;
|
||
var aliases = REC_ALIASES[field] || {};
|
||
return aliases[value] || value;
|
||
}
|
||
|
||
function normalizeRecIds(field, value) {
|
||
if (Array.isArray(value)) {
|
||
return value.map(function (item) { return normalizeRecId(field, item); })
|
||
.filter(function (item, idx, arr) { return item && arr.indexOf(item) === idx; });
|
||
}
|
||
var normalized = normalizeRecId(field, value);
|
||
return normalized ? [normalized] : [];
|
||
}
|
||
|
||
function legacyRecId(field) {
|
||
if (!REC) return null;
|
||
if (field === "canvas") return REC.canvas && REC.canvas.value;
|
||
if (field === "visual_style") return REC.visual_style || (REC.style && REC.style.value);
|
||
if (field === "icons") return REC.icons && REC.icons.value;
|
||
if (field === "image_usage") return REC.images && REC.images.value;
|
||
if (field === "image_ai_path") return REC.image_ai_path || (REC.images && REC.images.ai_path);
|
||
if (field === "formula_policy") return REC.typography && REC.typography.formula_policy && REC.typography.formula_policy.value;
|
||
if (field === "generation_mode") return REC.generation_mode && REC.generation_mode.value;
|
||
return REC[field] && REC[field].value;
|
||
}
|
||
|
||
function recId(field) {
|
||
var value = (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
|
||
return normalizeRecId(field, value || null);
|
||
}
|
||
|
||
function recValue(field) {
|
||
return (REC && REC.recommend && REC.recommend[field]) || legacyRecId(field);
|
||
}
|
||
|
||
function booleanRecommendation(field, fallback) {
|
||
if (!REC || !Object.prototype.hasOwnProperty.call(REC, field)) return fallback;
|
||
var spec = REC[field];
|
||
var value = spec && typeof spec === "object" ? spec.value : spec;
|
||
return typeof value === "boolean" ? value : fallback;
|
||
}
|
||
|
||
function recommendationFieldLocked(field) {
|
||
return !!(REC && REC[field] && typeof REC[field] === "object" && REC[field].locked === true);
|
||
}
|
||
|
||
// Guaranteed recommendation: the AI's pick, or the first catalog option as a
|
||
// fallback so an enumerable field ALWAYS shows a badged recommendation.
|
||
function recOrFirst(field, list) {
|
||
var r = recId(field);
|
||
if (r == null || r === "") r = normalizeRecId(field, directionField(field));
|
||
if (r != null && r !== "") return r;
|
||
return firstId(list);
|
||
}
|
||
// Render an enumerable field: ALL options from the catalog, recommended one
|
||
// badged, current selection from STATE, plus an optional Custom box. An
|
||
// AI-authored custom candidate stays fully visible while unselected and
|
||
// becomes editable only after selection; legacy custom inputs keep their
|
||
// compact free-text behavior.
|
||
// `list` is either a flat array of {id,label,desc,dim,viewbox} or a grouped array
|
||
// of {group, items:[...]}.
|
||
function enumField(parent, list, recommendedId, getVal, setVal, opts2) {
|
||
list = list || [];
|
||
opts2 = opts2 || {};
|
||
var grouped = list.length && list[0] && list[0].items;
|
||
var flat = grouped ? list.reduce(function (a, g) { return a.concat(g.items || []); }, []) : list;
|
||
var ids = flat.map(function (o) { return o.id; });
|
||
// Optional legacy spectrum: marks several catalog ids with localized
|
||
// tags and notes instead of one recommendation badge.
|
||
var spectrum = (opts2.spectrum && opts2.spectrum.length) ? opts2.spectrum : null;
|
||
var specById = {};
|
||
if (spectrum) spectrum.forEach(function (s) {
|
||
if (s && s.id) specById[s.id] = { tag: localized(s, "tag"), note: localized(s, "note") };
|
||
});
|
||
var allowCustom = opts2.allowCustom === true; // only for fields not fully enumerable
|
||
var aiCustom = opts2.aiCustom || null;
|
||
var customSentinel = opts2.customSentinel || (aiCustom ? "custom" : "");
|
||
var customInvalidValues = opts2.customInvalidValues || [];
|
||
var cur = getVal();
|
||
var isCustom = cur != null && cur !== "" &&
|
||
(cur === customSentinel || ids.indexOf(cur) === -1);
|
||
if (!allowCustom && isCustom && opts2.preserveCustom !== true) {
|
||
// closed field with an out-of-catalog value → snap to recommended/first
|
||
cur = ids.indexOf(recommendedId) >= 0 ? recommendedId : ids[0];
|
||
setVal(cur);
|
||
isCustom = false;
|
||
}
|
||
|
||
var allChips = [];
|
||
var customInput = el(aiCustom ? "textarea" : "input", "text-input custom-input");
|
||
setNaturalInputDirection(customInput);
|
||
if (opts2.inputClass) customInput.classList.add(opts2.inputClass);
|
||
if (aiCustom) customInput.rows = aiCustom.rows || 4;
|
||
else customInput.type = "text";
|
||
customInput.placeholder = opts2.placeholder || t("custom_placeholder");
|
||
customInput.style.display = "none";
|
||
var customPreview = null;
|
||
|
||
function customText() {
|
||
if (!aiCustom) return customInput.value || "";
|
||
return String(aiCustom.getText ? aiCustom.getText() : (aiCustom.text || ""));
|
||
}
|
||
|
||
function syncAiCustom(selected) {
|
||
if (!aiCustom || !customPreview) return;
|
||
var value = customText();
|
||
customPreview.textContent = value;
|
||
customPreview.style.display = selected ? "none" : "block";
|
||
customInput.style.display = selected ? "block" : "none";
|
||
if (selected && customInput.value !== value) customInput.value = value;
|
||
}
|
||
|
||
function deselect() {
|
||
allChips.forEach(function (c) { c.classList.remove("selected"); });
|
||
if (aiCustom) syncAiCustom(false);
|
||
}
|
||
function makeChip(o) {
|
||
var label = optionLabel(o);
|
||
var desc = optionDesc(o);
|
||
var spec = specById[o.id];
|
||
var chip = el("div", "chip");
|
||
var preview = previewNode(opts2.preview, o.id);
|
||
if (preview) {
|
||
chip.classList.add("chip-with-preview");
|
||
chip.classList.add("chip-preview-" + opts2.preview);
|
||
chip.appendChild(preview);
|
||
}
|
||
var copy = el("div", "chip-copy");
|
||
if (o.viewbox) {
|
||
label = label + (o.dim ? " · " + o.dim : "");
|
||
} else {
|
||
if (o.dim) label += " · " + o.dim;
|
||
if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc;
|
||
if (spec && spec.note) label += " · " + spec.note;
|
||
}
|
||
copy.appendChild(el("span", "chip-text", label));
|
||
if (spec) {
|
||
// spectrum pick: badge shows its temperament tag, not the generic ★
|
||
chip.classList.add("recommended");
|
||
copy.appendChild(el("span", "rec-badge", "★ " + (spec.tag || t("recommended"))));
|
||
} else if (!spectrum && o.id === recommendedId) {
|
||
chip.classList.add("recommended");
|
||
copy.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||
}
|
||
chip.appendChild(copy);
|
||
if (!isCustom && o.id === cur) chip.classList.add("selected");
|
||
chip.addEventListener("click", function () {
|
||
deselect();
|
||
chip.classList.add("selected");
|
||
if (!aiCustom) customInput.style.display = "none";
|
||
setVal(o.id);
|
||
});
|
||
allChips.push(chip);
|
||
return chip;
|
||
}
|
||
|
||
var chipsClass = "chips" + (opts2.chipsClass ? " " + opts2.chipsClass : "");
|
||
if (grouped) {
|
||
list.forEach(function (g) {
|
||
if (groupLabel(g)) parent.appendChild(el("div", "group-label", groupLabel(g)));
|
||
var row = el("div", chipsClass);
|
||
(g.items || []).forEach(function (o) { row.appendChild(makeChip(o)); });
|
||
parent.appendChild(row);
|
||
});
|
||
if (allowCustom) {
|
||
var lastRow = el("div", aiCustom ? "chips custom-chip-row" : "chips");
|
||
lastRow.appendChild(buildCustomChip());
|
||
parent.appendChild(lastRow);
|
||
}
|
||
} else {
|
||
var wrap = el("div", chipsClass);
|
||
var ownRowIds = opts2.ownRowIds || [];
|
||
flat.filter(function (o) { return ownRowIds.indexOf(o.id) === -1; })
|
||
.forEach(function (o) { wrap.appendChild(makeChip(o)); });
|
||
if (allowCustom && !opts2.customOnOwnRow) wrap.appendChild(buildCustomChip());
|
||
parent.appendChild(wrap);
|
||
flat.filter(function (o) { return ownRowIds.indexOf(o.id) >= 0; })
|
||
.forEach(function (o) {
|
||
var ownRow = el("div", "chips standalone-chip-row");
|
||
ownRow.appendChild(makeChip(o));
|
||
parent.appendChild(ownRow);
|
||
});
|
||
if (allowCustom && opts2.customOnOwnRow) {
|
||
var customRow = el("div", "chips custom-chip-row");
|
||
customRow.appendChild(buildCustomChip());
|
||
parent.appendChild(customRow);
|
||
}
|
||
}
|
||
if (allowCustom && !aiCustom) parent.appendChild(customInput);
|
||
|
||
function buildCustomChip() {
|
||
var customChip = el("div", aiCustom ? "chip ai-custom-candidate" : "chip");
|
||
if (aiCustom) {
|
||
var customHead = el("div", "ai-custom-candidate-head");
|
||
customHead.appendChild(el("span", "chip-text",
|
||
aiCustom.label || t("ai_custom_candidate")));
|
||
customHead.appendChild(el("span", "ai-custom-candidate-hint",
|
||
aiCustom.hint || t("ai_custom_candidate_hint")));
|
||
customChip.appendChild(customHead);
|
||
customPreview = el("div", "ai-custom-candidate-copy", customText());
|
||
customChip.appendChild(customPreview);
|
||
customChip.appendChild(customInput);
|
||
} else {
|
||
customChip.appendChild(el("span", "chip-text", t("custom")));
|
||
}
|
||
if (recommendedId === customSentinel ||
|
||
(recommendedId && ids.indexOf(recommendedId) === -1 && isCustom)) {
|
||
customChip.classList.add("recommended");
|
||
customChip.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||
}
|
||
if (isCustom) {
|
||
customChip.classList.add("selected");
|
||
if (aiCustom) syncAiCustom(true);
|
||
else {
|
||
customInput.style.display = "block";
|
||
customInput.value = customInvalidValues.indexOf(cur) >= 0 ? "" : cur;
|
||
}
|
||
} else if (aiCustom) {
|
||
syncAiCustom(false);
|
||
}
|
||
customChip.addEventListener("click", function () {
|
||
deselect();
|
||
customChip.classList.add("selected");
|
||
if (aiCustom) syncAiCustom(true);
|
||
else customInput.style.display = "block";
|
||
customInput.focus();
|
||
setVal(aiCustom ? customSentinel : (customInput.value || customSentinel));
|
||
});
|
||
allChips.push(customChip);
|
||
return customChip;
|
||
}
|
||
customInput.addEventListener("click", function (event) { event.stopPropagation(); });
|
||
customInput.addEventListener("input", function () {
|
||
if (aiCustom) {
|
||
if (aiCustom.setText) aiCustom.setText(customInput.value);
|
||
setVal(customSentinel);
|
||
} else {
|
||
setVal(customInput.value || customSentinel);
|
||
}
|
||
});
|
||
}
|
||
|
||
function textField(parent, getVal, setVal, placeholderKey, numeric) {
|
||
var input = el("input", numeric ? "num-input" : "text-input");
|
||
input.type = "text";
|
||
if (!numeric) setNaturalInputDirection(input);
|
||
input.value = getVal() || "";
|
||
input.placeholder = t(placeholderKey);
|
||
input.addEventListener("input", function () { setVal(input.value); });
|
||
parent.appendChild(input);
|
||
}
|
||
|
||
function textareaField(parent, getVal, setVal, placeholderKey, rows) {
|
||
var input = el("textarea", "text-input");
|
||
setNaturalInputDirection(input);
|
||
input.rows = rows || 2;
|
||
input.value = getVal() || "";
|
||
input.placeholder = t(placeholderKey);
|
||
input.addEventListener("input", function () { setVal(input.value); });
|
||
parent.appendChild(input);
|
||
return input;
|
||
}
|
||
|
||
function labeledTextarea(parent, labelKey, getVal, setVal, placeholderKey, hintKey, rows) {
|
||
var field = el("div", "subfield");
|
||
field.appendChild(el("div", "subfield-label", t(labelKey)));
|
||
if (hintKey) field.appendChild(el("div", "toggle-desc", t(hintKey)));
|
||
textareaField(field, getVal, setVal, placeholderKey, rows);
|
||
parent.appendChild(field);
|
||
return field;
|
||
}
|
||
|
||
function normPalette(c) {
|
||
function read(src, keys) {
|
||
if (!src) return undefined;
|
||
for (var i = 0; i < keys.length; i += 1) {
|
||
if (src[keys[i]] != null) return src[keys[i]];
|
||
}
|
||
return undefined;
|
||
}
|
||
function collect(src) {
|
||
return {
|
||
background: read(src, ["background", "bg"]),
|
||
secondary_bg: read(src, ["secondary_bg", "secondary_background", "card_bg", "card_background"]),
|
||
primary: read(src, ["primary"]),
|
||
accent: read(src, ["accent"]),
|
||
secondary_accent: read(src, ["secondary_accent", "secondary"]),
|
||
body_text: read(src, ["body_text", "text"])
|
||
};
|
||
}
|
||
if (c && c.palette) {
|
||
return collect(c.palette);
|
||
}
|
||
if (!c) return {};
|
||
return collect(c);
|
||
}
|
||
|
||
function recommendationLanguage() {
|
||
// `lang` controls the Confirm UI language; it is not evidence of the
|
||
// deck's content language. New staged files carry `primary_language`.
|
||
var raw = REC && (REC.primary_language || REC.content_language || REC.language);
|
||
if (raw && typeof raw === "object") {
|
||
raw = raw.value || raw.id || raw.code || "";
|
||
}
|
||
// The API is the single normalizer; the browser consumes its canonical
|
||
// value instead of maintaining a second alias table.
|
||
return String(raw || "und").trim().replace(/_/g, "-");
|
||
}
|
||
|
||
function isEnglishProject() {
|
||
return /^en(?:-|$)/i.test(recommendationLanguage());
|
||
}
|
||
|
||
function setEnglishLanguageAttributes(node) {
|
||
node.lang = "en";
|
||
node.dir = "ltr";
|
||
}
|
||
|
||
function setUiLanguageAttributes(node) {
|
||
node.lang = LANG === "zh" ? "zh-CN" : (LANG === "zh-TW" ? "zh-TW" : (LANG === "ja" ? "ja-JP" : "en-US"));
|
||
node.dir = "ltr";
|
||
}
|
||
|
||
function setNaturalInputDirection(node) {
|
||
node.dir = "auto";
|
||
}
|
||
|
||
function stringList(value) {
|
||
if (Array.isArray(value)) return value.map(String);
|
||
if (value == null || value === "") return [];
|
||
return [String(value)];
|
||
}
|
||
|
||
function normalizedFontToken(value) {
|
||
return String(value || "").trim().toLowerCase().replace(/[_\s]+/g, "-");
|
||
}
|
||
|
||
function fontScriptTokens(language) {
|
||
var parts = normalizedFontToken(language).split("-");
|
||
var base = parts[0];
|
||
if (!base || base === "und") return [];
|
||
var explicitScript = parts.filter(function (part) {
|
||
return /^[a-z]{4}$/.test(part);
|
||
})[0];
|
||
if (explicitScript) return [explicitScript];
|
||
if (base === "en") return ["latin", "latn", "english"];
|
||
if (base === "zh") {
|
||
if (parts.indexOf("cn") >= 0 || parts.indexOf("sg") >= 0) {
|
||
return ["han", "hans", "cjk", "chinese", "zh"];
|
||
}
|
||
if (parts.indexOf("tw") >= 0 || parts.indexOf("hk") >= 0 ||
|
||
parts.indexOf("mo") >= 0) {
|
||
return ["han", "hant", "cjk", "chinese", "zh"];
|
||
}
|
||
return ["han", "hans", "cjk", "chinese", "zh"];
|
||
}
|
||
if (base === "ja") return ["han", "jpan", "kana", "hiragana", "katakana", "cjk", "japanese", "ja"];
|
||
if (base === "ko") return ["hangul", "kore", "han", "cjk", "korean", "ko"];
|
||
if (["ru", "uk", "bg", "sr", "mk"].indexOf(base) >= 0) return ["cyrl", "cyrillic"];
|
||
if (["ar", "fa", "ur"].indexOf(base) >= 0) return ["arab", "arabic"];
|
||
if (base === "he" || base === "iw") return ["hebr", "hebrew"];
|
||
if (base === "el") return ["grek", "greek"];
|
||
if (base === "th") return ["thai"];
|
||
if (base === "hi" || base === "mr" || base === "ne") return ["deva", "devanagari"];
|
||
// Most remaining presentation locales use Latin script. Locale metadata
|
||
// still wins before this fallback.
|
||
return ["latin", "latn"];
|
||
}
|
||
|
||
function fontSupportsLanguage(font, language) {
|
||
var target = normalizedFontToken(language);
|
||
var targetBase = target.split("-")[0];
|
||
var locales = stringList(font && font.locales).map(normalizedFontToken);
|
||
if (locales.some(function (locale) {
|
||
return locale === "*" || locale === target ||
|
||
(target !== targetBase && locale === targetBase);
|
||
})) return true;
|
||
|
||
var supportedScripts = stringList(font && font.scripts).map(normalizedFontToken);
|
||
var wantedScripts = fontScriptTokens(language);
|
||
if (supportedScripts.some(function (script) {
|
||
return wantedScripts.indexOf(script) >= 0;
|
||
})) return true;
|
||
|
||
// A catalog entry without language metadata remains usable. Once either
|
||
// metadata field is present, filtering follows it.
|
||
return !locales.length && !supportedScripts.length;
|
||
}
|
||
|
||
function fontCatalogEntries(field) {
|
||
var fonts = (CAT && Array.isArray(CAT.fonts)) ? CAT.fonts : [];
|
||
var language = field === "english" ? "en" : recommendationLanguage();
|
||
if (field === "primary" && language === "und") {
|
||
return fonts.filter(function (font) { return font && font.id; });
|
||
}
|
||
var matching = fonts.filter(function (font) {
|
||
return font && font.id && fontSupportsLanguage(font, language);
|
||
});
|
||
return matching;
|
||
}
|
||
|
||
function findFontCatalogEntry(id) {
|
||
var fonts = (CAT && Array.isArray(CAT.fonts)) ? CAT.fonts : [];
|
||
var target = String(id || "").trim().toLowerCase();
|
||
for (var i = 0; i < fonts.length; i += 1) {
|
||
if (String(fonts[i].id || "").trim().toLowerCase() === target) return fonts[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function derivedFontCss(font) {
|
||
var primary = findFontCatalogEntry(font && font.primary);
|
||
return String((primary && primary.css) || "sans-serif").trim() ||
|
||
"sans-serif";
|
||
}
|
||
|
||
function normalizedFontRole(font, samples) {
|
||
font = (font && typeof font === "object") ? Object.assign({}, font) : {};
|
||
samples = samples || {};
|
||
var primary = font.primary;
|
||
var english = font.english;
|
||
if (isEnglishProject()) {
|
||
primary = primary || font.latin || font.cjk || "";
|
||
english = "";
|
||
} else {
|
||
primary = primary || font.cjk || "";
|
||
english = english || font.latin || "";
|
||
}
|
||
font.primary = primary;
|
||
if (isEnglishProject()) delete font.english;
|
||
else font.english = english;
|
||
font.sample_primary = font.sample_primary ||
|
||
(isEnglishProject() ? font.sample_latin : font.sample_cjk) ||
|
||
samples.primary || "";
|
||
if (isEnglishProject()) {
|
||
delete font.sample_english;
|
||
} else {
|
||
font.sample_english = font.sample_english || font.sample_latin ||
|
||
samples.english || "";
|
||
}
|
||
delete font.cjk;
|
||
delete font.latin;
|
||
delete font.sample_cjk;
|
||
delete font.sample_latin;
|
||
if (!String(font.css || "").trim()) font.css = derivedFontCss(font);
|
||
return font;
|
||
}
|
||
|
||
function normTypography(c) {
|
||
c = c || {};
|
||
var normalized = Object.assign({}, c);
|
||
var objectShape = c.heading && typeof c.heading === "object" &&
|
||
c.body && typeof c.body === "object";
|
||
var heading = objectShape ? c.heading : {
|
||
primary: isEnglishProject() ? (c.heading_latin || c.heading || "") : (c.heading || ""),
|
||
english: isEnglishProject() ? "" : (c.heading_latin || ""),
|
||
css: c.heading_css || ""
|
||
};
|
||
var body = objectShape ? c.body : {
|
||
primary: isEnglishProject() ? (c.body_latin || c.body || "") : (c.body || ""),
|
||
english: isEnglishProject() ? "" : (c.body_latin || ""),
|
||
css: c.body_css || ""
|
||
};
|
||
normalized.name = c.name || "";
|
||
normalized.note = c.note || "";
|
||
normalized.custom = c.custom || "";
|
||
normalized.body_size = typographyBodySize(c);
|
||
normalized.heading = normalizedFontRole(heading, {
|
||
primary: isEnglishProject()
|
||
? (c.sample_heading_latin || c.sample_heading || "")
|
||
: (c.sample_heading || ""),
|
||
english: c.sample_heading_latin || ""
|
||
});
|
||
normalized.body = normalizedFontRole(body, {
|
||
primary: isEnglishProject()
|
||
? (c.sample_body_latin || c.sample_body || "")
|
||
: (c.sample_body || ""),
|
||
english: c.sample_body_latin || ""
|
||
});
|
||
delete normalized.heading_latin;
|
||
delete normalized.body_latin;
|
||
delete normalized.heading_css;
|
||
delete normalized.body_css;
|
||
delete normalized.sample_heading;
|
||
delete normalized.sample_heading_latin;
|
||
delete normalized.sample_body;
|
||
delete normalized.sample_body_latin;
|
||
return normalized;
|
||
}
|
||
|
||
function typographyBodySize(c) {
|
||
c = c || {};
|
||
var value = c.body_size || c.body_baseline || c.body_px ||
|
||
(c.sizes && c.sizes.body) ||
|
||
(c.size && c.size.body) ||
|
||
(c.body && typeof c.body === "object" && (c.body.size || c.body.font_size));
|
||
return value == null ? "" : String(value).replace(/px$/i, "");
|
||
}
|
||
|
||
function designDirectionSpec() {
|
||
return (REC && REC.design_directions) ||
|
||
(REC && REC.design && REC.design.directions) ||
|
||
{};
|
||
}
|
||
|
||
function designDirectionCandidates() {
|
||
var spec = designDirectionSpec();
|
||
return spec.candidates || spec.options || [];
|
||
}
|
||
|
||
function selectedDesignDirectionIndex() {
|
||
var candidates = designDirectionCandidates();
|
||
var selected = Number(designDirectionSpec().selected);
|
||
if (!isFinite(selected) || selected < 0) selected = 0;
|
||
return Math.min(Math.floor(selected), Math.max(candidates.length - 1, 0));
|
||
}
|
||
|
||
function designDirectionId(candidate, index) {
|
||
var value = candidate && candidate.id;
|
||
return String(value || ("direction-" + (Number(index) + 1)));
|
||
}
|
||
|
||
function selectedDesignDirection() {
|
||
var candidates = designDirectionCandidates();
|
||
return candidates[selectedDesignDirectionIndex()] || {};
|
||
}
|
||
|
||
function directionField(field) {
|
||
var candidate = selectedDesignDirection();
|
||
return candidate[field] != null ? candidate[field] : null;
|
||
}
|
||
|
||
function directionBehavior(candidate, field) {
|
||
return String(localized(candidate, field + "_behavior") ||
|
||
(candidate && candidate[field + "_behavior"]) || "");
|
||
}
|
||
|
||
function directionStateSignature(candidate) {
|
||
candidate = candidate || {};
|
||
var out = {};
|
||
if (candidate.mode) {
|
||
out.mode = STATE.mode || "";
|
||
out.mode_behavior = STATE.mode === "custom" ? (STATE.mode_behavior || "") : "";
|
||
}
|
||
if (candidate.visual_style) {
|
||
out.visual_style = STATE.visual_style || "";
|
||
out.visual_style_behavior = STATE.visual_style === "custom" ?
|
||
(STATE.visual_style_behavior || "") : "";
|
||
}
|
||
if (candidate.color) {
|
||
var palette = normPalette(STATE.color || {});
|
||
out.color = {
|
||
custom: (STATE.color && STATE.color.custom) || "",
|
||
palette: {}
|
||
};
|
||
PALETTE_ROLES.forEach(function (role) {
|
||
out.color.palette[role] = normHex(palette[role]) || "";
|
||
});
|
||
}
|
||
if (candidate.typography) {
|
||
var typography = normTypography(STATE.typography || {});
|
||
out.typography = {
|
||
heading: typography.heading || {},
|
||
body: typography.body || {},
|
||
body_size: String(typography.body_size || ""),
|
||
sizes: {}
|
||
};
|
||
["title", "subtitle", "annotation"].forEach(function (role) {
|
||
out.typography.sizes[role] = String(
|
||
(STATE.typography && STATE.typography.sizes && STATE.typography.sizes[role]) || ""
|
||
);
|
||
});
|
||
}
|
||
if (candidate.icons) out.icons = STATE.icons || "";
|
||
if (candidate.image_strategy) {
|
||
out.image_strategy = comparableImageStrategy(STATE.image_strategy || {});
|
||
}
|
||
return JSON.stringify(out);
|
||
}
|
||
|
||
function activeDirectionAdjusted(candidate, index) {
|
||
return designDirectionId(candidate, index) === ACTIVE_DIRECTION_ID &&
|
||
directionStateSignature(candidate) !== ACTIVE_DIRECTION_BASELINE;
|
||
}
|
||
|
||
function customCandidateSpec(field) {
|
||
var candidates = REC && REC.custom_candidates;
|
||
var candidate = candidates && candidates[field];
|
||
if (typeof candidate === "string") return { behavior: candidate };
|
||
if (candidate && typeof candidate === "object") return candidate;
|
||
var legacy = REC && REC[field + "_behavior"];
|
||
if (legacy && typeof legacy === "object") legacy = legacy.value;
|
||
return legacy ? { behavior: legacy } : {};
|
||
}
|
||
|
||
function customCandidateBehavior(field) {
|
||
var candidate = customCandidateSpec(field);
|
||
return String(localized(candidate, "behavior") || candidate.value || candidate.custom || "");
|
||
}
|
||
|
||
function creativeCustomOptions(field, stateKey, placeholderKey) {
|
||
var candidate = customCandidateSpec(field);
|
||
var current = String(STATE[stateKey] || customCandidateBehavior(field) || "");
|
||
if (!current.trim()) return null;
|
||
STATE[stateKey] = current;
|
||
return {
|
||
label: localized(candidate, "name") || t("ai_custom_candidate"),
|
||
hint: t("ai_custom_candidate_hint"),
|
||
getText: function () { return STATE[stateKey] || ""; },
|
||
setText: function (value) { STATE[stateKey] = value; },
|
||
rows: 4,
|
||
placeholder: t(placeholderKey)
|
||
};
|
||
}
|
||
|
||
function colorRecommendationCandidates() {
|
||
var direct = (REC.color && REC.color.candidates) || [];
|
||
if (direct.length) return direct;
|
||
return designDirectionCandidates().map(function (candidate) {
|
||
return candidate && candidate.color;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
function typographyRecommendationCandidates() {
|
||
var direct = (REC.typography && REC.typography.candidates) || [];
|
||
if (direct.length) return direct;
|
||
return designDirectionCandidates().map(function (candidate) {
|
||
return candidate && candidate.typography;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
function imageStrategySpec() {
|
||
return (REC && REC.image_strategy) ||
|
||
(REC && REC.images && REC.images.strategy) ||
|
||
(REC && REC.images && REC.images.ai_strategy) ||
|
||
{};
|
||
}
|
||
|
||
function imageStrategyCandidates() {
|
||
var spec = imageStrategySpec();
|
||
var direct = spec.candidates || spec.options || [];
|
||
if (direct.length) return direct;
|
||
return designDirectionCandidates().map(function (candidate) {
|
||
return candidate && candidate.image_strategy;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
function imageStrategyRecommendationCandidates() {
|
||
return imageStrategyCandidates().filter(function (candidate) {
|
||
return candidate && candidate.rendering !== "custom";
|
||
}).slice(0, 3);
|
||
}
|
||
|
||
function imageStrategyCatalogCandidates() {
|
||
var items = AI_IMAGE_COMPARISON && AI_IMAGE_COMPARISON.rendering;
|
||
if (!Array.isArray(items)) return [];
|
||
return items.map(function (item) {
|
||
return item && item.id ? { rendering: item.id } : null;
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
function imageStrategySelectableCandidates() {
|
||
var recommended = imageStrategyRecommendationCandidates();
|
||
var seen = {};
|
||
recommended.forEach(function (candidate) { seen[candidate.rendering] = true; });
|
||
return recommended.concat(imageStrategyCatalogCandidates().filter(function (candidate) {
|
||
return !seen[candidate.rendering];
|
||
}));
|
||
}
|
||
|
||
function imageStrategyCustomCandidate() {
|
||
var candidate = customCandidateSpec("image_strategy");
|
||
if (!customCandidateBehavior("image_strategy")) return null;
|
||
if (!candidate || typeof candidate !== "object") candidate = {};
|
||
candidate = Object.assign({}, candidate, { rendering: "custom" });
|
||
return normalizedImageStrategy(candidate);
|
||
}
|
||
|
||
function normalizedImageStrategy(candidate) {
|
||
candidate = candidate || {};
|
||
var out = {
|
||
name: localized(candidate, "name") || candidate.name || "",
|
||
rendering: candidate.rendering || "",
|
||
visual: localized(candidate, "visual") || "",
|
||
mood: localized(candidate, "mood") || ""
|
||
};
|
||
var behavior = localized(candidate, "behavior") || candidate.behavior || candidate.custom || "";
|
||
if (behavior) out.behavior = behavior;
|
||
return out;
|
||
}
|
||
|
||
function comparableImageStrategy(candidate) {
|
||
var strategy = normalizedImageStrategy(candidate);
|
||
return {
|
||
rendering: strategy.rendering || "",
|
||
visual: strategy.visual || "",
|
||
mood: strategy.mood || "",
|
||
behavior: strategy.behavior || ""
|
||
};
|
||
}
|
||
|
||
function directionCardSummary(candidate) {
|
||
candidate = candidate || {};
|
||
var strategy = normalizedImageStrategy(candidate.image_strategy || {});
|
||
return String(
|
||
localized(candidate, "note") ||
|
||
directionBehavior(candidate, "visual_style") ||
|
||
directionBehavior(candidate, "mode") ||
|
||
strategy.visual ||
|
||
strategy.behavior ||
|
||
""
|
||
);
|
||
}
|
||
|
||
function usesCustomImagePlanValue(value) {
|
||
var ids = (CAT.image_usage || []).map(function (item) { return item.id; });
|
||
if (Array.isArray(value)) return false;
|
||
return value && ids.indexOf(value) === -1;
|
||
}
|
||
|
||
function customImagePlanHasAiSignal() {
|
||
return imageStrategyRecommendationCandidates().length > 0 || !!recId("image_ai_path");
|
||
}
|
||
|
||
function needsGeneratedImagesForUsage(value) {
|
||
if (Array.isArray(value)) return value.indexOf("ai") >= 0;
|
||
return value === "ai" || (usesCustomImagePlanValue(value) && customImagePlanHasAiSignal());
|
||
}
|
||
|
||
function selectedImageUsageIds(value) {
|
||
var validIds = (CAT.image_usage || []).map(function (item) { return item.id; });
|
||
return normalizeRecIds("image_usage", value).filter(function (id) {
|
||
return validIds.indexOf(id) >= 0;
|
||
});
|
||
}
|
||
|
||
function imageUsageNotesRecommendation(rawUsage) {
|
||
var notes = (REC && REC.image_notes && REC.image_notes.value) ||
|
||
(REC && REC.image_notes) ||
|
||
(REC && REC.images && REC.images.notes) ||
|
||
"";
|
||
if (!notes && usesCustomImagePlanValue(rawUsage)) notes = rawUsage;
|
||
return typeof notes === "string" ? notes : "";
|
||
}
|
||
|
||
function defaultImageUsageId() {
|
||
return firstId(CAT.image_usage);
|
||
}
|
||
|
||
function imageStrategySelectedIndex() {
|
||
var spec = imageStrategySpec();
|
||
var direct = spec.candidates || spec.options || [];
|
||
var idx = direct.length ? Number(spec.selected || 0) : selectedDesignDirectionIndex();
|
||
if (!isFinite(idx) || idx < 0) idx = 0;
|
||
return Math.min(
|
||
Math.floor(idx),
|
||
Math.max(imageStrategyRecommendationCandidates().length - 1, 0)
|
||
);
|
||
}
|
||
|
||
// ---- section renderers ----------------------------------------------
|
||
function renderCanvas(host) {
|
||
var sec = section(1, "sec_canvas");
|
||
enumField(sec, CAT.canvas, recOrFirst("canvas", CAT.canvas),
|
||
function () { return STATE.canvas; },
|
||
function (v) {
|
||
STATE.canvas = v;
|
||
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
|
||
// Canvas changes dimensions only — never silently rewrite font sizes
|
||
// the user can see / edit. The size hint re-renders with the new
|
||
// canvas; a default body is filled only when none is set yet.
|
||
if (!STATE.typography.body_size) {
|
||
STATE.typography.body_size = defaultBodySizeForCanvas(v, STATE.delivery_purpose);
|
||
}
|
||
renderAll();
|
||
}, { allowCustom: true });
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderPages(host) {
|
||
var sec = section(2, "sec_pages");
|
||
textField(sec, function () { return STATE.page_count; },
|
||
function (v) { STATE.page_count = v; }, "placeholder_pages", true);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function templateApplicationRecommendation() {
|
||
if (!REC || REC.template_application == null) return null;
|
||
var field = REC.template_application;
|
||
if (typeof field === "object") {
|
||
return field.value == null ? "" : String(field.value);
|
||
}
|
||
return String(field);
|
||
}
|
||
|
||
function renderTemplateApplication(host) {
|
||
if (templateApplicationRecommendation() == null) return;
|
||
var sec = section("T", "sec_template_application");
|
||
setSectionNote(sec, t("template_application_hint"));
|
||
textareaField(sec,
|
||
function () { return STATE.template_application; },
|
||
function (v) { STATE.template_application = v; },
|
||
"placeholder_template_application", 4);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderCommunication(host) {
|
||
var sec = section(1, "sec_communication");
|
||
var audienceField = el("div", "subfield");
|
||
audienceField.appendChild(el("div", "subfield-label", t("sec_audience")));
|
||
textField(audienceField, function () { return STATE.audience; },
|
||
function (v) { STATE.audience = v; }, "placeholder_audience", false);
|
||
sec.appendChild(audienceField);
|
||
labeledTextarea(sec, "communication_intent",
|
||
function () { return STATE.communication_intent; },
|
||
function (v) { STATE.communication_intent = v; },
|
||
"placeholder_communication_intent", "communication_intent_hint", 3);
|
||
labeledTextarea(sec, "audience_outcome",
|
||
function () { return STATE.audience_outcome; },
|
||
function (v) { STATE.audience_outcome = v; },
|
||
"placeholder_audience_outcome", null, 2);
|
||
labeledTextarea(sec, "core_message",
|
||
function () { return STATE.core_message; },
|
||
function (v) { STATE.core_message = v; },
|
||
"placeholder_core_message", null, 2);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderDelivery(host) {
|
||
var sec = section(2, "sec_delivery");
|
||
labeledTextarea(sec, "delivery_context",
|
||
function () { return STATE.delivery_context; },
|
||
function (v) { STATE.delivery_context = v; },
|
||
"placeholder_delivery_context", "delivery_context_hint", 2);
|
||
labeledTextarea(sec, "artifact_afterlife",
|
||
function () { return STATE.artifact_afterlife; },
|
||
function (v) { STATE.artifact_afterlife = v; },
|
||
"placeholder_artifact_afterlife", null, 2);
|
||
// Material divergence remains open prose: it controls how source material
|
||
// may be reshaped, independently of communication intent and template reuse.
|
||
var divergenceField = labeledTextarea(sec, "sub_divergence",
|
||
function () { return STATE.content_divergence; },
|
||
function (v) { STATE.content_divergence = v; },
|
||
"placeholder_divergence", null, 2);
|
||
if (recommendationFieldLocked("content_divergence")) {
|
||
var divergenceInput = divergenceField.querySelector("textarea");
|
||
if (divergenceInput) {
|
||
divergenceInput.readOnly = true;
|
||
divergenceInput.classList.add("locked-field");
|
||
}
|
||
divergenceField.appendChild(el("div", "toggle-desc locked-field-hint", t("content_divergence_locked_hint")));
|
||
}
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderReadingMode(host) {
|
||
if (!isPptCanvas(STATE.canvas)) return;
|
||
var sec = section("D", "delivery_purpose");
|
||
setSectionNote(sec, t("delivery_purpose_hint"));
|
||
enumField(sec, CAT.delivery_purpose,
|
||
recOrFirst("delivery_purpose", CAT.delivery_purpose),
|
||
function () { return STATE.delivery_purpose; },
|
||
function (v) {
|
||
STATE.delivery_purpose = v;
|
||
// Same-stage dependency, resolved entirely in the browser: do
|
||
// not ask the backend to author Stage 2 again. Manual size
|
||
// overrides remain authoritative.
|
||
syncUnpinnedTypographySizes(true);
|
||
});
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function applyDesignDirection(candidate, index, shouldRender) {
|
||
candidate = candidate || {};
|
||
var directionId = designDirectionId(candidate, index);
|
||
if (candidate.mode) {
|
||
STATE.mode = candidate.mode;
|
||
STATE.mode_behavior = candidate.mode === "custom" ?
|
||
directionBehavior(candidate, "mode") : "";
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.mode = directionId;
|
||
}
|
||
if (candidate.visual_style) {
|
||
STATE.visual_style = candidate.visual_style;
|
||
STATE.visual_style_behavior = candidate.visual_style === "custom" ?
|
||
directionBehavior(candidate, "visual_style") : "";
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.visual_style = directionId;
|
||
}
|
||
if (candidate.color) {
|
||
STATE.color = {
|
||
name: localized(candidate.color, "name") || candidate.color.name || "",
|
||
palette: Object.assign({}, normPalette(candidate.color))
|
||
};
|
||
}
|
||
if (candidate.typography) {
|
||
var typography = normTypography(candidate.typography);
|
||
resetTypographySizeOverrides();
|
||
STATE.typography = typography;
|
||
STATE.typography.name = localized(candidate.typography, "name") || typography.name || "";
|
||
STATE.typography.body_size = typography.body_size ||
|
||
defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||
STATE.typography.sizes = Object.assign({}, typography.sizes || {});
|
||
syncUnpinnedTypographySizes(false);
|
||
}
|
||
if (candidate.icons) STATE.icons = normalizeRecId("icons", candidate.icons);
|
||
if (candidate.image_strategy) {
|
||
STATE.image_strategy = normalizedImageStrategy(candidate.image_strategy);
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.image_strategy = directionId;
|
||
}
|
||
ACTIVE_DIRECTION_ID = directionId;
|
||
ACTIVE_DIRECTION_BASELINE = directionStateSignature(candidate);
|
||
if (shouldRender !== false) renderAll();
|
||
}
|
||
|
||
function renderDesignDirections(host) {
|
||
var candidates = designDirectionCandidates();
|
||
if (!candidates.length) return;
|
||
var sec = section("B", "sec_design_directions", t("design_directions_hint"));
|
||
var grid = el("div", "font-grid design-direction-grid");
|
||
var cardStates = [];
|
||
var recommendedIndex = selectedDesignDirectionIndex();
|
||
candidates.forEach(function (candidate, idx) {
|
||
var card = el("div", "font-card design-direction-card");
|
||
card.title = t("direction_apply_hint");
|
||
var head = el("div", "font-card-head");
|
||
head.appendChild(el("span", "font-card-name",
|
||
localized(candidate, "name") || (t("option_prefix") + " " + (idx + 1))));
|
||
if (idx === recommendedIndex) {
|
||
head.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||
}
|
||
var status = el("span", "rec-badge direction-status");
|
||
status.style.display = "none";
|
||
head.appendChild(status);
|
||
card.appendChild(head);
|
||
var customVisual = candidate.visual_style === "custom";
|
||
if (candidate.visual_style) {
|
||
var preview = el("div", "design-direction-preview");
|
||
if (customVisual) {
|
||
preview.classList.add("design-direction-custom-preview");
|
||
preview.appendChild(
|
||
el("div", "design-direction-custom-label", t("custom"))
|
||
);
|
||
preview.appendChild(el(
|
||
"div",
|
||
"design-direction-custom-copy",
|
||
directionCardSummary(candidate) || t("custom")
|
||
));
|
||
} else {
|
||
appendVisualStyleImage(preview, candidate.visual_style);
|
||
}
|
||
card.appendChild(preview);
|
||
}
|
||
var meta = [];
|
||
if (candidate.mode && candidate.mode !== "custom") {
|
||
meta.push(directionComponentValueLabel(candidate, "mode"));
|
||
}
|
||
if (candidate.visual_style && !customVisual) {
|
||
meta.push(directionComponentValueLabel(candidate, "visual_style"));
|
||
}
|
||
var typographyName = candidate.typography &&
|
||
(localized(candidate.typography, "name") || candidate.typography.name);
|
||
if (typographyName) meta.push(typographyName);
|
||
if (candidate.icons) meta.push(humanizeId(candidate.icons));
|
||
if (candidate.image_strategy && candidate.image_strategy.rendering &&
|
||
candidate.image_strategy.rendering !== "custom") {
|
||
meta.push(comparisonValueLabel("rendering", candidate.image_strategy.rendering));
|
||
}
|
||
if (meta.length) card.appendChild(el("div", "font-card-meta", meta.join(" · ")));
|
||
var palette = normPalette(candidate.color || {});
|
||
var swatches = el("div", "palette-swatches design-direction-swatches");
|
||
PALETTE_ROLES.forEach(function (role) {
|
||
var value = normHex(palette[role]);
|
||
if (!value) return;
|
||
var swatch = el("span", "swatch");
|
||
swatch.style.background = value;
|
||
swatch.title = role + ": " + value;
|
||
swatches.appendChild(swatch);
|
||
});
|
||
if (swatches.childElementCount) card.appendChild(swatches);
|
||
var note = localized(candidate, "note");
|
||
if (note && !customVisual) card.appendChild(el("div", "color-note", note));
|
||
var restore = el("button", "direction-reset-button", t("direction_restore"));
|
||
restore.type = "button";
|
||
restore.hidden = true;
|
||
restore.addEventListener("click", function (event) {
|
||
event.stopPropagation();
|
||
applyDesignDirection(candidate, idx);
|
||
});
|
||
card.appendChild(restore);
|
||
card.addEventListener("click", function () {
|
||
if (designDirectionId(candidate, idx) === ACTIVE_DIRECTION_ID) return;
|
||
applyDesignDirection(candidate, idx);
|
||
});
|
||
cardStates.push({
|
||
candidate: candidate,
|
||
index: idx,
|
||
card: card,
|
||
status: status,
|
||
restore: restore
|
||
});
|
||
grid.appendChild(card);
|
||
});
|
||
refreshDesignDirectionState = function () {
|
||
cardStates.forEach(function (entry) {
|
||
var active = designDirectionId(entry.candidate, entry.index) === ACTIVE_DIRECTION_ID;
|
||
var adjusted = active && activeDirectionAdjusted(entry.candidate, entry.index);
|
||
entry.card.classList.toggle("selected", active && !adjusted);
|
||
entry.card.classList.toggle("adjusted", adjusted);
|
||
entry.status.style.display = active ? "inline-block" : "none";
|
||
entry.status.textContent = adjusted ? t("direction_adjusted") : t("direction_active");
|
||
entry.restore.hidden = !(active && adjusted);
|
||
entry.card.title = active ? "" : t("direction_apply_hint");
|
||
});
|
||
};
|
||
refreshDesignDirectionState();
|
||
sec.appendChild(grid);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function directionComponentMatchesAuthored(candidate, field) {
|
||
if (field === "mode") {
|
||
return STATE.mode === candidate.mode &&
|
||
(candidate.mode !== "custom" ||
|
||
String(STATE.mode_behavior || "") === directionBehavior(candidate, "mode"));
|
||
}
|
||
if (field === "visual_style") {
|
||
return STATE.visual_style === candidate.visual_style &&
|
||
(candidate.visual_style !== "custom" ||
|
||
String(STATE.visual_style_behavior || "") ===
|
||
directionBehavior(candidate, "visual_style"));
|
||
}
|
||
if (field === "image_strategy") {
|
||
return JSON.stringify(comparableImageStrategy(STATE.image_strategy || {})) ===
|
||
JSON.stringify(comparableImageStrategy(candidate.image_strategy || {}));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function directionComponentSelected(candidate, field, index) {
|
||
var activeId = ACTIVE_COMPONENT_DIRECTION_IDS[field];
|
||
if (activeId) return activeId === designDirectionId(candidate, index);
|
||
return directionComponentMatchesAuthored(candidate, field);
|
||
}
|
||
|
||
function applyDirectionComponent(candidate, field, index) {
|
||
if (field === "mode") {
|
||
STATE.mode = candidate.mode;
|
||
STATE.mode_behavior = candidate.mode === "custom" ?
|
||
directionBehavior(candidate, "mode") : "";
|
||
} else if (field === "visual_style") {
|
||
STATE.visual_style = candidate.visual_style;
|
||
STATE.visual_style_behavior = candidate.visual_style === "custom" ?
|
||
directionBehavior(candidate, "visual_style") : "";
|
||
} else if (field === "image_strategy") {
|
||
STATE.image_strategy = normalizedImageStrategy(candidate.image_strategy || {});
|
||
}
|
||
ACTIVE_COMPONENT_DIRECTION_IDS[field] = designDirectionId(candidate, index);
|
||
renderAll();
|
||
}
|
||
|
||
function directionComponentIsCustom(candidate, field) {
|
||
if (field === "image_strategy") {
|
||
return candidate && candidate.image_strategy &&
|
||
candidate.image_strategy.rendering === "custom";
|
||
}
|
||
return candidate && candidate[field] === "custom";
|
||
}
|
||
|
||
function setDirectionComponentBehavior(field, value) {
|
||
if (field === "mode") {
|
||
STATE.mode = "custom";
|
||
STATE.mode_behavior = value;
|
||
} else if (field === "visual_style") {
|
||
STATE.visual_style = "custom";
|
||
STATE.visual_style_behavior = value;
|
||
} else if (field === "image_strategy") {
|
||
STATE.image_strategy = normalizedImageStrategy(STATE.image_strategy || {});
|
||
STATE.image_strategy.rendering = "custom";
|
||
STATE.image_strategy.behavior = value;
|
||
}
|
||
}
|
||
|
||
function directionComponentValueLabel(candidate, field) {
|
||
var value = candidate && candidate[field];
|
||
if (field === "image_strategy") {
|
||
value = value && value.rendering;
|
||
return comparisonValueLabel("rendering", value);
|
||
}
|
||
var catalog = field === "mode" ? CAT.modes : CAT.visual_styles;
|
||
var option = findCatalogOption(catalog, value);
|
||
return option ? optionLabel(option) : (value === "custom" ? t("custom") : humanizeId(value));
|
||
}
|
||
|
||
function directionComponentNote(candidate, field) {
|
||
if (field === "image_strategy") {
|
||
var strategy = normalizedImageStrategy(candidate.image_strategy || {});
|
||
return [strategy.visual, strategy.mood, strategy.behavior].filter(Boolean).join(" · ");
|
||
}
|
||
if (candidate[field] === "custom") return directionBehavior(candidate, field);
|
||
return localized(candidate, "note") || "";
|
||
}
|
||
|
||
function directionComponentEditableBehavior(candidate, field) {
|
||
if (field === "image_strategy") {
|
||
return normalizedImageStrategy(candidate.image_strategy || {}).behavior || "";
|
||
}
|
||
return directionBehavior(candidate, field);
|
||
}
|
||
|
||
function renderDirectionComponentCandidates(parent, field) {
|
||
var candidates = designDirectionCandidates().filter(function (candidate) {
|
||
return candidate && candidate[field];
|
||
});
|
||
if (!candidates.length) return;
|
||
var block = el("div", "subfield scheme-component-options");
|
||
block.appendChild(el("div", "subfield-label", t("scheme_component_options")));
|
||
var grid = el("div", "font-grid scheme-component-grid");
|
||
var entries = [];
|
||
candidates.forEach(function (candidate, index) {
|
||
var card = el("div", "font-card scheme-component-card");
|
||
card.dataset.candidateId = designDirectionId(candidate, index) + ":" + field;
|
||
var head = el("div", "font-card-head");
|
||
head.appendChild(el("span", "font-card-name",
|
||
localized(candidate, "name") || (t("option_prefix") + " " + (index + 1))));
|
||
head.appendChild(el("span", "font-card-meta",
|
||
directionComponentValueLabel(candidate, field)));
|
||
card.appendChild(head);
|
||
if (field === "visual_style" && candidate.visual_style !== "custom") {
|
||
var preview = el("div", "design-direction-preview");
|
||
appendVisualStyleImage(preview, candidate.visual_style);
|
||
card.appendChild(preview);
|
||
}
|
||
var note = directionComponentNote(candidate, field);
|
||
var noteNode = note ? el("div", "color-note", note) : null;
|
||
if (noteNode) card.appendChild(noteNode);
|
||
var editor = null;
|
||
if (directionComponentIsCustom(candidate, field)) {
|
||
editor = el("textarea", "text-input scheme-component-editor");
|
||
setNaturalInputDirection(editor);
|
||
editor.rows = 4;
|
||
editor.value = directionComponentEditableBehavior(candidate, field);
|
||
editor.placeholder = t(field === "mode" ? "mode_behavior_placeholder" :
|
||
(field === "visual_style" ? "visual_style_behavior_placeholder" :
|
||
"image_strategy_custom_placeholder"));
|
||
editor.style.display = "none";
|
||
editor.addEventListener("click", function (event) {
|
||
event.stopPropagation();
|
||
});
|
||
editor.addEventListener("input", function () {
|
||
setDirectionComponentBehavior(field, editor.value);
|
||
if (field === "image_strategy") refreshImageStrategyPreview();
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
});
|
||
card.appendChild(editor);
|
||
}
|
||
card.addEventListener("click", function () {
|
||
applyDirectionComponent(candidate, field, index);
|
||
});
|
||
entries.push({
|
||
candidate: candidate,
|
||
index: index,
|
||
card: card,
|
||
note: noteNode,
|
||
editor: editor
|
||
});
|
||
grid.appendChild(card);
|
||
});
|
||
var paint = function () {
|
||
entries.forEach(function (entry) {
|
||
var selected = directionComponentSelected(
|
||
entry.candidate, field, entry.index
|
||
);
|
||
var adjusted = selected &&
|
||
!directionComponentMatchesAuthored(entry.candidate, field);
|
||
entry.card.classList.toggle("selected", selected);
|
||
entry.card.classList.toggle("adjusted", adjusted);
|
||
if (entry.editor) {
|
||
entry.editor.style.display = selected ? "block" : "none";
|
||
if (selected && document.activeElement !== entry.editor) {
|
||
var current = field === "mode" ? STATE.mode_behavior :
|
||
(field === "visual_style" ? STATE.visual_style_behavior :
|
||
((STATE.image_strategy || {}).behavior || ""));
|
||
entry.editor.value = current;
|
||
}
|
||
if (selected) fitTextareaToContent(entry.editor);
|
||
}
|
||
if (entry.note) entry.note.style.display = selected ? "none" : "block";
|
||
});
|
||
};
|
||
DIRECTION_COMPONENT_PAINTERS.push(paint);
|
||
paint();
|
||
block.appendChild(grid);
|
||
parent.appendChild(block);
|
||
}
|
||
|
||
function currentDirectionCustomCandidate(field, stateKey) {
|
||
if (STATE[field] !== "custom") return null;
|
||
return designDirectionCandidates().filter(function (candidate) {
|
||
return candidate && candidate[field] === "custom" &&
|
||
directionBehavior(candidate, field) === String(STATE[stateKey] || "");
|
||
})[0];
|
||
}
|
||
|
||
function renderCurrentDirectionCustomEditor(parent, field, stateKey, placeholderKey) {
|
||
if (STATE[field] !== "custom") return null;
|
||
if (ACTIVE_COMPONENT_DIRECTION_IDS[field]) return null;
|
||
var source = currentDirectionCustomCandidate(field, stateKey);
|
||
if (!source && customCandidateBehavior(field)) return null;
|
||
var block = el("div", "subfield direction-custom-editor");
|
||
var label = t("custom");
|
||
if (source && localized(source, "name")) {
|
||
label = localized(source, "name") + " · " + label;
|
||
}
|
||
block.appendChild(el("div", "subfield-label", label));
|
||
var input = el("textarea", "text-input custom-input");
|
||
setNaturalInputDirection(input);
|
||
input.rows = 4;
|
||
input.placeholder = t(placeholderKey);
|
||
input.value = STATE[stateKey] || "";
|
||
input.style.display = "block";
|
||
input.addEventListener("input", function () {
|
||
STATE[stateKey] = input.value;
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
});
|
||
block.appendChild(input);
|
||
parent.appendChild(block);
|
||
return block;
|
||
}
|
||
|
||
function renderNarrativeDirection(host) {
|
||
var sec = section(4, "sec_narrative");
|
||
renderDirectionComponentCandidates(sec, "mode");
|
||
var directionCustomEditor = null;
|
||
var custom = currentDirectionCustomCandidate("mode", "mode_behavior") ? null :
|
||
creativeCustomOptions("mode", "mode_behavior", "mode_behavior_placeholder");
|
||
enumField(sec, CAT.modes, recOrFirst("mode", CAT.modes),
|
||
function () { return STATE.mode; }, function (v) {
|
||
STATE.mode = v;
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.mode = "";
|
||
if (v !== "custom") STATE.mode_behavior = "";
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
if (directionCustomEditor) {
|
||
directionCustomEditor.style.display = v === "custom" ? "block" : "none";
|
||
}
|
||
},
|
||
{
|
||
allowCustom: !!custom,
|
||
customOnOwnRow: true,
|
||
customSentinel: "custom",
|
||
preserveCustom: true,
|
||
placeholder: t("mode_behavior_placeholder"),
|
||
aiCustom: custom
|
||
});
|
||
directionCustomEditor = renderCurrentDirectionCustomEditor(
|
||
sec, "mode", "mode_behavior", "mode_behavior_placeholder"
|
||
);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderVisualDirection(host) {
|
||
var sec = section(5, "sec_visual");
|
||
renderDirectionComponentCandidates(sec, "visual_style");
|
||
var directionCustomEditor = null;
|
||
var custom = currentDirectionCustomCandidate(
|
||
"visual_style", "visual_style_behavior"
|
||
) ? null : creativeCustomOptions(
|
||
"visual_style", "visual_style_behavior", "visual_style_behavior_placeholder"
|
||
);
|
||
enumField(sec, CAT.visual_styles, recOrFirst("visual_style", CAT.visual_styles),
|
||
function () { return STATE.visual_style; }, function (v) {
|
||
STATE.visual_style = v;
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.visual_style = "";
|
||
if (v !== "custom") STATE.visual_style_behavior = "";
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
if (directionCustomEditor) {
|
||
directionCustomEditor.style.display = v === "custom" ? "block" : "none";
|
||
}
|
||
},
|
||
{
|
||
allowCustom: !!custom,
|
||
customOnOwnRow: true,
|
||
customSentinel: "custom",
|
||
preserveCustom: true,
|
||
placeholder: t("visual_style_behavior_placeholder"),
|
||
aiCustom: custom,
|
||
preview: "visual_style",
|
||
chipsClass: "visual-style-grid"
|
||
});
|
||
directionCustomEditor = renderCurrentDirectionCustomEditor(
|
||
sec, "visual_style", "visual_style_behavior",
|
||
"visual_style_behavior_placeholder"
|
||
);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
var PALETTE_ROLES = [
|
||
"background",
|
||
"secondary_bg",
|
||
"primary",
|
||
"accent",
|
||
"secondary_accent",
|
||
"body_text"
|
||
];
|
||
|
||
function normHex(val) {
|
||
var v = (val || "").trim();
|
||
if (!/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(v)) return null;
|
||
return v.charAt(0) === "#" ? v : "#" + v;
|
||
}
|
||
function hexOr(val, fallback) {
|
||
return normHex(val) || fallback;
|
||
}
|
||
// Replaced when the combined color+typography preview mounts; the color and
|
||
// typography sections call it after every change so the preview stays live.
|
||
var refreshStylePreview = function () {};
|
||
// Replaced when the selected generated-image preview mounts.
|
||
var refreshImageStrategyPreview = function () {};
|
||
// Replaced when the final plan's image-production section mounts; image-use
|
||
// edits call it so the conditional AI path stays synchronized on the page.
|
||
var refreshImageProduction = function () {};
|
||
// Replaced when the typography section mounts; the canvas section calls it so
|
||
// the body-size hint tracks the chosen canvas dimensions.
|
||
var refreshBodySizeHint = function () {};
|
||
// Replaced when the typography section mounts; body-size / reading-mode
|
||
// changes call it so unpinned per-role values update locally.
|
||
var refreshSizeInputs = function () {};
|
||
|
||
// Per-role size slots the user can edit directly (parallel to color roles).
|
||
// Defaults derive from `body` via mid-band ramp ratios (strategist.md §g);
|
||
// values are px (the system's only unit).
|
||
var SIZE_ROLES = ["title", "subtitle", "annotation"];
|
||
var SIZE_RATIO = { title: 1.75, subtitle: 1.35, annotation: 0.78 };
|
||
var TYPOGRAPHY_SIZE_OVERRIDES = {
|
||
body: false,
|
||
title: false,
|
||
subtitle: false,
|
||
annotation: false
|
||
};
|
||
|
||
function resetTypographySizeOverrides() {
|
||
Object.keys(TYPOGRAPHY_SIZE_OVERRIDES).forEach(function (role) {
|
||
TYPOGRAPHY_SIZE_OVERRIDES[role] = false;
|
||
});
|
||
}
|
||
|
||
function deriveSize(role, bodyVal) {
|
||
var raw = (bodyVal || 0) * (SIZE_RATIO[role] || 1);
|
||
// All px. On PPT, snap the recommended role size to a clean even number so
|
||
// the user sees conventional sizes (body 24 → title 42, subtitle 32), not
|
||
// ratio leftovers. Non-PPT keeps a plain integer — large px, snapping moot.
|
||
if (isPptCanvas(STATE.canvas)) return Math.round(raw / 2) * 2;
|
||
return Math.round(raw);
|
||
}
|
||
|
||
// Canvas dimensions (viewBox user units) parsed from a catalog `dim` like
|
||
// "1242×1660" or from a custom canvas string containing WxH; null if unknown.
|
||
function canvasDimensions(canvasVal) {
|
||
var dim = null;
|
||
(CAT.canvas || []).forEach(function (o) { if (o.id === canvasVal) dim = o.dim; });
|
||
var m = String(dim || canvasVal || "").match(/(\d{2,5})\s*[×xX*]\s*(\d{2,5})/);
|
||
return m ? { width: parseInt(m[1], 10), height: parseInt(m[2], 10) } : null;
|
||
}
|
||
|
||
// PPT canvases (16:9 / 4:3) take the fixed per-reading-mode body px;
|
||
// other canvases use the canvas-owned effective-span rule below.
|
||
function isPptCanvas(canvasVal) {
|
||
var id = String(canvasVal || "").toLowerCase();
|
||
return id === "ppt169" || id === "ppt43";
|
||
}
|
||
|
||
// Body baseline in **px** per reading mode (legacy key:
|
||
// delivery_purpose). The system is px-only — these mirror the registered-PPT
|
||
// values in references/canvas-formats.md. No pt layer, no conversion. `def` is the fixed
|
||
// recommendation; lo/hi are a sanity envelope for the out-of-range flag only.
|
||
function deliveryBodyPx(purposeId) {
|
||
if (purposeId === "text") return { lo: 18, hi: 21, def: 20 };
|
||
if (purposeId === "presentation") return { lo: 28, hi: 32, def: 32 };
|
||
return { lo: 22, hi: 25, def: 24 }; // balanced — the default
|
||
}
|
||
|
||
// Mirrors references/canvas-formats.md § "Typography Scale Start". The
|
||
// 3× short-side cap keeps extreme aspect ratios from inflating the scale;
|
||
// lo/hi remain advisory and def is the initial anchor, never a hard floor.
|
||
function bodySizeBandForCanvas(canvasVal, purposeId) {
|
||
if (isPptCanvas(canvasVal)) return deliveryBodyPx(purposeId);
|
||
var dims = canvasDimensions(canvasVal);
|
||
if (!dims) return { lo: 32, hi: 48, def: 40 }; // legacy invalid-custom fallback
|
||
var shortSide = Math.min(dims.width, dims.height);
|
||
var longSide = Math.max(dims.width, dims.height);
|
||
var span = Math.min(longSide, 3 * shortSide);
|
||
return {
|
||
lo: Math.round(span * 0.025),
|
||
hi: Math.round(span * 0.033),
|
||
def: Math.round(span * 0.029)
|
||
};
|
||
}
|
||
|
||
function defaultBodySizeForCanvas(canvasVal, purposeId) {
|
||
return bodySizeBandForCanvas(canvasVal, purposeId).def;
|
||
}
|
||
|
||
// Resolve the only deterministic same-stage size dependency locally. The
|
||
// backend authors Stage 2 once; changing reading mode or body size updates
|
||
// only unpinned values already visible in this page.
|
||
function syncUnpinnedTypographySizes(resetBodyFromReadingMode) {
|
||
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
|
||
if (!STATE.typography.sizes) STATE.typography.sizes = {};
|
||
if (resetBodyFromReadingMode && !TYPOGRAPHY_SIZE_OVERRIDES.body) {
|
||
STATE.typography.body_size = defaultBodySizeForCanvas(
|
||
STATE.canvas, STATE.delivery_purpose
|
||
);
|
||
}
|
||
var body = parseFloat(STATE.typography.body_size);
|
||
if (!isFinite(body)) {
|
||
body = defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||
}
|
||
SIZE_ROLES.forEach(function (role) {
|
||
if (!TYPOGRAPHY_SIZE_OVERRIDES[role]) {
|
||
STATE.typography.sizes[role] = deriveSize(role, body);
|
||
}
|
||
});
|
||
refreshSizeInputs();
|
||
refreshBodySizeHint();
|
||
refreshStylePreview();
|
||
}
|
||
|
||
function roundSize(value) {
|
||
return Math.round(value * 100) / 100;
|
||
}
|
||
|
||
function formatPtFromPx(value) {
|
||
var px = parseFloat(value);
|
||
if (!isFinite(px)) return "";
|
||
var pt = Math.round(px * 0.75 * 10) / 10;
|
||
return pt % 1 === 0 ? String(Math.round(pt)) : String(pt);
|
||
}
|
||
|
||
function normalizeTypographyForSubmit(payload) {
|
||
if (!payload.typography || typeof payload.typography !== "object") return;
|
||
var typ = normTypography(payload.typography);
|
||
payload.typography = typ;
|
||
if (isEnglishProject()) {
|
||
["heading", "body"].forEach(function (role) {
|
||
delete typ[role].english;
|
||
delete typ[role].sample_english;
|
||
});
|
||
}
|
||
var body = parseFloat(typ.body_size);
|
||
if (!isFinite(body)) {
|
||
// Cleared / invalid body field — fall back so role sizes never submit
|
||
// against an empty anchor.
|
||
body = defaultBodySizeForCanvas(payload.canvas, payload.delivery_purpose);
|
||
}
|
||
// px is the only unit — round and submit as-is. No pt conversion, no
|
||
// body_size_pt / sizes_pt provenance (the system never carries pt).
|
||
typ.body_size = roundSize(body);
|
||
typ.body_size_unit = "px";
|
||
if (typ.sizes && typeof typ.sizes === "object") {
|
||
Object.keys(typ.sizes).forEach(function (role) {
|
||
var raw = parseFloat(typ.sizes[role]);
|
||
if (isFinite(raw)) typ.sizes[role] = roundSize(raw);
|
||
});
|
||
}
|
||
if (typographyFamiliesComplete(typ)) delete typ.custom;
|
||
// delivery_purpose is PPT-only; drop it on non-PPT canvases where it has
|
||
// no meaning and was never shown.
|
||
if (!isPptCanvas(payload.canvas)) delete payload.delivery_purpose;
|
||
}
|
||
|
||
function renderColor(host) {
|
||
var cands = colorRecommendationCandidates();
|
||
var sec = section(5, "sec_color");
|
||
var grid = el("div", "color-grid");
|
||
var hexInputs = {};
|
||
var hexSwatches = {};
|
||
var cardSwatchRefs = []; // idx -> {role: swatchEl}, for live override feedback
|
||
var selectedIdx = -1;
|
||
|
||
function paintSwatch(elem, val) {
|
||
var n = normHex(val);
|
||
elem.style.background = n || "transparent";
|
||
elem.classList.toggle("hex-swatch-empty", !n);
|
||
}
|
||
function applyHexInputs(pal) {
|
||
PALETTE_ROLES.forEach(function (role) {
|
||
if (hexInputs[role]) hexInputs[role].value = pal[role] || "";
|
||
if (hexSwatches[role]) paintSwatch(hexSwatches[role], pal[role]);
|
||
});
|
||
}
|
||
var customInput = el("textarea", "text-input custom-color-input");
|
||
setNaturalInputDirection(customInput);
|
||
customInput.rows = 2;
|
||
customInput.placeholder = t("custom_color_placeholder");
|
||
customInput.style.display = "none";
|
||
|
||
function selectCard(idx) {
|
||
var c = cands[idx] || {};
|
||
selectedIdx = idx;
|
||
STATE.color = {
|
||
name: localized(c, "name") || c.name || "",
|
||
palette: Object.assign({}, normPalette(c))
|
||
};
|
||
grid.querySelectorAll(".color-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); });
|
||
customInput.style.display = "none";
|
||
applyHexInputs(STATE.color.palette);
|
||
refreshStylePreview();
|
||
}
|
||
|
||
function selectCustomColor() {
|
||
selectedIdx = -1;
|
||
STATE.color = { name: "custom", custom: customInput.value || "", palette: {} };
|
||
grid.querySelectorAll(".color-card").forEach(function (card) { card.classList.remove("selected"); });
|
||
customCard.classList.add("selected");
|
||
customInput.style.display = "block";
|
||
customInput.focus();
|
||
refreshStylePreview();
|
||
}
|
||
|
||
cands.forEach(function (c, idx) {
|
||
var pal = normPalette(c);
|
||
var refs = {};
|
||
var card = el("div", "color-card");
|
||
var sw = el("div", "swatches");
|
||
PALETTE_ROLES.forEach(function (role) {
|
||
if (!pal[role]) return;
|
||
var col = el("div", "swatch-col");
|
||
var s = el("div", "swatch"); s.style.background = pal[role];
|
||
refs[role] = s;
|
||
col.appendChild(s);
|
||
col.appendChild(el("div", "swatch-role", t("role_" + role)));
|
||
col.appendChild(el("div", "color-hex", normHex(pal[role]) || pal[role]));
|
||
sw.appendChild(col);
|
||
});
|
||
cardSwatchRefs[idx] = refs;
|
||
card.appendChild(sw);
|
||
card.appendChild(el("div", "color-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1))));
|
||
if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note")));
|
||
card.addEventListener("click", function () { selectCard(idx); });
|
||
grid.appendChild(card);
|
||
});
|
||
var customCard = el("div", "color-card color-card-custom");
|
||
customCard.appendChild(el("div", "color-name", t("custom_color")));
|
||
customCard.addEventListener("click", selectCustomColor);
|
||
grid.appendChild(customCard);
|
||
sec.appendChild(grid);
|
||
customInput.addEventListener("input", function () {
|
||
if (!STATE.color || STATE.color.name !== "custom") selectCustomColor();
|
||
STATE.color.custom = customInput.value;
|
||
refreshStylePreview();
|
||
});
|
||
sec.appendChild(customInput);
|
||
|
||
var override = el("div", "hex-override");
|
||
override.appendChild(el("div", "subfield-label", t("hex_override")));
|
||
var row = el("div", "hex-row");
|
||
PALETTE_ROLES.forEach(function (role) {
|
||
var wrap = el("div", "hex-cell");
|
||
wrap.appendChild(el("div", "hex-cell-label", t("role_" + role)));
|
||
var line = el("div", "hex-input-line");
|
||
var sw = el("div", "hex-swatch hex-swatch-empty");
|
||
var inp = document.createElement("input");
|
||
inp.type = "text"; inp.placeholder = "#";
|
||
inp.addEventListener("input", function () {
|
||
if (!STATE.color) STATE.color = { name: "custom", palette: {} };
|
||
if (!STATE.color.palette) STATE.color.palette = {};
|
||
STATE.color.palette[role] = inp.value;
|
||
paintSwatch(sw, inp.value);
|
||
// Reflect a valid override straight onto the selected card so the
|
||
// user sees the change in context, not just in the input row.
|
||
var n = normHex(inp.value);
|
||
if (n && selectedIdx >= 0 && cardSwatchRefs[selectedIdx] && cardSwatchRefs[selectedIdx][role]) {
|
||
cardSwatchRefs[selectedIdx][role].style.background = n;
|
||
}
|
||
refreshStylePreview();
|
||
});
|
||
hexInputs[role] = inp; hexSwatches[role] = sw;
|
||
line.appendChild(sw); line.appendChild(inp);
|
||
wrap.appendChild(line); row.appendChild(wrap);
|
||
});
|
||
override.appendChild(row);
|
||
sec.appendChild(override);
|
||
host.appendChild(sec);
|
||
|
||
var selIdx = -1;
|
||
if (STATE.color && STATE.color.name && STATE.color.name !== "custom") {
|
||
cands.forEach(function (c, i) {
|
||
if ((localized(c, "name") || c.name) === STATE.color.name) selIdx = i;
|
||
});
|
||
}
|
||
if (STATE.color && STATE.color.name === "custom") {
|
||
customInput.value = STATE.color.custom || "";
|
||
selectCustomColor();
|
||
} else if (selIdx >= 0) {
|
||
selectCard(selIdx);
|
||
} else {
|
||
applyHexInputs((STATE.color && STATE.color.palette) || {});
|
||
}
|
||
}
|
||
|
||
function renderIcons(host) {
|
||
var sec = section(6, "sec_icons");
|
||
enumField(sec, CAT.icons, recOrFirst("icons", CAT.icons),
|
||
function () { return STATE.icons; }, function (v) { STATE.icons = v; refreshStylePreview(); },
|
||
{ allowCustom: true, customOnOwnRow: true, ownRowIds: ["none"] });
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function previewFontStack(primary, fallback) {
|
||
if (!primary) return fallback || "";
|
||
if (!fallback) return primary;
|
||
return primary + ", " + fallback;
|
||
}
|
||
|
||
function typographyFamiliesComplete(typography) {
|
||
typography = normTypography(typography || {});
|
||
return ["heading", "body"].every(function (role) {
|
||
var font = typography[role] || {};
|
||
var fields = isEnglishProject() ? ["primary", "css"] :
|
||
["primary", "english", "css"];
|
||
return fields.every(function (field) {
|
||
return !!String(font[field] || "").trim();
|
||
});
|
||
});
|
||
}
|
||
|
||
function sampleText(role, field) {
|
||
// Keep comparison copy stable: choices change visual treatment, not content.
|
||
var useEnglish = field === "english" || isEnglishProject();
|
||
if (role === "heading") {
|
||
return t(useEnglish ? "preview_latin_title" : "preview_big_title");
|
||
}
|
||
return t(useEnglish ? "preview_latin_body" : "preview_body_intro");
|
||
}
|
||
|
||
function fontSample(box, slot, css, role) {
|
||
var line = el("div", "font-sample-line");
|
||
var primary = el("span", "fs-primary", sampleText(role, "primary"));
|
||
setUiLanguageAttributes(primary);
|
||
var primaryStack = previewFontStack(slot.primary, css);
|
||
if (primaryStack) primary.style.fontFamily = primaryStack;
|
||
if (primaryStack) primary.title = primaryStack;
|
||
line.appendChild(primary);
|
||
if (!isEnglishProject()) {
|
||
var english = el("span", "fs-english", sampleText(role, "english"));
|
||
setEnglishLanguageAttributes(english);
|
||
var englishStack = previewFontStack(slot.english, css);
|
||
if (englishStack) english.style.fontFamily = englishStack;
|
||
if (englishStack) english.title = englishStack;
|
||
line.appendChild(english);
|
||
}
|
||
box.appendChild(line);
|
||
}
|
||
|
||
function fontChoiceLabel(font) {
|
||
var label = localized(font, "label") || font.id;
|
||
if (String(label).toLowerCase() === String(font.id).toLowerCase()) {
|
||
return String(font.id);
|
||
}
|
||
return label + " · " + font.id;
|
||
}
|
||
|
||
function typographyFieldLabel(field) {
|
||
if (field === "english") return t("english_font");
|
||
return t("primary_language_font");
|
||
}
|
||
|
||
function typographyChoiceConfigs() {
|
||
var configs = [];
|
||
["heading", "body"].forEach(function (role) {
|
||
configs.push([role, "primary"]);
|
||
if (!isEnglishProject()) configs.push([role, "english"]);
|
||
});
|
||
return configs;
|
||
}
|
||
|
||
function typographySignature(typography) {
|
||
typography = normTypography(typography || {});
|
||
var fields = isEnglishProject() ? ["primary"] : ["primary", "english"];
|
||
var values = [];
|
||
["heading", "body"].forEach(function (role) {
|
||
fields.forEach(function (field) {
|
||
values.push(String((typography[role] || {})[field] || "")
|
||
.trim().toLowerCase());
|
||
});
|
||
});
|
||
return values.join("\u0000");
|
||
}
|
||
|
||
function renderTypography(host) {
|
||
var cands = typographyRecommendationCandidates().filter(typographyFamiliesComplete);
|
||
var sec = section(7, "sec_type");
|
||
var grid = el("div", "font-grid");
|
||
var customFields = el("div", "custom-typography-fields font-picker-fields");
|
||
var customInputs = {};
|
||
var customLegacyNote = el("div", "toggle-desc custom-typography-legacy");
|
||
var pickerHead = el("div", "font-picker-head");
|
||
pickerHead.appendChild(el("span", "subfield-label", t("font_selection")));
|
||
var customStatus = el("span", "font-custom-status", t("customized"));
|
||
pickerHead.appendChild(customStatus);
|
||
customFields.appendChild(pickerHead);
|
||
customFields.appendChild(el("div", "toggle-desc font-picker-hint", t("font_picker_hint")));
|
||
customFields.appendChild(customLegacyNote);
|
||
|
||
function syncCustomInputs() {
|
||
var typography = STATE.typography || {};
|
||
customLegacyNote.textContent = String(typography.custom || "").trim();
|
||
customLegacyNote.style.display = customLegacyNote.textContent ? "block" : "none";
|
||
customStatus.style.display = typography.name === "custom" ? "inline-flex" : "none";
|
||
typographyChoiceConfigs().forEach(function (config) {
|
||
var role = config[0], field = config[1];
|
||
var control = customInputs[role + "_" + field];
|
||
if (!control) return;
|
||
var value = String((typography[role] || {})[field] || "");
|
||
var matchingFont = control.options.filter(function (font) {
|
||
return String(font.id).toLowerCase() === value.toLowerCase();
|
||
})[0];
|
||
control.select.value = matchingFont ? matchingFont.id : "__other__";
|
||
control.other.value = matchingFont ? "" : value;
|
||
control.other.style.display = matchingFont ? "none" : "block";
|
||
});
|
||
}
|
||
|
||
function markCustomTypography() {
|
||
if (!STATE.typography) {
|
||
STATE.typography = { name: "custom", heading: {}, body: {} };
|
||
}
|
||
STATE.typography.name = "custom";
|
||
grid.querySelectorAll(".font-card").forEach(function (card) {
|
||
card.classList.remove("selected");
|
||
});
|
||
customCard.classList.add("selected");
|
||
customStatus.style.display = "inline-flex";
|
||
}
|
||
|
||
function updateRoleCss(role) {
|
||
if (!STATE.typography[role]) STATE.typography[role] = {};
|
||
STATE.typography[role].css = derivedFontCss(STATE.typography[role]);
|
||
}
|
||
|
||
function selectFont(idx) {
|
||
var c = normTypography(cands[idx] || {});
|
||
var prev = STATE.typography || {};
|
||
STATE.typography = {
|
||
name: localized(c, "name") || c.name || "",
|
||
heading: c.heading || {},
|
||
body: c.body || {},
|
||
// Font cards choose family and character. Reading mode and
|
||
// explicit size inputs own the sizing state.
|
||
body_size: prev.body_size ||
|
||
defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose),
|
||
sizes: Object.assign({}, prev.sizes || {})
|
||
};
|
||
if (sizeInput) sizeInput.value = STATE.typography.body_size || "";
|
||
grid.querySelectorAll(".font-card").forEach(function (card, i) { card.classList.toggle("selected", i === idx); });
|
||
syncCustomInputs();
|
||
refreshSizeInputs(); // fill any role with no value yet; never overwrites existing values
|
||
refreshStylePreview();
|
||
}
|
||
|
||
function selectCustomTypography() {
|
||
var prev = STATE.typography || {};
|
||
STATE.typography = normTypography(prev);
|
||
STATE.typography.name = "custom";
|
||
STATE.typography.body_size = prev.body_size || "";
|
||
// Switching font family must not drop any explicit size.
|
||
STATE.typography.sizes = Object.assign({}, prev.sizes || {});
|
||
markCustomTypography();
|
||
syncCustomInputs();
|
||
var firstControl = customInputs.heading_primary;
|
||
if (firstControl) firstControl.select.focus();
|
||
refreshSizeInputs();
|
||
refreshStylePreview();
|
||
}
|
||
|
||
cands.forEach(function (c, idx) {
|
||
c = normTypography(c);
|
||
var head = c.heading || {}, body = c.body || {};
|
||
var card = el("div", "font-card");
|
||
var top = el("div", "font-card-head");
|
||
top.appendChild(el("span", "font-card-name", localized(c, "name") || (t("option_prefix") + " " + (idx + 1))));
|
||
var metaFields = isEnglishProject() ? ["primary"] : ["primary", "english"];
|
||
var meta = ["heading", "body"].map(function (role) {
|
||
var slot = role === "heading" ? head : body;
|
||
return t("font_" + role) + " " + metaFields.map(function (field) {
|
||
return typographyFieldLabel(field) + ": " + (slot[field] || "—");
|
||
}).join(" / ");
|
||
}).join(" · ");
|
||
top.appendChild(el("span", "font-card-meta", meta));
|
||
card.appendChild(top);
|
||
var hbox = el("div", "font-sample-heading-box"); fontSample(hbox, head, head.css, "heading"); card.appendChild(hbox);
|
||
var bbox = el("div", "font-sample-body-box"); fontSample(bbox, body, body.css, "body"); card.appendChild(bbox);
|
||
if (localized(c, "note")) card.appendChild(el("div", "color-note", localized(c, "note")));
|
||
card.addEventListener("click", function () { selectFont(idx); });
|
||
grid.appendChild(card);
|
||
});
|
||
var customCard = el("div", "font-card font-card-custom");
|
||
customCard.appendChild(el("div", "font-card-name", t("custom_typography")));
|
||
customCard.addEventListener("click", selectCustomTypography);
|
||
grid.appendChild(customCard);
|
||
sec.appendChild(grid);
|
||
typographyChoiceConfigs().forEach(function (config) {
|
||
var role = config[0], fieldName = config[1];
|
||
var field = el("label", "custom-typography-field");
|
||
field.appendChild(el("span", "hex-cell-label",
|
||
t("font_" + role) + " · " + typographyFieldLabel(fieldName)));
|
||
var options = fontCatalogEntries(fieldName);
|
||
var select = el("select", "font-select");
|
||
options.forEach(function (font) {
|
||
var option = document.createElement("option");
|
||
option.value = font.id;
|
||
option.textContent = fontChoiceLabel(font);
|
||
select.appendChild(option);
|
||
});
|
||
var otherOption = document.createElement("option");
|
||
otherOption.value = "__other__";
|
||
otherOption.textContent = t("other_installed_font");
|
||
select.appendChild(otherOption);
|
||
var input = el("input", "text-input other-font-input");
|
||
input.type = "text";
|
||
input.placeholder = t("other_font_placeholder");
|
||
select.addEventListener("change", function () {
|
||
markCustomTypography();
|
||
if (!STATE.typography[role]) {
|
||
STATE.typography[role] = { css: "sans-serif" };
|
||
}
|
||
if (select.value === "__other__") {
|
||
input.value = "";
|
||
input.style.display = "block";
|
||
STATE.typography[role][fieldName] = "";
|
||
input.focus();
|
||
} else {
|
||
input.value = "";
|
||
input.style.display = "none";
|
||
STATE.typography[role][fieldName] = select.value;
|
||
}
|
||
updateRoleCss(role);
|
||
refreshStylePreview();
|
||
});
|
||
input.addEventListener("input", function () {
|
||
markCustomTypography();
|
||
if (!STATE.typography[role]) {
|
||
STATE.typography[role] = { css: "sans-serif" };
|
||
}
|
||
STATE.typography[role][fieldName] = input.value;
|
||
updateRoleCss(role);
|
||
refreshStylePreview();
|
||
});
|
||
customInputs[role + "_" + fieldName] = {
|
||
select: select,
|
||
other: input,
|
||
options: options
|
||
};
|
||
field.appendChild(select);
|
||
field.appendChild(input);
|
||
customFields.appendChild(field);
|
||
});
|
||
sec.appendChild(customFields);
|
||
|
||
var sizeField = el("div", "subfield");
|
||
sizeField.appendChild(el("div", "subfield-label", t("font_body_size")));
|
||
sizeField.appendChild(el("div", "toggle-desc body-size-relation", t("body_size_unit_relation")));
|
||
var sizeRow = el("div", "font-size-row");
|
||
var sizeInput = el("input", "num-input font-size-input");
|
||
sizeInput.type = "number";
|
||
sizeInput.min = "8";
|
||
sizeInput.max = "256";
|
||
sizeInput.step = "1";
|
||
sizeInput.value = (STATE.typography && STATE.typography.body_size) || "";
|
||
sizeInput.placeholder = isPptCanvas(STATE.canvas)
|
||
? "20 / 24 / 32"
|
||
: String(bodySizeBandForCanvas(STATE.canvas, STATE.delivery_purpose).def);
|
||
sizeInput.addEventListener("input", function () {
|
||
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
|
||
STATE.typography.body_size = sizeInput.value;
|
||
TYPOGRAPHY_SIZE_OVERRIDES.body = sizeInput.value !== "";
|
||
// Body is an explicit local anchor. Recompute only role values the
|
||
// user has not edited; no request leaves the browser.
|
||
syncUnpinnedTypographySizes(false);
|
||
});
|
||
sizeRow.appendChild(sizeInput);
|
||
sizeRow.appendChild(el("span", "font-size-unit", "px"));
|
||
var sizePtHint = el("div", "toggle-desc body-size-pt");
|
||
var sizeHint = el("div", "toggle-desc body-size-hint");
|
||
// PPT body is one fixed px value per reading mode (not a range); non-PPT
|
||
// canvases use the canvas-owned effective span. A manually pinned value is never
|
||
// overwritten by later reading-mode changes.
|
||
// Everything is px — lo/hi are only a sanity envelope for the OOR flag.
|
||
refreshBodySizeHint = function () {
|
||
var txt = t("font_body_size_hint");
|
||
var lo, hi;
|
||
if (isPptCanvas(STATE.canvas)) {
|
||
var pb = deliveryBodyPx(STATE.delivery_purpose);
|
||
lo = pb.lo; hi = pb.hi;
|
||
txt += " " + t("body_size_hint_purpose").replace("{def}", pb.def);
|
||
} else {
|
||
var band = bodySizeBandForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||
lo = band.lo; hi = band.hi;
|
||
txt += " " + t("body_size_hint_canvas")
|
||
.replace("{lo}", lo).replace("{hi}", hi);
|
||
}
|
||
// Flag (hint only) a value far outside the
|
||
// canvas's usual px range, so an accidental extreme value is visible
|
||
// instead of silently submitting it.
|
||
var cur = parseFloat(STATE.typography && STATE.typography.body_size);
|
||
sizePtHint.textContent = isFinite(cur)
|
||
? t("body_size_pt_hint").replace("{pt}", formatPtFromPx(cur))
|
||
: "";
|
||
if (isFinite(cur) && isFinite(lo) && isFinite(hi) && (cur < lo || cur > hi)) {
|
||
txt += " " + t("body_size_hint_oor");
|
||
}
|
||
sizeHint.textContent = txt;
|
||
};
|
||
refreshBodySizeHint();
|
||
sizeField.appendChild(sizeRow);
|
||
sizeField.appendChild(sizePtHint);
|
||
sizeField.appendChild(sizeHint);
|
||
|
||
// Reading mode and typography are both confirmed in Stage 2. Its
|
||
// compatibility key remains delivery_purpose; the dependency is a local
|
||
// deterministic update, not a second Stage-2 recommendation.
|
||
sec.appendChild(sizeField);
|
||
|
||
// Per-role size override (parallel to color's per-role HEX override): the
|
||
// ramp derives title / subtitle / annotation from body, but the user may
|
||
// set each explicitly. Values are px (the system's only unit).
|
||
var sizeOverride = el("div", "hex-override");
|
||
sizeOverride.appendChild(el("div", "subfield-label", t("size_override")));
|
||
var srow = el("div", "hex-row");
|
||
var sizeInputs = {};
|
||
var sizePtHints = {};
|
||
function refreshRolePtHint(role) {
|
||
var input = sizeInputs[role];
|
||
var hint = sizePtHints[role];
|
||
if (!input || !hint) return;
|
||
var pt = formatPtFromPx(input.value);
|
||
hint.textContent = pt ? t("role_size_pt_hint").replace("{pt}", pt) : "";
|
||
}
|
||
SIZE_ROLES.forEach(function (role) {
|
||
var wrap = el("div", "hex-cell");
|
||
wrap.appendChild(el("div", "hex-cell-label", t("size_role_" + role)));
|
||
var inputLine = el("div", "role-size-line");
|
||
var inp = document.createElement("input");
|
||
inp.type = "number"; inp.min = "6"; inp.max = "512"; inp.step = "1";
|
||
inp.addEventListener("input", function () {
|
||
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
|
||
if (!STATE.typography.sizes) STATE.typography.sizes = {};
|
||
STATE.typography.sizes[role] = inp.value;
|
||
TYPOGRAPHY_SIZE_OVERRIDES[role] = true;
|
||
refreshRolePtHint(role);
|
||
refreshStylePreview();
|
||
});
|
||
sizeInputs[role] = inp;
|
||
inputLine.appendChild(inp);
|
||
inputLine.appendChild(el("span", "font-size-unit", "px"));
|
||
wrap.appendChild(inputLine);
|
||
sizePtHints[role] = el("div", "role-size-pt");
|
||
wrap.appendChild(sizePtHints[role]);
|
||
srow.appendChild(wrap);
|
||
});
|
||
sizeOverride.appendChild(srow);
|
||
sec.appendChild(sizeOverride);
|
||
|
||
// Reflect state into the controls. Derivation itself happens only through
|
||
// syncUnpinnedTypographySizes(); a re-render preserves the visible state.
|
||
refreshSizeInputs = function () {
|
||
if (!STATE.typography) STATE.typography = { name: "", heading: {}, body: {} };
|
||
if (!STATE.typography.sizes) STATE.typography.sizes = {};
|
||
sizeInput.value = STATE.typography.body_size || "";
|
||
var bodyVal = parseFloat(STATE.typography.body_size) ||
|
||
defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||
SIZE_ROLES.forEach(function (role) {
|
||
var cur = STATE.typography.sizes[role];
|
||
var hasVal = cur !== undefined && cur !== null && cur !== "";
|
||
if (!hasVal) STATE.typography.sizes[role] = deriveSize(role, bodyVal);
|
||
if (sizeInputs[role]) sizeInputs[role].value = STATE.typography.sizes[role];
|
||
refreshRolePtHint(role);
|
||
});
|
||
};
|
||
refreshSizeInputs();
|
||
|
||
host.appendChild(sec);
|
||
|
||
var nameMatch = -1;
|
||
var signatureMatch = -1;
|
||
var stateSignature = typographySignature(STATE.typography || {});
|
||
if (STATE.typography && STATE.typography.name !== "custom") cands.forEach(function (c, i) {
|
||
var sameName = [localized(c, "name"), c.name_zh, c.name_zh_tw, c.name_en, c.name_ja]
|
||
.some(function (name) { return name === STATE.typography.name; });
|
||
if (!sameName && c.name && typeof c.name === "object") {
|
||
sameName = Object.keys(c.name).some(function (key) {
|
||
return c.name[key] === STATE.typography.name;
|
||
});
|
||
}
|
||
if (sameName && nameMatch < 0) nameMatch = i;
|
||
if (typographySignature(c) === stateSignature && signatureMatch < 0) signatureMatch = i;
|
||
});
|
||
// Names preserve the selected candidate when several recommendations share
|
||
// one font stack. Signature matching is only a first-match legacy fallback.
|
||
var selIdx = nameMatch >= 0 ? nameMatch : signatureMatch;
|
||
if (selIdx >= 0) selectFont(selIdx);
|
||
else if (STATE.typography && STATE.typography.name === "custom") {
|
||
["heading", "body"].forEach(function (role) {
|
||
if (!STATE.typography[role]) STATE.typography[role] = {};
|
||
if (!STATE.typography[role].css) updateRoleCss(role);
|
||
});
|
||
customCard.classList.add("selected");
|
||
syncCustomInputs();
|
||
} else {
|
||
syncCustomInputs();
|
||
}
|
||
}
|
||
|
||
function renderFormulaPolicy(host) {
|
||
var sec = section("F", "formula_policy");
|
||
enumField(sec, CAT.formula_policy, recOrFirst("formula_policy", CAT.formula_policy),
|
||
function () { return STATE.formula_policy; }, function (v) { STATE.formula_policy = v; });
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
// Combined color + typography + icon preview — not a separate confirmation, just a
|
||
// live "overall impression" of the style choices made above. Kept
|
||
// deliberately abstract (a style chip, not a slide layout); page layout
|
||
// preview is the live-preview server's job (Step 6).
|
||
function renderStylePreview(host) {
|
||
var wrap = el("div", "style-preview");
|
||
var label = el("div", "style-preview-label");
|
||
label.appendChild(el("span", "spl-title", t("style_preview_label")));
|
||
// The "rough feel, not a slide layout" caveat sits in the label in the
|
||
// UI font — never rendered in the candidate's body font, so it cannot
|
||
// pose as sample content.
|
||
label.appendChild(el("span", "spl-note", t("style_preview_body")));
|
||
wrap.appendChild(label);
|
||
var card = el("div", "style-preview-card");
|
||
var textcol = el("div", "sp-textcol");
|
||
var title = el("div", "sp-title");
|
||
var titlePrimary = el("span", "sp-title-primary");
|
||
var titleEnglish = el("span", "sp-title-english");
|
||
title.appendChild(titlePrimary); title.appendChild(titleEnglish);
|
||
var bodyRow = el("div", "sp-body");
|
||
var accentBar = el("span", "sp-accent-bar");
|
||
var bodyWrap = el("div", "sp-body-wrap");
|
||
var bodyPrimary = el("span", "sp-body-primary");
|
||
var bodyEnglish = el("span", "sp-body-english");
|
||
setEnglishLanguageAttributes(titleEnglish);
|
||
setEnglishLanguageAttributes(bodyEnglish);
|
||
bodyWrap.appendChild(bodyPrimary); bodyWrap.appendChild(bodyEnglish);
|
||
bodyRow.appendChild(accentBar); bodyRow.appendChild(bodyWrap);
|
||
textcol.appendChild(title); textcol.appendChild(bodyRow);
|
||
var content = el("div", "sp-content");
|
||
var chip = el("div", "sp-chip");
|
||
var chipDot = el("span", "sp-chip-dot");
|
||
var chipLabel = el("span", "sp-chip-label");
|
||
chip.appendChild(chipDot); chip.appendChild(chipLabel);
|
||
card.appendChild(textcol); card.appendChild(content); card.appendChild(chip);
|
||
wrap.appendChild(card);
|
||
host.appendChild(wrap);
|
||
// The strip is mounted inside the top bar on Stage 2, so it stays visible
|
||
// while the center form scrolls.
|
||
wrap.style.top = "0px";
|
||
|
||
function paint() {
|
||
var pal = (STATE.color && STATE.color.palette) || {};
|
||
var typ = STATE.typography || {};
|
||
var head = typ.heading || {}, body = typ.body || {};
|
||
var bg = hexOr(pal.background, "#ffffff");
|
||
var sbg = hexOr(pal.secondary_bg, bg);
|
||
var pri = hexOr(pal.primary, "#1a3a6b");
|
||
var acc = hexOr(pal.accent, pri);
|
||
var sacc = hexOr(pal.secondary_accent, acc);
|
||
var txt = hexOr(pal.body_text, "#1d2430");
|
||
// body_size is px everywhere — preview it directly, no conversion.
|
||
var rawSize = parseFloat(typ.body_size) ||
|
||
defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||
var bodyPx = Math.max(12, Math.min(34, rawSize));
|
||
var headPrimaryStack = previewFontStack(head.primary, head.css);
|
||
var headEnglishStack = previewFontStack(head.english, head.css);
|
||
var bodyPrimaryStack = previewFontStack(body.primary, body.css);
|
||
var bodyEnglishStack = previewFontStack(body.english, body.css);
|
||
|
||
card.style.background = bg;
|
||
titlePrimary.textContent = sampleText("heading", "primary");
|
||
setUiLanguageAttributes(titlePrimary);
|
||
titleEnglish.textContent = sampleText("heading", "english");
|
||
title.style.color = pri;
|
||
title.style.fontSize = Math.round(bodyPx * 1.7) + "px";
|
||
titlePrimary.style.fontFamily = headPrimaryStack || "";
|
||
titleEnglish.style.fontFamily = headEnglishStack || "";
|
||
bodyPrimary.textContent = sampleText("body", "primary");
|
||
setUiLanguageAttributes(bodyPrimary);
|
||
bodyEnglish.textContent = sampleText("body", "english");
|
||
bodyWrap.style.color = txt;
|
||
bodyWrap.style.fontSize = bodyPx + "px";
|
||
bodyPrimary.style.fontFamily = bodyPrimaryStack || "";
|
||
bodyEnglish.style.fontFamily = bodyEnglishStack || "";
|
||
titleEnglish.style.display = isEnglishProject() ? "none" : "";
|
||
bodyEnglish.style.display = isEnglishProject() ? "none" : "";
|
||
accentBar.style.background = acc;
|
||
content.style.color = txt;
|
||
content.style.fontFamily = bodyPrimaryStack || "";
|
||
content.innerHTML = stylePreviewContentMarkup(STATE.icons);
|
||
setUiLanguageAttributes(content);
|
||
chip.style.background = sbg;
|
||
chipDot.style.background = sacc;
|
||
chipLabel.textContent = t("role_secondary_bg");
|
||
chipLabel.style.color = txt;
|
||
chipLabel.style.fontFamily = bodyPrimaryStack || "";
|
||
}
|
||
refreshStylePreview = paint;
|
||
paint();
|
||
}
|
||
|
||
function renderImageStrategyPreview(host) {
|
||
var wrap = el("div", "style-preview image-strategy-left-preview");
|
||
var label = el("div", "style-preview-label");
|
||
label.appendChild(el("span", "spl-title", t("image_strategy")));
|
||
label.appendChild(el("span", "spl-note", t("image_strategy_reference_hint")));
|
||
wrap.appendChild(label);
|
||
var card = el("div", "style-preview-card image-strategy-preview-card");
|
||
var visual = el("div", "image-strategy-preview-visual");
|
||
var copy = el("div", "direction-preview-copy");
|
||
var title = el("div", "direction-preview-title");
|
||
var desc = el("div", "direction-preview-desc");
|
||
copy.appendChild(title);
|
||
copy.appendChild(desc);
|
||
card.appendChild(visual);
|
||
card.appendChild(copy);
|
||
wrap.appendChild(card);
|
||
host.appendChild(wrap);
|
||
function paint() {
|
||
var show = needsGeneratedImagesForUsage(STATE.image_usage);
|
||
wrap.hidden = !show;
|
||
if (!show) return;
|
||
var strategy = STATE.image_strategy || {};
|
||
visual.innerHTML = "";
|
||
var row = appendImageStrategyPreviews(visual, strategy);
|
||
visual.classList.toggle("image-strategy-preview-empty", !row);
|
||
if (!row) visual.appendChild(el("div", "toggle-desc",
|
||
strategy.rendering === "custom" ? t("image_strategy_no_reference") :
|
||
t("image_strategy_select_placeholder")));
|
||
title.textContent = strategy.name ||
|
||
(strategy.rendering === "custom" ? t("image_strategy_ai_custom") :
|
||
(strategy.rendering ? comparisonValueLabel("rendering", strategy.rendering) :
|
||
t("image_strategy_select_placeholder")));
|
||
var parts = [];
|
||
if (strategy.rendering) {
|
||
parts.push(t("image_strategy_rendering") + ": " +
|
||
comparisonValueLabel("rendering", strategy.rendering));
|
||
}
|
||
if (strategy.visual) parts.push(t("image_strategy_visual") + ": " + strategy.visual);
|
||
if (strategy.mood) parts.push(t("image_strategy_mood") + ": " + strategy.mood);
|
||
if (strategy.behavior) parts.push(strategy.behavior);
|
||
desc.textContent = parts.join(" · ") || t("image_strategy_reference_hint");
|
||
}
|
||
refreshImageStrategyPreview = paint;
|
||
paint();
|
||
}
|
||
|
||
function findCatalogOption(list, id) {
|
||
var flat = [];
|
||
(list || []).forEach(function (item) {
|
||
if (item && item.items) flat = flat.concat(item.items);
|
||
else flat.push(item);
|
||
});
|
||
for (var i = 0; i < flat.length; i += 1) {
|
||
if (flat[i] && flat[i].id === id) return flat[i];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function stylePreviewRows() {
|
||
return [
|
||
[t("preview_point_1_title"), t("preview_point_1_text")],
|
||
[t("preview_point_2_title"), t("preview_point_2_text")],
|
||
[t("preview_point_3_title"), t("preview_point_3_text")]
|
||
];
|
||
}
|
||
|
||
function stylePreviewContentMarkup(iconId) {
|
||
var rows = stylePreviewRows();
|
||
var icons = stylePreviewIconSamples(iconId, rows.length);
|
||
return rows.map(function (row, idx) {
|
||
return '<div class="sp-content-row">' +
|
||
'<span class="sp-content-icon">' + icons[idx] + '</span>' +
|
||
'<span class="sp-content-copy"><b>' + escapeHtml(row[0]) + '</b><small>' + escapeHtml(row[1]) + '</small></span>' +
|
||
'</div>';
|
||
}).join("");
|
||
}
|
||
|
||
function stylePreviewIconSamples(iconId, count) {
|
||
if (iconId === "emoji") return ["📊", "💡", "✅"].slice(0, count).map(function (x) {
|
||
return '<span class="sp-icon-emoji">' + x + '</span>';
|
||
});
|
||
if (iconId === "none") return new Array(count).fill('<span class="sp-icon-none-dot"></span>');
|
||
var samples = ICON_PREVIEWS[iconId] || [];
|
||
var out = [];
|
||
for (var i = 0; i < count; i += 1) {
|
||
var sample = samples[i % Math.max(samples.length, 1)];
|
||
out.push(sample ? '<span class="sp-icon-mark" title="' + escapeHtml(sample.name || "") + '">' + (sample.svg || "") + '</span>' : '<span class="sp-icon-none-dot"></span>');
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function renderImageDirection(host) {
|
||
var sec = section(8, "sec_images");
|
||
var usageChips = el("div", "chips");
|
||
var usageNote = el("div", "subfield");
|
||
usageNote.appendChild(el("div", "subfield-label", t("image_usage_notes")));
|
||
var usageNoteInput = el("textarea", "text-input image-usage-notes-input");
|
||
setNaturalInputDirection(usageNoteInput);
|
||
usageNoteInput.placeholder = t("image_usage_notes_placeholder");
|
||
usageNoteInput.value = STATE.image_notes || "";
|
||
usageNoteInput.addEventListener("input", function () { STATE.image_notes = usageNoteInput.value; });
|
||
usageNote.appendChild(usageNoteInput);
|
||
|
||
var strategySub = el("div", "subfield image-strategy-subfield");
|
||
strategySub.appendChild(el("div", "subfield-label", t("image_strategy")));
|
||
strategySub.appendChild(el("div", "toggle-desc", t("image_strategy_reference_hint")));
|
||
renderDirectionComponentCandidates(strategySub, "image_strategy");
|
||
var recommendedStrategies = imageStrategyRecommendationCandidates();
|
||
var strategyCands = imageStrategySelectableCandidates();
|
||
var hasRecommendedStrategies = recommendedStrategies.length > 0;
|
||
var customStrategy = STATE.image_strategy_custom || imageStrategyCustomCandidate();
|
||
var presetPicker = el("div", "image-strategy-picker");
|
||
var presetSelect = el("select", "font-select image-strategy-select");
|
||
var customCard = null;
|
||
var currentCustomEditor = null;
|
||
var syncCustomStrategy = function () {};
|
||
var selectCustomImageStrategy = function () {};
|
||
|
||
function selectImageStrategy(idx) {
|
||
if (!strategyCands[idx]) return;
|
||
STATE.image_strategy = normalizedImageStrategy(strategyCands[idx]);
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.image_strategy = "";
|
||
presetSelect.value = String(idx);
|
||
if (customCard) customCard.classList.remove("selected");
|
||
if (currentCustomEditor) currentCustomEditor.style.display = "none";
|
||
syncCustomStrategy(false);
|
||
refreshImageStrategyPreview();
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
}
|
||
|
||
function imageStrategyCandidateIndex(strategy) {
|
||
if (!strategy) return -1;
|
||
var normalized = JSON.stringify(comparableImageStrategy(strategy));
|
||
for (var i = 0; i < strategyCands.length; i += 1) {
|
||
if (JSON.stringify(comparableImageStrategy(strategyCands[i])) === normalized) return i;
|
||
}
|
||
for (var fallback = 0; fallback < strategyCands.length; fallback += 1) {
|
||
if (strategyCands[fallback] &&
|
||
strategyCands[fallback].rendering === strategy.rendering) return fallback;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function strategyOptionLabel(candidate, idx) {
|
||
var renderingLabel = comparisonValueLabel("rendering", candidate.rendering);
|
||
var candidateName = localized(candidate, "name") || renderingLabel ||
|
||
(t("option_prefix") + " " + (idx + 1));
|
||
return candidateName !== renderingLabel ?
|
||
candidateName + " · " + renderingLabel : candidateName;
|
||
}
|
||
|
||
function appendStrategyOptions(label, start, end) {
|
||
if (start >= end) return;
|
||
var group = document.createElement("optgroup");
|
||
group.label = label;
|
||
for (var idx = start; idx < end; idx += 1) {
|
||
var option = document.createElement("option");
|
||
option.value = String(idx);
|
||
option.textContent = strategyOptionLabel(strategyCands[idx], idx);
|
||
group.appendChild(option);
|
||
}
|
||
presetSelect.appendChild(group);
|
||
}
|
||
|
||
var placeholderOption = document.createElement("option");
|
||
placeholderOption.value = "";
|
||
placeholderOption.textContent = t("image_strategy_select_placeholder");
|
||
placeholderOption.disabled = true;
|
||
placeholderOption.selected = true;
|
||
presetSelect.appendChild(placeholderOption);
|
||
appendStrategyOptions(t("image_strategy_recommended_group"), 0, recommendedStrategies.length);
|
||
appendStrategyOptions(t("image_strategy_all_group"), recommendedStrategies.length, strategyCands.length);
|
||
presetSelect.disabled = !strategyCands.length;
|
||
presetSelect.addEventListener("change", function () {
|
||
selectImageStrategy(parseInt(presetSelect.value, 10));
|
||
});
|
||
presetPicker.appendChild(presetSelect);
|
||
if (!strategyCands.length) {
|
||
presetPicker.appendChild(el("div", "toggle-desc", t("image_strategy_empty")));
|
||
}
|
||
strategySub.appendChild(presetPicker);
|
||
|
||
if (customStrategy) {
|
||
customStrategy = normalizedImageStrategy(customStrategy);
|
||
STATE.image_strategy_custom = customStrategy;
|
||
customCard = el("div", "font-card image-strategy-custom-card ai-custom-candidate");
|
||
var customTop = el("div", "font-card-head");
|
||
customTop.appendChild(el("span", "font-card-name",
|
||
customStrategy.name || t("image_strategy_ai_custom")));
|
||
customTop.appendChild(el("span", "font-card-meta", t("image_strategy_ai_custom_desc")));
|
||
customCard.appendChild(customTop);
|
||
[
|
||
["image_strategy_visual", customStrategy.visual],
|
||
["image_strategy_mood", customStrategy.mood]
|
||
].forEach(function (row) {
|
||
if (row[1]) customCard.appendChild(el("div", "color-note", t(row[0]) + ":" + row[1]));
|
||
});
|
||
var customCopy = el("div", "ai-custom-candidate-copy",
|
||
customStrategy.behavior || t("image_strategy_custom_placeholder"));
|
||
customCard.appendChild(customCopy);
|
||
var customInput = el("textarea", "text-input image-strategy-custom-input");
|
||
setNaturalInputDirection(customInput);
|
||
customInput.rows = 4;
|
||
customInput.placeholder = t("image_strategy_custom_placeholder");
|
||
customInput.value = customStrategy.behavior;
|
||
customInput.style.display = "none";
|
||
customCard.appendChild(customInput);
|
||
|
||
syncCustomStrategy = function (selected) {
|
||
customCopy.textContent = customStrategy.behavior || t("image_strategy_custom_placeholder");
|
||
customCopy.style.display = selected ? "none" : "block";
|
||
customInput.style.display = selected ? "block" : "none";
|
||
if (selected && customInput.value !== customStrategy.behavior) {
|
||
customInput.value = customStrategy.behavior || "";
|
||
}
|
||
};
|
||
|
||
selectCustomImageStrategy = function () {
|
||
STATE.image_strategy = normalizedImageStrategy(customStrategy);
|
||
ACTIVE_COMPONENT_DIRECTION_IDS.image_strategy = "";
|
||
presetSelect.value = "";
|
||
customCard.classList.add("selected");
|
||
syncCustomStrategy(true);
|
||
refreshImageStrategyPreview();
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
};
|
||
|
||
customInput.addEventListener("click", function (event) { event.stopPropagation(); });
|
||
customInput.addEventListener("input", function () {
|
||
customStrategy.behavior = customInput.value;
|
||
STATE.image_strategy_custom = normalizedImageStrategy(customStrategy);
|
||
selectCustomImageStrategy();
|
||
});
|
||
customCard.addEventListener("click", function () {
|
||
selectCustomImageStrategy();
|
||
customInput.focus();
|
||
});
|
||
syncCustomStrategy(false);
|
||
strategySub.appendChild(customCard);
|
||
}
|
||
|
||
if (!customCard && !ACTIVE_COMPONENT_DIRECTION_IDS.image_strategy &&
|
||
STATE.image_strategy &&
|
||
STATE.image_strategy.rendering === "custom") {
|
||
var customEditorField = el("div", "subfield image-strategy-current-custom");
|
||
customEditorField.appendChild(el("div", "subfield-label", t("custom")));
|
||
currentCustomEditor = el("textarea", "text-input image-strategy-custom-input");
|
||
setNaturalInputDirection(currentCustomEditor);
|
||
currentCustomEditor.rows = 4;
|
||
currentCustomEditor.placeholder = t("image_strategy_custom_placeholder");
|
||
currentCustomEditor.value = STATE.image_strategy.behavior || "";
|
||
currentCustomEditor.addEventListener("input", function () {
|
||
STATE.image_strategy.behavior = currentCustomEditor.value;
|
||
refreshImageStrategyPreview();
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
});
|
||
customEditorField.appendChild(currentCustomEditor);
|
||
strategySub.appendChild(customEditorField);
|
||
}
|
||
|
||
var recommendedIds = selectedImageUsageIds(recValue("image_usage"));
|
||
if (!recommendedIds.length) recommendedIds = [defaultImageUsageId()];
|
||
var usageChipById = {};
|
||
function refreshUsageChips() {
|
||
Object.keys(usageChipById).forEach(function (id) {
|
||
usageChipById[id].classList.toggle("selected", STATE.image_usage.indexOf(id) >= 0);
|
||
});
|
||
var noImages = STATE.image_usage.indexOf("none") >= 0;
|
||
usageNote.style.display = noImages ? "none" : "block";
|
||
strategySub.style.display = needsGeneratedImagesForUsage(STATE.image_usage) ? "block" : "none";
|
||
refreshImageStrategyPreview();
|
||
}
|
||
function toggleImageUsage(id) {
|
||
var current = STATE.image_usage.slice();
|
||
if (id === "none") {
|
||
current = current.indexOf("none") >= 0 ? [] : ["none"];
|
||
} else {
|
||
current = current.filter(function (item) { return item !== "none"; });
|
||
if (current.indexOf(id) >= 0) {
|
||
current = current.filter(function (item) { return item !== id; });
|
||
} else {
|
||
current.push(id);
|
||
}
|
||
}
|
||
STATE.image_usage = current;
|
||
refreshUsageChips();
|
||
refreshImageProduction();
|
||
}
|
||
(CAT.image_usage || []).forEach(function (option) {
|
||
var label = optionLabel(option);
|
||
var desc = optionDesc(option);
|
||
if (desc) label += (LANG === "zh" || LANG === "ja" ? ":" : " — ") + desc;
|
||
var chip = el("div", "chip");
|
||
chip.appendChild(el("span", "chip-text", label));
|
||
if (recommendedIds.indexOf(option.id) >= 0) {
|
||
chip.classList.add("recommended");
|
||
chip.appendChild(el("span", "rec-badge", "★ " + t("recommended")));
|
||
}
|
||
chip.addEventListener("click", function () { toggleImageUsage(option.id); });
|
||
usageChipById[option.id] = chip;
|
||
usageChips.appendChild(chip);
|
||
});
|
||
sec.appendChild(usageChips);
|
||
sec.appendChild(usageNote);
|
||
sec.appendChild(strategySub);
|
||
|
||
var currentStrategyIndex = imageStrategyCandidateIndex(STATE.image_strategy);
|
||
if (STATE.image_strategy && STATE.image_strategy.rendering === "custom" && customCard) {
|
||
selectCustomImageStrategy();
|
||
} else if (STATE.image_strategy && STATE.image_strategy.rendering === "custom") {
|
||
presetSelect.value = "";
|
||
} else if (currentStrategyIndex >= 0) {
|
||
presetSelect.value = String(currentStrategyIndex);
|
||
if (customCard) customCard.classList.remove("selected");
|
||
syncCustomStrategy(false);
|
||
} else if ((!STATE.image_strategy || !STATE.image_strategy.rendering) &&
|
||
needsGeneratedImagesForUsage(STATE.image_usage) &&
|
||
hasRecommendedStrategies && strategyCands.length) {
|
||
selectImageStrategy(imageStrategySelectedIndex());
|
||
}
|
||
refreshUsageChips();
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderImageProduction(host) {
|
||
var sec = section("P", "sec_image_production", t("image_production_hint"));
|
||
var body = el("div", "image-production-body");
|
||
sec.appendChild(body);
|
||
refreshImageProduction = function () {
|
||
body.innerHTML = "";
|
||
var summary = el("div", "subfield");
|
||
summary.appendChild(el("div", "subfield-label", t("image_source_summary")));
|
||
var chips = el("div", "chips locked-summary-chips");
|
||
(STATE.image_usage || []).forEach(function (id) {
|
||
var option = findCatalogOption(CAT.image_usage, id);
|
||
chips.appendChild(el("div", "chip selected locked-summary-chip",
|
||
option ? optionLabel(option) : humanizeId(id)));
|
||
});
|
||
summary.appendChild(chips);
|
||
body.appendChild(summary);
|
||
if (needsGeneratedImagesForUsage(STATE.image_usage)) {
|
||
var pathField = el("div", "subfield");
|
||
pathField.appendChild(el("div", "subfield-label", t("image_ai_path")));
|
||
enumField(pathField, CAT.image_ai_path, recOrFirst("image_ai_path", CAT.image_ai_path),
|
||
function () { return STATE.image_ai_path; }, function (value) { STATE.image_ai_path = value; });
|
||
body.appendChild(pathField);
|
||
}
|
||
};
|
||
refreshImageProduction();
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderMode(host) {
|
||
var sec = section("M", "sec_mode");
|
||
function refresh() {
|
||
setSectionNote(sec, STATE.generation_mode === "split" ? t("mode_split_desc") : t("mode_continuous_desc"));
|
||
}
|
||
enumField(sec, CAT.generation_mode, recOrFirst("generation_mode", CAT.generation_mode),
|
||
function () { return STATE.generation_mode; }, function (v) { STATE.generation_mode = v; refresh(); });
|
||
refresh();
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderProactiveExecution(host) {
|
||
var sec = section("A", "sec_proactive_execution", t("proactive_execution_hint"));
|
||
var opts = [{ id: "off", label: t("off_default") }, { id: "on", label: t("on") }];
|
||
|
||
function addToggle(labelKey, descKey, stateKey, fallback) {
|
||
var field = el("div", "subfield");
|
||
field.appendChild(el("div", "subfield-label", t(labelKey)));
|
||
field.appendChild(el("div", "toggle-desc", t(descKey)));
|
||
var recommended = booleanRecommendation(stateKey, fallback);
|
||
enumField(field, opts, recommended ? "on" : "off",
|
||
function () { return STATE[stateKey] ? "on" : "off"; },
|
||
function (value) {
|
||
STATE[stateKey] = value === "on";
|
||
});
|
||
sec.appendChild(field);
|
||
}
|
||
|
||
addToggle(
|
||
"proactive_speaker_notes",
|
||
"proactive_speaker_notes_desc",
|
||
"proactive_speaker_notes",
|
||
true
|
||
);
|
||
addToggle(
|
||
"proactive_custom_animations",
|
||
"proactive_custom_animations_desc",
|
||
"proactive_custom_animations",
|
||
false
|
||
);
|
||
addToggle(
|
||
"proactive_narration_audio",
|
||
"proactive_narration_audio_desc",
|
||
"proactive_narration_audio",
|
||
false
|
||
);
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
function renderRefine(host) {
|
||
var sec = section("R", "sec_refine");
|
||
var opts = [{ id: "off", label: t("off_default") }, { id: "on", label: t("on") }];
|
||
function refresh() {
|
||
setSectionNote(sec, STATE.refine_spec ? t("refine_on_desc") : t("refine_off_desc"));
|
||
}
|
||
enumField(sec, opts, STATE.refine_spec ? "on" : "off",
|
||
function () { return STATE.refine_spec ? "on" : "off"; },
|
||
function (v) { STATE.refine_spec = (v === "on"); refresh(); });
|
||
refresh();
|
||
host.appendChild(sec);
|
||
}
|
||
|
||
// Two-stage confirmation: communication contract, then complete final plan.
|
||
var STAGE = 1;
|
||
|
||
function stageNumber(data) {
|
||
var raw = data && data.stage;
|
||
raw = String(raw == null ? "" : raw).toLowerCase();
|
||
if (raw === "stage1") return 1;
|
||
if (raw === "stage2") return 2;
|
||
return 0;
|
||
}
|
||
|
||
function stageTitle(stage) {
|
||
if (stage === 1) return t("stage_anchors");
|
||
if (stage === 2) return t("stage_final_plan");
|
||
return t("page_title");
|
||
}
|
||
|
||
function renderForStage(stage) {
|
||
var host = document.getElementById("sections");
|
||
host.innerHTML = "";
|
||
_secCounter = 0;
|
||
var heading = document.querySelector("#topbar .topbar-titles h1");
|
||
if (heading) heading.textContent = stageTitle(stage);
|
||
var hint = document.getElementById("topbar-hint");
|
||
if (hint) hint.textContent = stage === 1 ? t("stage1_current_value_hint") : t("topbar_hint");
|
||
// Detach the previous preview's repaint closures before the sections
|
||
// re-render: color/typography auto-select would otherwise call them and
|
||
// write to now-detached nodes until renderStylePreview remounts them.
|
||
refreshStylePreview = function () {};
|
||
refreshImageStrategyPreview = function () {};
|
||
refreshImageProduction = function () {};
|
||
refreshBodySizeHint = function () {};
|
||
refreshSizeInputs = function () {};
|
||
DIRECTION_COMPONENT_PAINTERS = [];
|
||
var previewHost = document.getElementById("topbar-preview");
|
||
if (previewHost) previewHost.innerHTML = "";
|
||
if (stage === 1) {
|
||
// Stage 1 closes the communication contract and its design basis in
|
||
// one submission. Detailed template controls stay hidden until the
|
||
// user explicitly chooses template-backed design.
|
||
renderCommunication(host);
|
||
renderDelivery(host);
|
||
renderCanvas(host);
|
||
renderTemplateSelection(host);
|
||
} else if (stage === 2) {
|
||
if (previewHost) renderStylePreview(previewHost);
|
||
if (previewHost) renderImageStrategyPreview(previewHost);
|
||
// Stage 2 confirms one coherent deck solution. Bundles provide a
|
||
// coordinated starting point; individual controls remain editable.
|
||
renderTemplateApplication(host);
|
||
renderDesignDirections(host);
|
||
renderNarrativeDirection(host);
|
||
renderVisualDirection(host);
|
||
renderReadingMode(host);
|
||
renderPages(host);
|
||
var styleGroup = el("div", "style-group");
|
||
renderColor(styleGroup);
|
||
renderIcons(styleGroup);
|
||
renderTypography(styleGroup);
|
||
host.appendChild(styleGroup);
|
||
renderImageDirection(host);
|
||
renderImageProduction(host);
|
||
renderFormulaPolicy(host);
|
||
renderProactiveExecution(host);
|
||
renderMode(host);
|
||
renderRefine(host);
|
||
var refreshDirectionIndicators = function () {
|
||
window.setTimeout(function () {
|
||
refreshDesignDirectionState();
|
||
refreshDirectionComponentStates();
|
||
}, 0);
|
||
};
|
||
host.oninput = refreshDirectionIndicators;
|
||
host.onchange = refreshDirectionIndicators;
|
||
host.onclick = refreshDirectionIndicators;
|
||
}
|
||
updateActionBar(stage);
|
||
}
|
||
|
||
function renderAll() { renderForStage(STAGE); }
|
||
|
||
function updateActionBar(stage) {
|
||
var btn = document.getElementById("btn-confirm");
|
||
btn.disabled = false;
|
||
if (stage === 1) btn.textContent = t("btn_confirm_contract");
|
||
else if (stage === 2) btn.textContent = t("btn_confirm_final_plan");
|
||
else btn.textContent = t("btn_confirm");
|
||
}
|
||
|
||
// ---- state init (once) ----------------------------------------------
|
||
function firstId(list) {
|
||
if (!list || !list.length) return undefined;
|
||
if (list[0].items) return (list[0].items[0] || {}).id;
|
||
return list[0].id;
|
||
}
|
||
function pick(field, catList) {
|
||
var recommended = recId(field);
|
||
if (recommended == null || recommended === "") {
|
||
recommended = normalizeRecId(field, directionField(field));
|
||
}
|
||
return recommended != null && recommended !== "" ? recommended : firstId(catList);
|
||
}
|
||
|
||
function initCreativeSelection(field, catalog, behaviorKey) {
|
||
var value = pick(field, catalog);
|
||
var behavior = customCandidateBehavior(field);
|
||
if (value && value !== "custom" && !findCatalogOption(catalog, value)) {
|
||
behavior = String(value);
|
||
value = "custom";
|
||
}
|
||
STATE[field] = value;
|
||
STATE[behaviorKey] = behavior;
|
||
}
|
||
|
||
function initStage1State() {
|
||
STATE.primary_language = recommendationLanguage();
|
||
STATE.canvas = pick("canvas", CAT.canvas);
|
||
STATE.audience = (REC.audience && REC.audience.value) || "";
|
||
STATE.communication_intent = (REC.communication_intent && REC.communication_intent.value) || "";
|
||
STATE.audience_outcome = (REC.audience_outcome && REC.audience_outcome.value) || "";
|
||
STATE.core_message = (REC.core_message && REC.core_message.value) || "";
|
||
STATE.delivery_context = (REC.delivery_context && REC.delivery_context.value) || "";
|
||
STATE.artifact_afterlife = (REC.artifact_afterlife && REC.artifact_afterlife.value) || "";
|
||
STATE.content_divergence = (REC.content_divergence && REC.content_divergence.value) || ""; // free text; blank = balanced default
|
||
}
|
||
|
||
// Stage-2 fields are (re-)read from the recommendations. At boot they come from
|
||
// the active stage file; after a stage-1 confirm enterStage() calls this again
|
||
// with the newly authored candidates. Stage-1 STATE is preserved
|
||
// across the single-session transition — this never resets the contract.
|
||
function initStage2State() {
|
||
[
|
||
"audience",
|
||
"communication_intent",
|
||
"audience_outcome",
|
||
"core_message",
|
||
"delivery_context",
|
||
"artifact_afterlife",
|
||
"content_divergence"
|
||
].forEach(function (field) {
|
||
var value = recValue(field);
|
||
if (value && typeof value === "object") value = value.value;
|
||
if (value != null) STATE[field] = String(value);
|
||
});
|
||
STATE.primary_language = recommendationLanguage();
|
||
resetTypographySizeOverrides();
|
||
var templateApplication = templateApplicationRecommendation();
|
||
if (templateApplication != null) {
|
||
STATE.template_application = templateApplication;
|
||
} else if (stageNumber(REC) === 2) {
|
||
delete STATE.template_application;
|
||
}
|
||
// Reading mode is a design-density tool, not part of the communication
|
||
// purpose. Keep the legacy delivery_purpose key for JSON compatibility.
|
||
STATE.delivery_purpose = recId("delivery_purpose") ||
|
||
directionField("delivery_purpose") || STATE.delivery_purpose || "balanced";
|
||
STATE.page_count = (REC.page_count && REC.page_count.value != null) ? String(REC.page_count.value) : (STATE.page_count || "");
|
||
initCreativeSelection("mode", CAT.modes, "mode_behavior");
|
||
initCreativeSelection("visual_style", CAT.visual_styles, "visual_style_behavior");
|
||
var cc = colorRecommendationCandidates();
|
||
var csel = (REC.color && REC.color.selected != null) ? REC.color.selected :
|
||
selectedDesignDirectionIndex();
|
||
var c0 = cc[Math.min(csel, Math.max(cc.length - 1, 0))] || {};
|
||
STATE.color = {
|
||
name: localized(c0, "name") || c0.name || "",
|
||
palette: Object.assign({}, normPalette(c0))
|
||
};
|
||
|
||
STATE.icons = pick("icons", CAT.icons);
|
||
|
||
var tc = typographyRecommendationCandidates();
|
||
var tsel = (REC.typography && REC.typography.selected != null) ? REC.typography.selected :
|
||
selectedDesignDirectionIndex();
|
||
var t0 = normTypography(tc[Math.min(tsel, Math.max(tc.length - 1, 0))] || {});
|
||
STATE.typography = {
|
||
name: localized(t0, "name") || t0.name || "",
|
||
heading: t0.heading || {},
|
||
body: t0.body || {},
|
||
body_size: t0.body_size || typographyBodySize(REC.typography),
|
||
sizes: Object.assign({}, t0.sizes || {})
|
||
};
|
||
if (t0.custom) STATE.typography.custom = t0.custom;
|
||
|
||
// Guarantee a body baseline even when a candidate omitted body_size, on
|
||
// any canvas (PPT → px default by purpose, non-PPT → px from effective span),
|
||
// so role sizes never derive from an empty anchor.
|
||
if (STATE.typography && !STATE.typography.body_size) {
|
||
STATE.typography.body_size = defaultBodySizeForCanvas(STATE.canvas, STATE.delivery_purpose);
|
||
}
|
||
// Preserve an authored positive candidate anchor; derive only the roles
|
||
// that remain unpinned. A user reading-mode change may reset it later.
|
||
if (stageNumber(REC) === 2) syncUnpinnedTypographySizes(false);
|
||
var rawImageUsage = recValue("image_usage");
|
||
STATE.image_usage = selectedImageUsageIds(rawImageUsage);
|
||
if (!STATE.image_usage.length) {
|
||
STATE.image_usage = [defaultImageUsageId()];
|
||
}
|
||
STATE.image_notes = imageUsageNotesRecommendation(rawImageUsage);
|
||
STATE.image_strategy_custom = imageStrategyCustomCandidate();
|
||
var strategyCandidates = imageStrategyRecommendationCandidates();
|
||
var directionStrategy = directionField("image_strategy");
|
||
var customStrategyRecommended = recId("image_strategy") === "custom";
|
||
if (customStrategyRecommended && STATE.image_strategy_custom) {
|
||
STATE.image_strategy = normalizedImageStrategy(STATE.image_strategy_custom);
|
||
} else if (directionStrategy) {
|
||
STATE.image_strategy = normalizedImageStrategy(directionStrategy);
|
||
} else if (strategyCandidates.length) {
|
||
STATE.image_strategy = normalizedImageStrategy(
|
||
strategyCandidates[imageStrategySelectedIndex()] || strategyCandidates[0]
|
||
);
|
||
}
|
||
var directions = designDirectionCandidates();
|
||
if (directions.length) {
|
||
var selected = selectedDesignDirectionIndex();
|
||
applyDesignDirection(directions[selected], selected, false);
|
||
}
|
||
}
|
||
|
||
function initProductionState() {
|
||
STATE.formula_policy = pick("formula_policy", CAT.formula_policy);
|
||
STATE.image_ai_path = pick("image_ai_path", CAT.image_ai_path);
|
||
STATE.proactive_speaker_notes = booleanRecommendation("proactive_speaker_notes", true);
|
||
STATE.proactive_custom_animations = booleanRecommendation("proactive_custom_animations", false);
|
||
STATE.proactive_narration_audio = booleanRecommendation("proactive_narration_audio", false);
|
||
|
||
STATE.generation_mode = pick("generation_mode", CAT.generation_mode);
|
||
STATE.refine_spec = !!((REC.refine_spec && REC.refine_spec.value) || (REC.recommend && REC.recommend.refine_spec));
|
||
}
|
||
|
||
function initState() {
|
||
initStage1State();
|
||
}
|
||
|
||
// ---- confirm + close -------------------------------------------------
|
||
function showConfirmedOverlay() {
|
||
var ov = document.getElementById("confirmed-overlay");
|
||
ov.querySelector(".cf-title").textContent = t("confirmed_title");
|
||
ov.querySelector(".cf-hint").textContent = t("confirmed_hint");
|
||
ov.style.display = "flex";
|
||
}
|
||
|
||
// ---- staged submit + next-stage transitions -------------------------
|
||
function communicationPayload() {
|
||
var payload = {
|
||
canvas: STATE.canvas,
|
||
audience: STATE.audience,
|
||
communication_intent: STATE.communication_intent,
|
||
audience_outcome: STATE.audience_outcome,
|
||
core_message: STATE.core_message,
|
||
delivery_context: STATE.delivery_context,
|
||
artifact_afterlife: STATE.artifact_afterlife,
|
||
content_divergence: STATE.content_divergence
|
||
};
|
||
if (STATE.primary_language && STATE.primary_language !== "und") {
|
||
payload.primary_language = STATE.primary_language;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function stage1Payload() {
|
||
var payload = communicationPayload();
|
||
payload.stage = "stage1";
|
||
payload.template_selection = {
|
||
mode: TEMPLATE_MODE,
|
||
selection_keys: TEMPLATE_MODE === "templates"
|
||
? TEMPLATE_SELECTED_KEYS.slice() : []
|
||
};
|
||
return payload;
|
||
}
|
||
|
||
function normalizeCreativePayload(payload) {
|
||
if (payload.mode !== "custom") delete payload.mode_behavior;
|
||
if (payload.visual_style !== "custom") delete payload.visual_style_behavior;
|
||
var imageStrategy = payload.image_strategy;
|
||
if (imageStrategy && imageStrategy.rendering === "custom") {
|
||
if (!imageStrategy.behavior && imageStrategy.custom) {
|
||
imageStrategy.behavior = imageStrategy.custom;
|
||
}
|
||
delete imageStrategy.custom;
|
||
} else if (imageStrategy) {
|
||
delete imageStrategy.behavior;
|
||
delete imageStrategy.custom;
|
||
}
|
||
delete payload.image_strategy_custom;
|
||
return payload;
|
||
}
|
||
|
||
function customSelectionsValid(payload) {
|
||
var imageStrategy = payload.image_strategy || {};
|
||
var valid = payload.mode !== "custom" || String(payload.mode_behavior || "").trim();
|
||
valid = valid && (payload.visual_style !== "custom" ||
|
||
String(payload.visual_style_behavior || "").trim());
|
||
valid = valid && (imageStrategy.rendering !== "custom" ||
|
||
String(imageStrategy.behavior || "").trim());
|
||
if (!valid) {
|
||
document.getElementById("confirm-status").textContent = t("custom_behavior_required");
|
||
}
|
||
return !!valid;
|
||
}
|
||
|
||
function imageStrategyValid(payload) {
|
||
if (!needsGeneratedImagesForUsage(payload.image_usage)) return true;
|
||
var imageStrategy = payload.image_strategy || {};
|
||
var rendering = String(imageStrategy.rendering || "").trim();
|
||
if (!rendering) {
|
||
document.getElementById("confirm-status").textContent = t("image_strategy_required");
|
||
return false;
|
||
}
|
||
var presetIds = imageStrategyCatalogCandidates().map(function (candidate) {
|
||
return candidate.rendering;
|
||
});
|
||
if (rendering !== "custom" && presetIds.length && presetIds.indexOf(rendering) < 0) {
|
||
document.getElementById("confirm-status").textContent = t("image_strategy_invalid");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function positiveNumber(value) {
|
||
var number = parseFloat(value);
|
||
return isFinite(number) && number > 0;
|
||
}
|
||
|
||
function designSystemValid(payload) {
|
||
var color = payload.color || {};
|
||
var palette = color.palette || {};
|
||
var completePalette = PALETTE_ROLES.every(function (role) {
|
||
return !!normHex(palette[role]);
|
||
});
|
||
var customPalette = color.name === "custom" && String(color.custom || "").trim();
|
||
|
||
var typography = payload.typography || {};
|
||
var completeFamilies = typographyFamiliesComplete(typography);
|
||
var sizes = typography.sizes || {};
|
||
var completeSizes = positiveNumber(typography.body_size) &&
|
||
["title", "subtitle", "annotation"].every(function (role) {
|
||
return positiveNumber(sizes[role]);
|
||
});
|
||
var valid = (completePalette || customPalette) &&
|
||
completeFamilies && completeSizes;
|
||
if (!valid) {
|
||
var message = color.name === "custom" && !customPalette
|
||
? t("custom_color_required")
|
||
: t("design_system_required");
|
||
document.getElementById("confirm-status").textContent = message;
|
||
}
|
||
return !!valid;
|
||
}
|
||
|
||
function submitStage(payload, nextStage) {
|
||
var btn = document.getElementById("btn-confirm");
|
||
btn.disabled = true;
|
||
fetch("/api/confirm", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
}).then(function (r) {
|
||
if (!r.ok) throw new Error("stage submit failed");
|
||
showDeriving();
|
||
pollForStage(nextStage);
|
||
}).catch(function () {
|
||
btn.disabled = false;
|
||
document.getElementById("confirm-status").textContent = t("error_retry");
|
||
});
|
||
}
|
||
|
||
function imageUsageValid(value) {
|
||
var ids = selectedImageUsageIds(value);
|
||
if (!ids.length) {
|
||
document.getElementById("confirm-status").textContent = t("image_usage_required");
|
||
return false;
|
||
}
|
||
if (ids.indexOf("none") >= 0 && ids.length > 1) {
|
||
document.getElementById("confirm-status").textContent = t("image_usage_none_exclusive");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function templateSelectionValid() {
|
||
var valid = TEMPLATE_MODE === "free_design" ||
|
||
(TEMPLATE_MODE === "templates" && TEMPLATE_SELECTED_KEYS.length > 0);
|
||
if (!valid) {
|
||
document.getElementById("confirm-status").textContent =
|
||
t("template_selection_required");
|
||
}
|
||
return valid;
|
||
}
|
||
|
||
function submitStage1() {
|
||
if (!templateSelectionValid()) return;
|
||
submitStage(stage1Payload(), 2);
|
||
}
|
||
|
||
function submitStage2() {
|
||
confirm();
|
||
}
|
||
|
||
function showDeriving() {
|
||
document.getElementById("sections").style.display = "none";
|
||
document.getElementById("actionbar").style.display = "none";
|
||
var l = document.getElementById("loading");
|
||
l.textContent = t("deriving");
|
||
l.style.display = "block";
|
||
}
|
||
|
||
// Poll session state first. It is derived from recommendation stage files
|
||
// and result.json, so a recovered server can tell the existing page exactly when
|
||
// the next stage is ready.
|
||
function pollForStage(nextStage) {
|
||
fetchJson("/api/session", "session")
|
||
.then(function (session) {
|
||
var readyStage = Number(session && session.recommendation_stage_number || 0);
|
||
if (readyStage < nextStage) {
|
||
setTimeout(function () { pollForStage(nextStage); }, 1200);
|
||
return null;
|
||
}
|
||
return fetchJson("/api/recommendations", "recommendations").then(function (data) {
|
||
var serverStage = stageNumber(data);
|
||
if (data && typeof serverStage === "number" && serverStage >= nextStage) {
|
||
enterStage(data, serverStage);
|
||
}
|
||
else { setTimeout(function () { pollForStage(nextStage); }, 1200); }
|
||
return null;
|
||
});
|
||
}).catch(function (err) {
|
||
var l = document.getElementById("loading");
|
||
if (l) l.textContent = t("connection_lost") + " " + (err && err.message ? err.message : "");
|
||
setTimeout(function () { pollForStage(nextStage); }, 1500);
|
||
});
|
||
}
|
||
|
||
function enterStage(data, stage) {
|
||
REC = data;
|
||
if (stage === 2) {
|
||
initStage2State();
|
||
initProductionState();
|
||
}
|
||
STAGE = stage;
|
||
document.getElementById("loading").style.display = "none";
|
||
document.getElementById("sections").style.display = "block";
|
||
document.getElementById("actionbar").style.display = "flex";
|
||
document.getElementById("confirm-status").textContent = "";
|
||
renderForStage(stage);
|
||
}
|
||
|
||
function confirm() {
|
||
var btn = document.getElementById("btn-confirm");
|
||
var payload = JSON.parse(JSON.stringify(STATE));
|
||
normalizeTypographyForSubmit(payload);
|
||
payload.stage = "final";
|
||
payload.image_usage = selectedImageUsageIds(payload.image_usage);
|
||
if (!imageUsageValid(payload.image_usage)) return;
|
||
if (!String(payload.image_notes || "").trim()) delete payload.image_notes;
|
||
if (!needsGeneratedImagesForUsage(payload.image_usage)) {
|
||
delete payload.image_ai_path;
|
||
delete payload.image_strategy;
|
||
} else {
|
||
payload.image_strategy = normalizedImageStrategy(payload.image_strategy);
|
||
}
|
||
normalizeCreativePayload(payload);
|
||
if (!imageStrategyValid(payload)) return;
|
||
if (!designSystemValid(payload)) return;
|
||
if (!customSelectionsValid(payload)) return;
|
||
btn.disabled = true;
|
||
fetch("/api/confirm", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
}).then(function (r) {
|
||
if (!r.ok) throw new Error("confirm failed");
|
||
showConfirmedOverlay();
|
||
fetch("/api/shutdown", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ reason: "confirmed" })
|
||
}).catch(function () { /* server already gone — fine */ });
|
||
setTimeout(function () { try { window.close(); } catch (e) { /* ignore */ } }, 400);
|
||
}).catch(function () {
|
||
btn.disabled = false;
|
||
document.getElementById("confirm-status").textContent = t("error_retry");
|
||
});
|
||
}
|
||
|
||
// ---- boot ------------------------------------------------------------
|
||
function showError(msg) {
|
||
document.getElementById("loading").style.display = "none";
|
||
var e = document.getElementById("error");
|
||
e.style.display = "block";
|
||
e.textContent = msg;
|
||
}
|
||
|
||
function fetchJson(url, label) {
|
||
return fetch(url, { cache: "no-store" }).then(function (r) {
|
||
return r.text().then(function (text) {
|
||
var data = null;
|
||
if (text) {
|
||
try { data = JSON.parse(text); }
|
||
catch (e) {
|
||
if (r.ok) throw new Error((label || url) + ": invalid JSON");
|
||
}
|
||
}
|
||
if (!r.ok) {
|
||
var serverMsg = data && data.error ? data.error : (text || r.statusText || r.status);
|
||
throw new Error((label || url) + ": " + serverMsg);
|
||
}
|
||
return data || {};
|
||
});
|
||
});
|
||
}
|
||
|
||
function loadCatalogs() {
|
||
return fetchJson("/api/catalogs", "catalogs")
|
||
.catch(function () { return fetchJson("/static/catalogs.json", "static catalogs"); });
|
||
}
|
||
|
||
function loadIconPreviews() {
|
||
return fetchJson("/api/icon-previews", "icon previews")
|
||
.catch(function () { return {}; });
|
||
}
|
||
|
||
function loadAiImageComparison() {
|
||
return fetchJson("/api/ai-image-comparison", "AI image comparison")
|
||
.catch(function () { return {}; });
|
||
}
|
||
|
||
function applyServerLanguage(data) {
|
||
var requested = data && data.lang;
|
||
if (requested !== "zh" && requested !== "en" && requested !== "ja" &&
|
||
requested !== "zh-TW") return;
|
||
var hasStored = false;
|
||
try { hasStored = !!window.localStorage.getItem("ppt_lang"); } catch (e) { /* ignore */ }
|
||
if (hasStored) return;
|
||
LANG = requested;
|
||
applyStaticTranslations();
|
||
var toggleBtn = document.getElementById("btn-lang-toggle");
|
||
if (toggleBtn) refreshLangToggle(toggleBtn);
|
||
}
|
||
|
||
function loadStrategistUi(forceStage) {
|
||
return Promise.all([
|
||
loadCatalogs(),
|
||
fetchJson("/api/recommendations", "recommendations"),
|
||
loadIconPreviews(),
|
||
loadAiImageComparison()
|
||
]).then(function (res) {
|
||
CAT = res[0];
|
||
REC = res[1];
|
||
ICON_PREVIEWS = res[2] || {};
|
||
AI_IMAGE_COMPARISON = res[3] || {};
|
||
applyServerLanguage(REC);
|
||
var activeStage = forceStage || stageNumber(REC);
|
||
if (activeStage === 1) {
|
||
if (!REC.template_options || typeof REC.template_options !== "object" ||
|
||
Array.isArray(REC.template_options)) {
|
||
throw new Error("Stage 1 recommendations must include template_options");
|
||
}
|
||
initTemplateOptions(REC.template_options);
|
||
}
|
||
initState();
|
||
enterStage(REC, activeStage);
|
||
return REC;
|
||
});
|
||
}
|
||
|
||
function boot() {
|
||
applyStaticTranslations();
|
||
var toggleBtn = document.getElementById("btn-lang-toggle");
|
||
var langMenu = document.getElementById("lang-menu");
|
||
refreshLangToggle(toggleBtn);
|
||
var setMenuOpen = function (open) {
|
||
langMenu.hidden = !open;
|
||
toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
||
if (open) {
|
||
var sel = langMenu.querySelector("li.selected") || langMenu.querySelector("li[data-lang]");
|
||
if (sel) sel.focus();
|
||
}
|
||
};
|
||
var chooseLang = function (v) {
|
||
setMenuOpen(false);
|
||
toggleBtn.focus();
|
||
if (v !== "ja" && v !== "en" && v !== "zh" && v !== "zh-TW") return;
|
||
if (v === LANG) return;
|
||
LANG = v;
|
||
try { window.localStorage.setItem("ppt_lang", LANG); } catch (e2) { /* ignore */ }
|
||
applyStaticTranslations();
|
||
refreshLangToggle(toggleBtn);
|
||
if (REC && CAT) {
|
||
renderAll(); // STATE persists → selections preserved
|
||
}
|
||
};
|
||
toggleBtn.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
setMenuOpen(langMenu.hidden);
|
||
});
|
||
toggleBtn.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape" && !langMenu.hidden) {
|
||
e.stopPropagation();
|
||
setMenuOpen(false);
|
||
} else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && langMenu.hidden) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setMenuOpen(true);
|
||
}
|
||
});
|
||
langMenu.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
var li = e.target && e.target.closest ? e.target.closest("li[data-lang]") : null;
|
||
if (li) chooseLang(li.getAttribute("data-lang"));
|
||
else setMenuOpen(false);
|
||
});
|
||
langMenu.addEventListener("keydown", function (e) {
|
||
e.stopPropagation(); // page-level shortcuts must not fire while the menu is open
|
||
var items = Array.prototype.slice.call(langMenu.querySelectorAll("li[data-lang]"));
|
||
var idx = items.indexOf(document.activeElement);
|
||
if (e.key === "Escape") {
|
||
setMenuOpen(false);
|
||
toggleBtn.focus();
|
||
} else if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
(items[idx + 1] || items[0]).focus();
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
(items[idx - 1] || items[items.length - 1]).focus();
|
||
} else if (e.key === "Home") {
|
||
e.preventDefault();
|
||
items[0].focus();
|
||
} else if (e.key === "End") {
|
||
e.preventDefault();
|
||
items[items.length - 1].focus();
|
||
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||
e.preventDefault();
|
||
} else if (e.key === "Enter" || e.key === " ") {
|
||
e.preventDefault();
|
||
if (idx >= 0) chooseLang(items[idx].getAttribute("data-lang"));
|
||
}
|
||
});
|
||
toggleBtn.parentElement.addEventListener("focusout", function (e) {
|
||
if (!langMenu.hidden && !toggleBtn.parentElement.contains(e.relatedTarget)) setMenuOpen(false);
|
||
});
|
||
document.addEventListener("click", function () {
|
||
if (!langMenu.hidden) setMenuOpen(false);
|
||
});
|
||
document.getElementById("btn-confirm").addEventListener("click", function () {
|
||
if (STAGE === 1) submitStage1();
|
||
else if (STAGE === 2) submitStage2();
|
||
else confirm();
|
||
});
|
||
|
||
// Session remains the first network read so completed runs can close
|
||
// cleanly. Active runs always load the current Strategist stage; Stage 1
|
||
// carries its template catalog inside the recommendation payload.
|
||
fetchJson("/api/session", "session").catch(function () {
|
||
return { phase: "strategist" };
|
||
}).then(function (session) {
|
||
if (session && session.status === "done") {
|
||
document.getElementById("loading").style.display = "none";
|
||
showConfirmedOverlay();
|
||
return null;
|
||
}
|
||
return loadStrategistUi().catch(function (err) {
|
||
showError(t("load_error") + " " + (err && err.message ? err.message : ""));
|
||
return null;
|
||
});
|
||
});
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|