collavre 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -5,12 +5,11 @@ module Tools
5
5
  class CreativeCreateService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
- include DescriptionNormalizable
9
8
 
10
9
  tool_name "creative_create_service"
11
- tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field accepts HTML format for rich text content."
10
+ tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field is written as Markdown."
12
11
 
13
- tool_param :description, description: "The content/title of the Creative. Accepts HTML format (e.g., '<p>Task title</p>'). Plain text will be wrapped in <p> tags automatically.", required: true
12
+ tool_param :description, description: "The content/title of the Creative, written in Markdown (GitHub-Flavored: headings, bold/italic, lists, links, tables, code blocks, task lists). A single newline is a line break. Plain text is stored as-is. Example: '# Title\\n\\n- item one\\n- item two'.", required: true
14
13
  tool_param :parent_id, description: "ID of the parent Creative. Required to create under a specific parent. If omitted, creates a root Creative.", required: false
15
14
  tool_param :progress, description: "Initial progress value (0.0 to 1.0). Default is 0.", required: false
16
15
  tool_param :after_id, description: "ID of a sibling Creative to insert after. Used for ordering.", required: false
@@ -32,15 +31,16 @@ module Tools
32
31
  end
33
32
  end
34
33
 
35
- # Normalize description - wrap plain text in <p> tags if needed
36
- normalized_description = normalize_description(description)
37
-
38
- # Build the creative
34
+ # Build the creative. The description is authored as Markdown: store it as
35
+ # the canonical markdown_source and let Describable#convert_markdown_to_html
36
+ # render it to the HTML description (markdown_editor defaults to the
37
+ # advanced "source" surface for tool/MCP writes).
39
38
  creative = Creative.new(
40
- description: normalized_description,
41
39
  parent: parent,
42
40
  progress: progress || 0
43
41
  )
42
+ creative.content_type_input = "markdown"
43
+ creative.markdown_source = description
44
44
 
45
45
  # Set user based on parent or current user
46
46
  creative.user = parent ? parent.user : Current.user
@@ -5,13 +5,12 @@ module Tools
5
5
  class CreativeUpdateService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
- include DescriptionNormalizable
9
8
 
10
9
  tool_name "creative_update_service"
11
10
  tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Mark a leaf Creative as complete (progress = 1.0)\n- Move a Creative to a different parent\n\nProgress constraints:\n- Only 1.0 (100%) is allowed — partial progress updates are not supported\n- Only leaf Creatives (with no children) can have their progress updated\n- Parent Creative progress is automatically calculated from children\n\nUse creative_retrieval_service to find the correct Creative before updating."
12
11
 
13
12
  tool_param :id, description: "The ID of the Creative to update.", required: true
14
- tool_param :description, description: "New content/title for the Creative. Accepts HTML format. If omitted, description remains unchanged.", required: false
13
+ tool_param :description, description: "New content/title for the Creative, written in Markdown (GitHub-Flavored: headings, bold/italic, lists, links, tables, code blocks, task lists). A single newline is a line break. Replaces the whole body. If omitted, the description remains unchanged.", required: false
15
14
  tool_param :progress, description: "Set to 1.0 to mark a leaf Creative as complete. Only 1.0 is allowed; partial progress and updates on parent Creatives are rejected.", required: false
16
15
  tool_param :parent_id, description: "New parent Creative ID to move this Creative under. If omitted, nil, or 0, the parent remains unchanged.", required: false
17
16
 
@@ -34,9 +33,22 @@ module Tools
34
33
  updates = {}
35
34
  parent_updates = {}
36
35
 
37
- # Handle description update
38
- if description.present?
39
- updates[:description] = normalize_description(description)
36
+ # Handle description update. The description is authored as Markdown: set it
37
+ # as the canonical markdown_source on the base/origin and let
38
+ # Describable#convert_markdown_to_html render the HTML description on save.
39
+ #
40
+ # Force the "source" editing surface (like create does) rather than letting
41
+ # convert_markdown_to_html preserve a prior "rich" preference: this update
42
+ # replaces the whole body with fresh tool/MCP-authored GFM, so the old
43
+ # rich-authored content the preference belonged to is gone. Tool writes can
44
+ # contain tables/raw HTML the Lexical nodes don't fully model; reopening in
45
+ # the source textarea preserves that structure, whereas a rich-editor
46
+ # round-trip could silently rewrite it.
47
+ description_provided = description.present?
48
+ if description_provided
49
+ base.content_type_input = "markdown"
50
+ base.markdown_source = description
51
+ base.markdown_editor = "source"
40
52
  end
41
53
 
42
54
  # Handle progress update
@@ -89,9 +101,12 @@ module Tools
89
101
  success &&= creative.update(parent_updates)
90
102
  end
91
103
 
92
- # Update content on the base/origin
93
- if updates.present?
94
- success &&= base.update(updates)
104
+ # Update content on the base/origin. markdown_source is set on `base`
105
+ # directly (above), so save it whenever a description was provided even if
106
+ # `updates` (progress) is empty.
107
+ if updates.present? || description_provided
108
+ base.assign_attributes(updates) if updates.present?
109
+ success &&= base.save
95
110
 
96
111
  # Note: progress updates are only allowed on leaf Creatives (validated above).
97
112
  # Parent progress is automatically calculated from children.
@@ -113,15 +113,13 @@ module Tools
113
113
  raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
114
114
  end
115
115
 
116
- # config is JSON, so worktree_id matching is a Ruby-side scan over the
117
- # topic's preview channels. With only a handful of previews per topic in
118
- # practice this is fine and avoids JSON operator divergence between
119
- # Postgres (config->>'worktree_id') and SQLite (json_extract).
116
+ # worktree_id is a real column denormalized from config (see
117
+ # Collavre::Channel#sync_indexed_config_columns), so the lookup is an
118
+ # indexed query it also backs the (topic_id, worktree_id) unique index
119
+ # that makes attach idempotent.
120
120
  sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
121
121
  def lookup_channel(topic, worktree_id)
122
- Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
123
- c.worktree_id.to_s == worktree_id.to_s
124
- end
122
+ Collavre::PreviewChannel.find_by(topic_id: topic.id, worktree_id: worktree_id)
125
123
  end
126
124
  end
127
125
  end
@@ -0,0 +1,188 @@
1
+ module Collavre
2
+ # Server-side typo correction. Sends the text the user is typing to an LLM and
3
+ # returns a validated *structured edit list* — never a rewritten string. Each
4
+ # edit is {original, suggestion, reason, confidence}. Validation rejects anything
5
+ # that is not a small, in-place spelling fix (style rewrites, hallucinated spans,
6
+ # edits inside code/URLs/mentions), so the UI can trust every returned edit.
7
+ class TypoCorrector
8
+ AGENT_EMAIL = "typo-corrector@collavre.local"
9
+
10
+ # Reject "corrections" that are really rewrites: an edit is kept only when the
11
+ # original is short and the suggestion is within this edit distance of it.
12
+ MAX_ORIGINAL_LENGTH = 40
13
+ MIN_EDIT_DISTANCE_CAP = 2
14
+ EDIT_DISTANCE_RATIO = 0.4
15
+
16
+ FALLBACK_SYSTEM_PROMPT = <<~PROMPT.freeze
17
+ You are a typo correction engine. Fix only spelling mistakes and obvious typos,
18
+ never style, grammar or meaning, in any language. `original` must be an exact
19
+ substring of the input. Return ONLY JSON:
20
+ {"edits":[{"original":"x","suggestion":"y","reason":"spelling","confidence":0.0}]}
21
+ PROMPT
22
+
23
+ def initialize(client: nil)
24
+ @client = client
25
+ end
26
+
27
+ # Returns an Array of Hashes with string keys: "original", "suggestion",
28
+ # "reason", "confidence". Empty array on blank input or LLM/parse failure.
29
+ def correct(text)
30
+ text = text.to_s
31
+ return [] if text.strip.blank?
32
+
33
+ response = client.chat([
34
+ { role: :user, parts: [ { text: "Text:\n#{text}" } ] }
35
+ ])
36
+
37
+ edits = parse_response(response)
38
+ validate(edits, text)
39
+ end
40
+
41
+ private
42
+
43
+ def client
44
+ @client ||= build_client
45
+ end
46
+
47
+ def build_client
48
+ agent = Collavre.user_class.find_by(email: AGENT_EMAIL)
49
+ AiClient.new(
50
+ vendor: agent&.llm_vendor.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_VENDOR", "gemini"),
51
+ model: agent&.llm_model.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite"),
52
+ system_prompt: agent&.system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
53
+ llm_api_key: agent&.llm_api_key,
54
+ gateway_url: agent&.gateway_url,
55
+ # Vendor adapters (e.g. OpenClaw) resolve the gateway/key from the context
56
+ # user, not the llm_api_key/gateway_url kwargs. Pass the agent so an
57
+ # OpenClaw-backed typo agent isn't built with user: nil (which makes the
58
+ # adapter bail with "Gateway URL not configured" and silently return no edits).
59
+ context: { user: agent },
60
+ # Runs on debounced typing of *unsubmitted* text — never persist the draft
61
+ # to ActivityLog (RubyLlmInteractionLogger writes `messages` there).
62
+ log_interactions: false
63
+ )
64
+ end
65
+
66
+ def parse_response(content)
67
+ return [] if content.blank?
68
+
69
+ cleaned = content.gsub(/^```json\s*/, "").gsub(/\s*```$/, "").strip
70
+ parsed = JSON.parse(cleaned)
71
+ edits = parsed.is_a?(Hash) ? parsed["edits"] : parsed
72
+ edits.is_a?(Array) ? edits : []
73
+ rescue JSON::ParserError => e
74
+ # Never log the raw response: it can echo the user's unsubmitted draft,
75
+ # which would leak private text to application logs despite log_interactions: false.
76
+ Rails.logger.warn("[TypoCorrector] JSON parse error: #{e.message} (#{content.to_s.length} chars)")
77
+ []
78
+ end
79
+
80
+ def validate(edits, text)
81
+ skip_ranges = skip_ranges(text)
82
+ seen = {}
83
+
84
+ edits.filter_map do |edit|
85
+ next unless edit.is_a?(Hash)
86
+
87
+ original = edit["original"].to_s
88
+ suggestion = edit["suggestion"].to_s
89
+ next if original.empty? || suggestion.empty? || original == suggestion
90
+ next if original.length > MAX_ORIGINAL_LENGTH
91
+
92
+ # `original` must appear verbatim in the text, outside any skip region
93
+ # (code/URL/mention/markup). We return the offset of that first *valid*
94
+ # occurrence so the client anchors there — otherwise the client's own
95
+ # left-to-right search would bind to an earlier occurrence sitting inside
96
+ # a protected span and edit it (a markdown-canonical/code-safety hole).
97
+ offset = first_offset_outside_skip_ranges(text, original, skip_ranges)
98
+ next if offset.nil?
99
+
100
+ # Reject big rewrites — keep only minimal in-place fixes.
101
+ cap = [ MIN_EDIT_DISTANCE_CAP, (original.length * EDIT_DISTANCE_RATIO).ceil ].max
102
+ next if levenshtein(original, suggestion) > cap
103
+
104
+ key = [ original, suggestion ]
105
+ next if seen[key]
106
+ seen[key] = true
107
+
108
+ {
109
+ "original" => original,
110
+ "suggestion" => suggestion,
111
+ "reason" => edit["reason"].presence || "spelling",
112
+ "confidence" => clamp_confidence(edit["confidence"]),
113
+ # Emit the offset in UTF-16 code units, the coordinate space JS string
114
+ # indexing uses. A Ruby character index would be off by one per astral
115
+ # character (emoji, etc.) before the typo, so the client's substring
116
+ # check at `offset` would fail and fall back to indexOf — which can bind
117
+ # to a protected occurrence the server already excluded.
118
+ "offset" => utf16_offset(text, offset)
119
+ }
120
+ end
121
+ end
122
+
123
+ def clamp_confidence(value)
124
+ return 0.5 if value.nil?
125
+
126
+ Float(value).clamp(0.0, 1.0)
127
+ rescue ArgumentError, TypeError
128
+ 0.5
129
+ end
130
+
131
+ # Character ranges that must never be edited: fenced code, inline code, URLs,
132
+ # @mentions, HTML tags, and markdown-canonical <span style> color fragments.
133
+ def skip_ranges(text)
134
+ ranges = []
135
+ patterns = [
136
+ /```.*?```/m, # fenced code blocks
137
+ /`[^`]*`/, # inline code
138
+ %r{https?://\S+}, # URLs
139
+ /@[^\s@]+/, # @mentions
140
+ # `[^<>]` (not just `[^>]`) keeps this linear: a stray run of "<<<<" can't
141
+ # make scan re-walk the tail from every "<" (CodeQL polynomial-ReDoS).
142
+ /<[^<>]+>/ # HTML / span-style markup
143
+ ]
144
+ patterns.each do |pattern|
145
+ text.scan(pattern) { ranges << Regexp.last_match.offset(0) }
146
+ end
147
+ ranges
148
+ end
149
+
150
+ # First character offset where `needle` occurs entirely outside every skip
151
+ # range, or nil if every occurrence overlaps a protected span.
152
+ def first_offset_outside_skip_ranges(text, needle, skip_ranges)
153
+ pos = 0
154
+ while (idx = text.index(needle, pos))
155
+ finish = idx + needle.length
156
+ inside = skip_ranges.any? { |(s, e)| idx < e && finish > s }
157
+ return idx unless inside
158
+
159
+ pos = idx + 1
160
+ end
161
+ nil
162
+ end
163
+
164
+ # Convert a Ruby character index into a UTF-16 code-unit offset (JS string
165
+ # coordinates). Characters outside the BMP count as two code units in JS.
166
+ def utf16_offset(text, char_index)
167
+ text[0, char_index].to_s.encode("UTF-16LE").bytesize / 2
168
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
169
+ char_index
170
+ end
171
+
172
+ # Iterative Levenshtein distance.
173
+ def levenshtein(a, b)
174
+ a = a.chars
175
+ b = b.chars
176
+ prev = (0..b.length).to_a
177
+ a.each_with_index do |ac, i|
178
+ curr = [ i + 1 ]
179
+ b.each_with_index do |bc, j|
180
+ cost = ac == bc ? 0 : 1
181
+ curr << [ curr[j] + 1, prev[j + 1] + 1, prev[j] + cost ].min
182
+ end
183
+ prev = curr
184
+ end
185
+ prev.last
186
+ end
187
+ end
188
+ end
@@ -1,6 +1,6 @@
1
1
  <% comment_topic = comment.topic %>
2
2
  <% current_topic_id = local_assigns[:current_topic_id] %>
3
- <% has_pending_action = comment.action.present? && comment.action_executed_at.blank? %>
3
+ <% has_pending_action = comment.approval_action? && comment.action_executed_at.blank? %>
4
4
  <% approver_id = comment.approver_id %>
5
5
  <div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>" data-ai-user="<%= comment.user&.ai_user? %>" data-streaming="<%= local_assigns.fetch(:streaming, false) %>">
6
6
  <div class="comment-select">
@@ -49,6 +49,11 @@
49
49
  <button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= comment.task_id %>" title="<%= t('collavre.comments.stop_agent') %>">
50
50
  <span class="agent-stop-icon">&#x25A0;</span> <%= t('collavre.comments.stop_agent') %>
51
51
  </button>
52
+ <% elsif comment.waiting_notice? && (blocking_task = comment.topic_blocking_task) %>
53
+ <%# Stop the in-progress blocker so the deferred waiter can proceed (stuck recovery). %>
54
+ <button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= blocking_task.id %>" title="<%= t('collavre.comments.stop_blocking_agent') %>">
55
+ <span class="agent-stop-icon">&#x25A0;</span> <%= t('collavre.comments.stop_blocking_agent') %>
56
+ </button>
52
57
  <% end %>
53
58
  <button class="add-reaction-btn" type="button" data-action="click->comment#triggerReactionPicker" title="<%= t('collavre.comments.add_reaction') %>">
54
59
  <span class="grayscale-emoji">☺</span>
@@ -85,8 +90,8 @@
85
90
  </div>
86
91
  <% end %>
87
92
  <div class="comment-content" data-comment-target="content"><%= comment.content %></div>
88
- <% if comment.comment_versions.any? %>
89
- <% version_count = comment.comment_versions.size %>
93
+ <% if comment.comment_versions_count > 0 %>
94
+ <% version_count = comment.comment_versions_count %>
90
95
  <% selected_id = comment.selected_version_id %>
91
96
  <% version_ids = comment.comment_versions.order(:version_number).pluck(:id) %>
92
97
  <% selected_index = selected_id ? (version_ids.index(selected_id)&.+(1) || version_count) : version_count %>
@@ -168,7 +173,7 @@
168
173
  <a href="#" class="comment-topic-switch" data-topic-id="<%= comment_topic.id %>">#<%= comment_topic.name %></a>
169
174
  </div>
170
175
  <% end %>
171
- <% if comment.action.present? %>
176
+ <% if comment.approval_action? %>
172
177
  <div class="comment-action-block" data-comment-id="<%= comment.id %>">
173
178
  <details class="comment-action-details">
174
179
  <summary class="comment-action-summary"><%= t("collavre.comments.action_summary") %></summary>
@@ -61,7 +61,20 @@
61
61
  data-remove-from-history-label="<%= t('collavre.comments.remove_from_history') %>"
62
62
  data-inbox-reply-button="<%= t('collavre.comments.inbox_reply_button') %>"
63
63
  data-table-download-csv-text="<%= t('collavre.comments.table_download.csv') %>"
64
- data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>">
64
+ data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>"
65
+ <% if Current.user %>
66
+ data-typo-enabled="<%= Current.user.typo_correction_enabled %>"
67
+ data-typo-threshold="<%= Current.user.typo_correction_threshold %>"
68
+ data-typo-on-voice="<%= Current.user.typo_correction_on_voice %>"
69
+ data-typo-on-soft-keyboard="<%= Current.user.typo_correction_on_soft_keyboard %>"
70
+ data-typo-on-physical-keyboard="<%= Current.user.typo_correction_on_physical_keyboard %>"
71
+ data-typo-in-chat="<%= Current.user.typo_correction_in_chat %>"
72
+ data-typo-in-editor="<%= Current.user.typo_correction_in_editor %>"
73
+ data-typo-endpoint="<%= collavre.typo_corrections_path %>"
74
+ data-typo-keep-label="<%= t('collavre.comments.typo_keep_label') %>"
75
+ data-typo-custom-label="<%= t('collavre.comments.typo_custom_label') %>"
76
+ data-typo-input-label="<%= t('collavre.comments.typo_input_label') %>"
77
+ <% end %>>
65
78
  <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
66
79
  <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
67
80
  <div class="comments-popup-header" data-comments--popup-target="header">
@@ -112,9 +125,17 @@
112
125
  data-action="dragover->comments--contexts#handleExternalDragOver drop->comments--contexts#handleExternalDrop dragleave->comments--contexts#handleExternalDragLeave"></div>
113
126
  <div data-share-modal-target="container"></div>
114
127
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
115
- <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
116
- data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
117
- data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
128
+ <div class="comment-topics-bar">
129
+ <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
130
+ data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
131
+ data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
132
+ <button type="button" class="topic-list-btn"
133
+ data-action="click->comments--topics#openTopicListPopup"
134
+ title="<%= t('collavre.comments.topic_list') %>"
135
+ aria-label="<%= t('collavre.comments.topic_list') %>">
136
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
137
+ </button>
138
+ </div>
118
139
  <%= render Collavre::UserMentionMenuComponent.new(menu_id: 'mention-menu') %>
119
140
  <%= render Collavre::CommandMenuComponent.new(menu_id: 'command-menu') %>
120
141
  <div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
@@ -4,6 +4,7 @@
4
4
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
5
5
  <input type="hidden" id="inline-creative-description" name="creative[description]" />
6
6
  <input type="hidden" id="inline-content-type" name="creative[content_type_input]" value="html" />
7
+ <input type="hidden" id="inline-markdown-editor" name="creative[markdown_editor]" />
7
8
  <input type="hidden" id="inline-markdown-source" name="creative[markdown_source]" />
8
9
  <input type="hidden" id="inline-parent-id" name="creative[parent_id]" />
9
10
  <input type="hidden" id="inline-before-id" name="before_id" />
@@ -49,6 +50,15 @@
49
50
  style="margin-left:0.5em;font-family:monospace;font-size:0.9em;">
50
51
  <%= t('collavre.creatives.index.toggle_markdown') %>
51
52
  </button>
53
+ <span id="inline-save-status"
54
+ class="inline-save-status"
55
+ role="status"
56
+ aria-live="polite"
57
+ data-label-pending="<%= t('collavre.creatives.index.save_status_pending') %>"
58
+ data-label-saving="<%= t('collavre.creatives.index.save_status_saving') %>"
59
+ data-label-saved="<%= t('collavre.creatives.index.save_status_saved') %>"
60
+ data-label-error="<%= t('collavre.creatives.index.save_status_error') %>"
61
+ style="margin-left:auto;font-size:0.85em;color:var(--text-secondary);"></span>
52
62
  </div>
53
63
  <div style="margin-top:0.5em;">
54
64
  <button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
@@ -0,0 +1,42 @@
1
+ <%#
2
+ Popup shown after a topic is moved to another creative, offering to re-add
3
+ members who had access on the source creative but not on the target.
4
+ Rendered hidden; populated and shown by topic_move_members_popup.js using the
5
+ `missing_members` payload returned from TopicsController#move.
6
+ %>
7
+ <div id="topic-move-members-modal"
8
+ style="display: none; position: fixed; inset: 0; z-index: 10001; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3);"
9
+ data-shares-url-template="<%= collavre.creative_creative_shares_path('__CREATIVE_ID__') %>"
10
+ data-adding-text="<%= t('collavre.topics.move.add_members.adding') %>"
11
+ data-added-one="<%= t('collavre.topics.move.add_members.added.one') %>"
12
+ data-added-other="<%= t('collavre.topics.move.add_members.added.other', count: '__COUNT__') %>"
13
+ data-partial-text="<%= t('collavre.topics.move.add_members.partial', added: '__ADDED__', failed: '__FAILED__') %>"
14
+ data-failed-text="<%= t('collavre.topics.move.add_members.failed') %>"
15
+ data-description-template="<%= t('collavre.topics.move.add_members.description', creative: '__CREATIVE_NAME__') %>">
16
+ <div class="popup-box" style="max-width: 460px; width: 90%;">
17
+ <button type="button" id="topic-move-members-close" class="popup-close" aria-label="Close" style="position: absolute; top: 12px; right: 12px;">&times;</button>
18
+ <h2><%= t('collavre.topics.move.add_members.title') %></h2>
19
+ <p id="topic-move-members-description" style="opacity: 0.75; margin: 0.5em 0 1em;"></p>
20
+ <div id="topic-move-members-message" aria-live="polite"></div>
21
+ <ul id="topic-move-members-list" class="share-grid"></ul>
22
+ <div style="display: flex; gap: 0.5em; justify-content: flex-end; margin-top: 1em;">
23
+ <button type="button" id="topic-move-members-skip" class="btn btn-secondary"><%= t('collavre.topics.move.add_members.skip') %></button>
24
+ <button type="button" id="topic-move-members-add" class="btn btn-primary"><%= t('collavre.topics.move.add_members.add') %></button>
25
+ </div>
26
+ </div>
27
+
28
+ <template id="topic-move-member-row">
29
+ <li class="topic-move-member-row" style="display: flex; align-items: center; gap: 0.5em;">
30
+ <input type="checkbox" class="tmm-checkbox" checked>
31
+ <span class="avatar share-avatar tmm-avatar" style="display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: var(--surface-3, #ddd); text-align: center; line-height: 20px; font-size: 10px; overflow: hidden;"></span>
32
+ <span class="tmm-name"></span>
33
+ <span class="tmm-email" style="opacity: 0.5; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
34
+ <select class="org-chart-permission-select share-modal-permission-select tmm-permission">
35
+ <% %w[admin write feedback read].each do |perm| %>
36
+ <option value="<%= perm %>"><%= t("collavre.creatives.index.permission_#{perm}") %></option>
37
+ <% end %>
38
+ </select>
39
+ <span class="tmm-status" style="width: 16px; text-align: center;"></span>
40
+ </li>
41
+ </template>
42
+ </div>
@@ -104,6 +104,17 @@
104
104
  <div data-share-modal-target="container"></div>
105
105
  <% end %>
106
106
 
107
+ <%# The "re-add dropped members" modal must be present wherever a topic can be
108
+ dropped onto another creative — including the top-level index, where
109
+ @parent_creative/@creative are nil (moving a topic between root-level
110
+ creatives). Gating it on the write check above hid it exactly there, so
111
+ getModal() returned null and the popup silently never appeared. It stays
112
+ hidden until populated, and share creation is permission-checked
113
+ server-side, so rendering it for any authenticated user is safe. %>
114
+ <% if authenticated? %>
115
+ <%= render 'topic_move_members_modal' %>
116
+ <% end %>
117
+
107
118
  <%= render 'import_upload_zone' %>
108
119
 
109
120
  <% if can_manage_integrations %>
@@ -159,6 +170,7 @@
159
170
  title_effective_origin = @parent_creative.effective_origin(Set.new)
160
171
  title_content_type = title_effective_origin.data&.dig("content_type")
161
172
  title_markdown_source = title_can_write ? title_effective_origin.data&.dig("markdown_source") : nil
173
+ title_markdown_editor = title_effective_origin.data&.dig("editor")
162
174
  %>
163
175
  <%= content_tag(
164
176
  "creative-tree-row",
@@ -172,7 +184,8 @@
172
184
  "data-progress-value": @parent_creative.progress,
173
185
  "data-origin-id": @parent_creative.origin_id,
174
186
  "data-content-type": title_content_type,
175
- "data-markdown-source": title_markdown_source
187
+ "data-markdown-source": title_markdown_source,
188
+ "data-markdown-editor": title_markdown_editor
176
189
  ) %>
177
190
  <% else %>
178
191
  <%= content_tag(
@@ -8,7 +8,7 @@
8
8
  <% initial_index = params[:slide].to_i.clamp(0, @slide_ids.length - 1) %>
9
9
  <% initial_prompt = @creative.prompt_for(Current.user) %>
10
10
  <div id="slide-view" data-slide-ids="<%= @slide_ids.join(',') %>" data-initial-index="<%= initial_index %>" data-root-id="<%= @creative.id %>">
11
- <div id="slide-content"><%= content_tag(tag_name, sanitize(@creative.effective_description, tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + %w[table thead tbody tfoot tr th td], attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + %w[colspan rowspan data-lexical])) %></div>
11
+ <div id="slide-content"><%= content_tag(tag_name, embed_youtube_iframe(@creative.effective_description), class: 'creative-content') %></div>
12
12
  </div>
13
13
  <div id="slide-controls">
14
14
  <div id="slide-counter"><%= initial_index + 1 %> / <%= @slide_ids.length %></div>
@@ -19,6 +19,7 @@
19
19
  <section id="problem" class="landing-section landing-problem">
20
20
  <div class="landing-container">
21
21
  <h2 class="landing-section-title"><%= t('collavre.landing.problem.title') %></h2>
22
+ <p class="landing-section-sub"><%= t('collavre.landing.problem.subtitle') %></p>
22
23
  <div class="landing-problem-grid">
23
24
  <div class="landing-problem-card">
24
25
  <span class="landing-problem-icon">📝</span>
@@ -52,40 +53,77 @@
52
53
  <p><%= t('collavre.landing.features.tree.desc') %></p>
53
54
  </div>
54
55
 
55
- <div class="landing-feature-card">
56
- <div class="landing-feature-icon">🤖</div>
57
- <h3><%= t('collavre.landing.features.ai.title') %></h3>
58
- <p><%= t('collavre.landing.features.ai.desc') %></p>
59
- </div>
60
-
61
56
  <div class="landing-feature-card">
62
57
  <div class="landing-feature-icon">💡</div>
63
58
  <h3><%= t('collavre.landing.features.context.title') %></h3>
64
59
  <p><%= t('collavre.landing.features.context.desc') %></p>
65
60
  </div>
66
61
 
62
+ <div class="landing-feature-card">
63
+ <div class="landing-feature-icon">🤖</div>
64
+ <h3><%= t('collavre.landing.features.ai.title') %></h3>
65
+ <p><%= t('collavre.landing.features.ai.desc') %></p>
66
+ </div>
67
+
67
68
  <div class="landing-feature-card">
68
69
  <div class="landing-feature-icon">💬</div>
69
70
  <h3><%= t('collavre.landing.features.chat.title') %></h3>
70
71
  <p><%= t('collavre.landing.features.chat.desc') %></p>
71
72
  </div>
72
73
 
74
+ </div>
75
+ </div>
76
+ </section>
73
77
 
78
+ <%# ═════════════════════════════════════ THE INTERFACE ══════════════════════════════════════ %>
79
+ <%# A still before the film: the reader decides in two seconds whether this is a surface they
80
+ would write in, and a video they have not pressed play on cannot answer that. %>
81
+ <section id="interface" class="landing-section landing-interface">
82
+ <div class="landing-container">
83
+ <h2 class="landing-section-title"><%= t('collavre.landing.interface.title') %></h2>
84
+ <p class="landing-section-sub"><%= t('collavre.landing.interface.subtitle') %></p>
85
+
86
+ <div class="landing-shot-frame landing-shot-light">
87
+ <%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}.png",
88
+ class: "landing-shot-img",
89
+ alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
74
90
  </div>
91
+ <div class="landing-shot-frame landing-shot-dark">
92
+ <%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}-dark.png",
93
+ class: "landing-shot-img",
94
+ alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
95
+ </div>
96
+
97
+ <ul class="landing-interface-points">
98
+ <li><%= t('collavre.landing.interface.markdown') %></li>
99
+ <li><%= t('collavre.landing.interface.rich') %></li>
100
+ <li><%= t('collavre.landing.interface.drag') %></li>
101
+ <li><%= t('collavre.landing.interface.presence') %></li>
102
+ </ul>
75
103
  </div>
76
104
  </section>
77
105
 
78
106
  <%# ══════════════════════════════════════════ DEMO ══════════════════════════════════════════ %>
107
+ <%# Demo video/poster URLs are full absolute URLs stored per-locale in
108
+ config/locales/landing.*.yml. They point at the hosted assets directly, so
109
+ they resolve from any environment (preview included) with no DB lookup and
110
+ no env-bound blob ids. Render the section only when a video URL is set. %>
111
+ <% demo_video = t('collavre.landing.demo.video', default: nil) %>
112
+ <% demo_poster = t('collavre.landing.demo.poster', default: nil) %>
113
+ <% demo_video_dark = t('collavre.landing.demo.video_dark', default: nil) %>
114
+ <% demo_poster_dark = t('collavre.landing.demo.poster_dark', default: nil) %>
115
+ <% if demo_video.present? || demo_video_dark.present? %>
79
116
  <section id="demo" class="landing-section landing-demo">
80
117
  <div class="landing-container">
81
118
  <h2 class="landing-section-title"><%= t('collavre.landing.demo.title') %></h2>
82
119
  <%# Light mode video %>
120
+ <% if demo_video.present? %>
83
121
  <div class="landing-demo-frame landing-demo-light" data-controller="landing-video">
84
122
  <video class="landing-demo-video"
85
123
  data-landing-video-target="video"
86
124
  autoplay muted loop playsinline
87
- poster="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDEyLCJwdXIiOiJibG9iX2lkIn19--33811afcd451cef0cf10313fa1bfecca15b41299/demo-poster.jpg">
88
- <source src="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDEwLCJwdXIiOiJibG9iX2lkIn19--c272341a19ed8e847208c52da0617ebc801e2e67/demo.mp4" type="video/mp4">
125
+ poster="<%= demo_poster %>">
126
+ <source src="<%= demo_video %>" type="video/mp4">
89
127
  </video>
90
128
  <div class="landing-demo-progress" data-landing-video-target="progressBar">
91
129
  <div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
@@ -94,13 +132,15 @@
94
132
  <span class="landing-demo-toggle-icon" data-landing-video-target="icon">❚❚</span>
95
133
  </button>
96
134
  </div>
135
+ <% end %>
97
136
  <%# Dark mode video %>
137
+ <% if demo_video_dark.present? %>
98
138
  <div class="landing-demo-frame landing-demo-dark" data-controller="landing-video">
99
139
  <video class="landing-demo-video"
100
140
  data-landing-video-target="video"
101
141
  autoplay muted loop playsinline
102
- poster="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDEzLCJwdXIiOiJibG9iX2lkIn19--53c3c505596bca0befbbebb5958764a153ffee5e/demo-dark-poster.jpg">
103
- <source src="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDExLCJwdXIiOiJibG9iX2lkIn19--758c90c311bb68f46a2bda87553e3cdfd800420b/demo-dark.mp4" type="video/mp4">
142
+ poster="<%= demo_poster_dark %>">
143
+ <source src="<%= demo_video_dark %>" type="video/mp4">
104
144
  </video>
105
145
  <div class="landing-demo-progress" data-landing-video-target="progressBar">
106
146
  <div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
@@ -109,8 +149,10 @@
109
149
  <span class="landing-demo-toggle-icon" data-landing-video-target="icon">❚❚</span>
110
150
  </button>
111
151
  </div>
152
+ <% end %>
112
153
  </div>
113
154
  </section>
155
+ <% end %>
114
156
 
115
157
  <%# ═══════════════════════════════════════ FINAL CTA ════════════════════════════════════════ %>
116
158
  <section class="landing-section landing-cta">