maquina_stream 0.1.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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/.rdoc_options +30 -0
  3. data/CHANGELOG.md +38 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +163 -0
  6. data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
  7. data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
  8. data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
  9. data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
  10. data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
  11. data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
  12. data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
  13. data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
  14. data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
  15. data/app/controllers/maquina_stream/application_controller.rb +21 -0
  16. data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
  17. data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
  18. data/app/helpers/maquina_stream/components_helper.rb +80 -0
  19. data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
  20. data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
  21. data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
  22. data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
  23. data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
  24. data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
  25. data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
  26. data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
  27. data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
  28. data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
  29. data/app/javascript/maquina_stream/index.js +59 -0
  30. data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
  31. data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
  32. data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
  33. data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
  34. data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
  35. data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
  36. data/config/importmap.rb +10 -0
  37. data/config/locales/en.yml +79 -0
  38. data/config/locales/es.yml +82 -0
  39. data/config/routes.rb +11 -0
  40. data/docs/configuration.md +219 -0
  41. data/docs/deferred-renderers.md +184 -0
  42. data/docs/getting-started.md +356 -0
  43. data/docs/javascript.md +298 -0
  44. data/docs/registries.md +283 -0
  45. data/docs/repair.md +162 -0
  46. data/docs/security.md +247 -0
  47. data/docs/streaming.md +308 -0
  48. data/lib/generators/maquina_stream/install/USAGE +26 -0
  49. data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
  50. data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
  51. data/lib/generators/maquina_stream/streamable/USAGE +28 -0
  52. data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
  53. data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
  54. data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
  55. data/lib/maquina_stream/block.rb +99 -0
  56. data/lib/maquina_stream/broadcaster.rb +233 -0
  57. data/lib/maquina_stream/component_cache.rb +0 -0
  58. data/lib/maquina_stream/components/contract.rb +184 -0
  59. data/lib/maquina_stream/components.rb +135 -0
  60. data/lib/maquina_stream/configuration.rb +240 -0
  61. data/lib/maquina_stream/document.rb +296 -0
  62. data/lib/maquina_stream/engine.rb +46 -0
  63. data/lib/maquina_stream/errors.rb +17 -0
  64. data/lib/maquina_stream/export.rb +66 -0
  65. data/lib/maquina_stream/frame.rb +73 -0
  66. data/lib/maquina_stream/manifest.rb +137 -0
  67. data/lib/maquina_stream/registries.rb +116 -0
  68. data/lib/maquina_stream/renderer/fence.rb +115 -0
  69. data/lib/maquina_stream/renderer/post_pass.rb +363 -0
  70. data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
  71. data/lib/maquina_stream/renderer/view_context.rb +72 -0
  72. data/lib/maquina_stream/renderer.rb +128 -0
  73. data/lib/maquina_stream/sanitizer.rb +392 -0
  74. data/lib/maquina_stream/streamable.rb +281 -0
  75. data/lib/maquina_stream/text_direction.rb +56 -0
  76. data/lib/maquina_stream/themes.rb +84 -0
  77. data/lib/maquina_stream/version.rb +5 -0
  78. data/lib/maquina_stream.rb +175 -0
  79. metadata +204 -0
@@ -0,0 +1,356 @@
1
+ # Getting started
2
+
3
+ From `bundle add maquina_stream` to a message streaming into a browser: two
4
+ commands, two seams you fill in yourself, one view.
5
+
6
+ ## 1. Install
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem "maquina_stream"
11
+ ```
12
+
13
+ Rails 8, Ruby 3.3+. The engine pulls in `maquina_remend`, `commonmarker`,
14
+ `nokogiri` and `rouge`. `maquina_components` is optional; without it the engine
15
+ renders its own Tailwind fallbacks.
16
+
17
+ It ships no migrations and no models. **The host owns persistence.**
18
+
19
+ **Turbo is required.** Repair applies Turbo Stream morphs, and with
20
+ `window.Turbo` undefined every repair fails inside a catch — the message stops
21
+ being correct and nothing in the browser says so. The install generator pins it
22
+ if your Gemfile has `turbo-rails` and refuses loudly if it does not.
23
+
24
+ ## 2. Two commands
25
+
26
+ ```sh
27
+ bin/rails generate maquina_stream:install
28
+ bin/rails generate maquina_stream:streamable Message
29
+ ```
30
+
31
+ ```
32
+ create config/initializers/maquina_stream.rb
33
+ route mount MaquinaStream::Engine => "/maquina_stream"
34
+ append config/importmap.rb
35
+ append app/javascript/controllers/index.js
36
+ insert app/views/layouts/application.html.erb
37
+
38
+ create db/migrate/20260101000000_create_messages.rb
39
+ create app/models/message.rb
40
+ gsub config/initializers/maquina_stream.rb
41
+ ```
42
+
43
+ Both are idempotent and neither overwrites a file. Re-run them as often as you
44
+ like; anything already in place is reported and left alone. A step whose
45
+ ingredient is missing — no importmap, no Stimulus entrypoint, no layout — prints
46
+ the exact content to paste rather than failing or silently doing nothing.
47
+
48
+ `streamable` is per model, because a host may have several: an assistant
49
+ message and a tool call are two streams, not one.
50
+
51
+ ```sh
52
+ bin/rails db:migrate
53
+ ```
54
+
55
+ The migration carries the three columns the contract needs — the buffer, plus
56
+ `stream_sequence` and `stream_status` — and generates them from
57
+ `MaquinaStream::Streamable` itself, so it cannot drift from the contract
58
+ `maquina_stream_contract_gaps` checks. A model whose table already exists gets
59
+ `add_column` for only the columns it lacks.
60
+
61
+ | Flag | What it does |
62
+ |---|---|
63
+ | `--buffer=body` | Names a different column as the markdown buffer |
64
+ | `--stream-for="[:conversation, record.conversation_id, :messages]"` | Sets the Turbo broadcast target |
65
+ | `--deferred-renderers` (install) | Also pins `mermaid` and `katex`, with `preload: false` |
66
+
67
+ Assert the contract in your own suite, so a missing column fails at test time
68
+ rather than mid-stream:
69
+
70
+ ```ruby
71
+ test "Message satisfies the maquina_stream contract" do
72
+ assert_empty Message.maquina_stream_contract_gaps
73
+ end
74
+ ```
75
+
76
+ ## 3. The two seams
77
+
78
+ The generators write everything the engine can know. These two it cannot.
79
+
80
+ ### `authorize`, in the initializer
81
+
82
+ ```ruby
83
+ # config/initializers/maquina_stream.rb
84
+ c.authorize = ->(record, request) { false } # ← the generated stub
85
+ ```
86
+
87
+ **As generated it denies every repair request.** That is the safe failure and a
88
+ broken feature both: the browser can never repair a message until you replace
89
+ it. Guessing a host's authorization is how an engine leaks other people's
90
+ messages, so it guesses nothing.
91
+
92
+ ```ruby
93
+ c.authorize = ->(record, request) do
94
+ record.conversation.member?(request.session[:user_id])
95
+ end
96
+ ```
97
+
98
+ `find_stream` is the other seam, and `maquina_stream:streamable` fills it in:
99
+
100
+ ```ruby
101
+ c.find_stream = ->(sid) { Message.find_by(id: sid) }
102
+ ```
103
+
104
+ Unset, it raises rather than returning `nil` — a silent `nil` would look like a
105
+ missing record rather than a missing seam. Every other option in the generated
106
+ initializer is commented, with the default it already has.
107
+ [configuration.md](configuration.md) explains each one.
108
+
109
+ ### `stream_for:`, in the model
110
+
111
+ ```ruby
112
+ class Message < ApplicationRecord
113
+ include MaquinaStream::Streamable
114
+
115
+ maquina_stream buffer: :content,
116
+ stream_for: ->(record) { record }
117
+ end
118
+ ```
119
+
120
+ `stream_for:` returns the Turbo broadcast target. The generated one gives every
121
+ message a stream of its own; a conversation-wide target is usually what you
122
+ want:
123
+
124
+ ```ruby
125
+ stream_for: ->(record) { [:conversation, record.conversation_id, :messages] }
126
+ ```
127
+
128
+ The engine never guesses one, because who may subscribe to a stream is your
129
+ question, not the engine's.
130
+
131
+ ## 4. The view
132
+
133
+ The engine appends block HTML into `#ms-msg-<sid>` and never creates that
134
+ element. The wrapper, the controllers on it and its repair URLs are yours:
135
+
136
+ ```erb
137
+ <%# app/views/messages/_message.html.erb %>
138
+ <article id="live-msg-<%= message.maquina_stream_id %>">
139
+ <div id="ms-msg-<%= message.maquina_stream_id %>"
140
+ data-controller="ms-repair ms-reveal"
141
+ data-ms-repair-manifest-url-value="<%= maquina_stream.manifest_path(sid: message.maquina_stream_id) %>"
142
+ data-ms-repair-blocks-url-value="<%= maquina_stream.blocks_path(sid: message.maquina_stream_id) %>"
143
+ data-ms-repair-interval-value="4000"
144
+ <%= "data-ms-streaming" if message.maquina_stream_open? %>><%= MaquinaStream.render(message) %></div>
145
+ </article>
146
+ ```
147
+
148
+ `maquina_stream.` is the mounted engine's route proxy, so those two paths follow
149
+ wherever the install generator mounted it.
150
+
151
+ `data-ms-streaming` is the one attribute you have to keep correct. It is stamped
152
+ from `maquina_stream_open?`, and everything derived from "this message is still
153
+ being written" reads it: the caret CSS, the reveal animation, and the guard that
154
+ keeps copy and download buttons inert while a code block is half-arrived.
155
+ Taking it off is what a seal looks like in the DOM.
156
+
157
+ Subscribe the page to the same target you gave `stream_for:`:
158
+
159
+ ```erb
160
+ <%= turbo_stream_from :conversation, @conversation.id, :messages %>
161
+ <div id="messages">
162
+ <%= render @messages %>
163
+ </div>
164
+ ```
165
+
166
+ ## 5. Stream one message end to end
167
+
168
+ Create the record, broadcast an empty shell so the browser has somewhere to put
169
+ blocks, then feed the broadcaster:
170
+
171
+ ```ruby
172
+ class Message < ApplicationRecord
173
+ # …
174
+
175
+ def broadcast_shell
176
+ if maquina_stream_open?
177
+ Turbo::StreamsChannel.broadcast_append_to(
178
+ maquina_stream_target,
179
+ target: "messages",
180
+ partial: "messages/message",
181
+ locals: {message: self}
182
+ )
183
+ else
184
+ Turbo::StreamsChannel.broadcast_replace_to(
185
+ maquina_stream_target,
186
+ target: "live-msg-#{maquina_stream_id}",
187
+ partial: "messages/message",
188
+ locals: {message: self},
189
+ attributes: {"method" => "morph"}
190
+ )
191
+ end
192
+ end
193
+
194
+ def stream_from(chat, prompt, broadcaster: MaquinaStream::Broadcaster.new(self))
195
+ chat.ask(prompt) do |chunk|
196
+ text = chunk.content.to_s
197
+ broadcaster.append(text) unless text.empty?
198
+ end
199
+
200
+ broadcaster.seal!
201
+ rescue
202
+ broadcaster.seal!(status: :errored)
203
+ raise
204
+ end
205
+ end
206
+ ```
207
+
208
+ ```ruby
209
+ message = Message.create!(conversation: conversation)
210
+ message.broadcast_shell
211
+ message.stream_from(chat, params[:prompt])
212
+ message.broadcast_shell # again, now sealed: takes data-ms-streaming off
213
+ ```
214
+
215
+ The shell goes out twice — once empty when the record opens, once more when it
216
+ seals. The second one morphs, so the blocks the deltas already delivered are
217
+ reconciled rather than deleted and recreated.
218
+
219
+ **Always seal.** A stream that ends without a seal leaves every client waiting
220
+ for a frame that never arrives; that is why the `rescue` above seals as
221
+ `errored` before re-raising.
222
+
223
+ ## 6. Watch it work
224
+
225
+ Two live pages in `test/dummy` talk to a real model rather than a fixture:
226
+
227
+ | Page | What it is |
228
+ |---|---|
229
+ | `/harness/chat` | Bare `ruby_llm`. `chat.ask` yields chunks, one record, one seal. |
230
+ | `/harness/agent` | [Nexo](https://maquina.app/documentation/nexo/). One `Streamable` record per step of an agent run. |
231
+
232
+ Both read `test/dummy/config/llm.yml`, which is git-ignored. Copy the example
233
+ and point it at any OpenAI-compatible endpoint — ollama, vLLM, LM Studio,
234
+ OpenRouter and OpenAI itself all speak it:
235
+
236
+ ```sh
237
+ cp test/dummy/config/llm.yml.example test/dummy/config/llm.yml
238
+ cd test/dummy && bundle exec puma -p 3001 config.ru
239
+ # http://localhost:3001/harness
240
+ ```
241
+
242
+ There is no ENV fallback and no default host: a missing or half-written file
243
+ makes the pages say what to create rather than fail against somebody else's
244
+ endpoint.
245
+
246
+ `/harness` itself is the fixture page — every control, the link dialog, the
247
+ autoscroll pane, the deferred renderers — and `/history` shows a page of sealed
248
+ messages served from the render cache.
249
+
250
+ ## What the generators do for you
251
+
252
+ Every step, for a host that would rather do it by hand — or that has to,
253
+ because it uses a bundler instead of importmaps.
254
+
255
+ ### `maquina_stream:install`
256
+
257
+ **The initializer**, `config/initializers/maquina_stream.rb`, with every option
258
+ commented at its default and the two seams stubbed:
259
+
260
+ ```ruby
261
+ MaquinaStream.configure do |c|
262
+ c.find_stream = ->(sid) { Message.find_by(id: sid) }
263
+ c.authorize = ->(record, request) { record.conversation.readable_by?(request) }
264
+ end
265
+ ```
266
+
267
+ **The mount**, in `config/routes.rb`. The engine contributes two `GET` routes —
268
+ the repair path — and nothing else:
269
+
270
+ ```ruby
271
+ mount MaquinaStream::Engine => "/maquina_stream"
272
+ ```
273
+
274
+ **The Turbo pin**, in `config/importmap.rb`. The engine appends its own pins to
275
+ your importmap through its engine initializer, and pins nothing third-party —
276
+ not even Stimulus, because an engine that pinned it would win or lose a version
277
+ fight with your app for no reason:
278
+
279
+ ```ruby
280
+ pin "@hotwired/turbo-rails", to: "turbo.min.js"
281
+ ```
282
+
283
+ **The Stimulus registration**, appended to `app/javascript/controllers/index.js`
284
+ or, failing that, to whichever entrypoint calls `Application.start()`:
285
+
286
+ ```js
287
+ import { registerMaquinaStreamControllers } from "maquina_stream"
288
+ registerMaquinaStreamControllers(application)
289
+ ```
290
+
291
+ The engine registers its own identifiers rather than relying on your eager-load
292
+ glob, because those identifiers are part of the DOM contract. See
293
+ [javascript.md](javascript.md).
294
+
295
+ **The stylesheets**, injected into the layout's `<head>`:
296
+
297
+ ```erb
298
+ <%= stylesheet_link_tag "maquina_stream/reveal" %>
299
+ <%= stylesheet_link_tag "maquina_stream/themes/light" %>
300
+ <%= stylesheet_link_tag "maquina_stream/themes/dark" %>
301
+ ```
302
+
303
+ **The deferred-renderer pins**, with `--deferred-renderers`:
304
+
305
+ ```ruby
306
+ pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", preload: false
307
+ pin "katex", to: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs", preload: false
308
+ ```
309
+
310
+ `preload: false` is load-bearing. importmap-rails preloads by default, which
311
+ emits a `<link rel="modulepreload">` and fetches both libraries on every page —
312
+ exactly the cost the lazy import inside the deferred controller exists to
313
+ avoid. Pin an exact version: NoBuild means no lockfile, so the version lives
314
+ there and nowhere else.
315
+
316
+ ### `maquina_stream:streamable`
317
+
318
+ **The migration.** The `maquina_stream` macro generates its contract methods
319
+ from three columns: the one you name as the buffer, plus `stream_sequence` and
320
+ `stream_status`.
321
+
322
+ ```ruby
323
+ create_table :messages do |t|
324
+ t.text :content, null: false, default: ""
325
+ t.integer :stream_sequence, null: false, default: 0
326
+ t.string :stream_status, null: false, default: "open"
327
+ t.timestamps
328
+ end
329
+ ```
330
+
331
+ `stream_status` holds one of `open`, `complete`, `cancelled`, `errored` or
332
+ `timed_out`.
333
+
334
+ **The model**, created if it is missing and injected into if it is not:
335
+
336
+ ```ruby
337
+ class Message < ApplicationRecord
338
+ include MaquinaStream::Streamable
339
+
340
+ maquina_stream buffer: :content,
341
+ stream_for: ->(record) { record }
342
+ end
343
+ ```
344
+
345
+ **The `find_stream` seam**, but only when it is still the stub the install
346
+ generator wrote. One you wrote yourself is never touched.
347
+
348
+ The full method table, and what to do when your columns are named differently,
349
+ is in [streaming.md](streaming.md).
350
+
351
+ ## Where to go next
352
+
353
+ - [streaming.md](streaming.md) — the contract in full, sealing, agent runs
354
+ - [configuration.md](configuration.md) — every option and how to choose
355
+ - [repair.md](repair.md) — what the two routes do and what you must implement
356
+ - [registries.md](registries.md) — teach the renderer about your own fences and tags
@@ -0,0 +1,298 @@
1
+ # JavaScript
2
+
3
+ Nine Stimulus controllers, shipped as source and pinned into your importmap.
4
+ There is no build step, no `package.json` and no npm dependency, and nothing
5
+ here imports a third-party library at load time.
6
+
7
+ ## Registering
8
+
9
+ ```js
10
+ // app/javascript/application.js
11
+ import "@hotwired/turbo-rails"
12
+ import { Application } from "@hotwired/stimulus"
13
+ import { registerMaquinaStreamControllers } from "maquina_stream"
14
+
15
+ const application = Application.start()
16
+ registerMaquinaStreamControllers(application)
17
+ ```
18
+
19
+ The engine registers its own identifiers rather than relying on your eager-load
20
+ glob, because those identifiers are part of the DOM contract and must not depend
21
+ on where you keep your files.
22
+
23
+ `registerMaquinaStreamControllers` also wires one `turbo:before-cache` listener
24
+ that calls `teardown()` on any controller that defines it, so a controller that
25
+ mutated the DOM rolls that back before Turbo snapshots the page.
26
+
27
+ ## Importmap
28
+
29
+ The engine appends its own pins to your importmap automatically. It pins only
30
+ its own source: `@hotwired/stimulus` stays your pin, because an engine that
31
+ pinned it would win or lose a version fight with your app for no reason.
32
+
33
+ Libraries the deferred renderers need are yours too:
34
+
35
+ ```ruby
36
+ # config/importmap.rb
37
+ pin "mermaid", to: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm", preload: false
38
+ pin "katex", to: "https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.mjs", preload: false
39
+ ```
40
+
41
+ `preload: false` is load-bearing. importmap-rails preloads by default, which
42
+ emits a `<link rel="modulepreload">` and fetches both libraries on every page —
43
+ exactly the cost the lazy import inside the deferred controller exists to avoid.
44
+
45
+ ## The controllers
46
+
47
+ | Identifier | Job | Mounted by |
48
+ |---|---|---|
49
+ | `ms-repair` | manifest diff, block fetch, silent morph, keyframe timer | you, on the message element |
50
+ | `ms-reveal` | animates the text that just arrived | you, on the message element |
51
+ | `ms-autoscroll` | stick to the bottom, release when the reader scrolls up | you, on whatever scrolls |
52
+ | `ms-link-safety` | confirmation dialog before following a link out | you, around the message |
53
+ | `ms-code` | copy and download a code block | the renderer |
54
+ | `ms-table` | copy and download a table, toggle fullscreen | the renderer |
55
+ | `ms-deferred` | base class: lazy import, render on payload change, sanitize output | — |
56
+ | `ms-diagram`, `ms-math` | extend `ms-deferred` | the renderer, per the fence registry |
57
+
58
+ The renderer emits everything the controllers need **inside** a message. Four
59
+ are yours, because they are page-level rather than message-level.
60
+
61
+ **Mounting a controller without its actions is silent.** Nothing errors; the
62
+ controller simply never hears anything. Check the `data-action` when a control
63
+ seems dead.
64
+
65
+ ## The message element
66
+
67
+ ```erb
68
+ <div id="ms-msg-<%= message.maquina_stream_id %>"
69
+ data-controller="ms-repair ms-reveal"
70
+ data-ms-repair-manifest-url-value="<%= maquina_stream.manifest_path(sid: message.maquina_stream_id) %>"
71
+ data-ms-repair-blocks-url-value="<%= maquina_stream.blocks_path(sid: message.maquina_stream_id) %>"
72
+ data-ms-repair-interval-value="4000"
73
+ <%= "data-ms-streaming" if message.maquina_stream_open? %>><%= MaquinaStream.render(message) %></div>
74
+ ```
75
+
76
+ `data-ms-streaming` is the one attribute you have to keep correct, and
77
+ everything about "this message is still being written" derives from it. Blocks
78
+ carry no streaming state of their own: a block is exactly its content plus its
79
+ identity, so two tabs holding the same content hold the same DOM, and a block's
80
+ bytes never disagree with the digest repair compares them by.
81
+
82
+ The caret is CSS, reading the same attribute:
83
+
84
+ ```css
85
+ [data-ms-streaming] > [data-ms-block]:last-child::after { /* caret */ }
86
+ ```
87
+
88
+ Controllers find the message element by `#ms-msg-<sid>`; if you wrap rendered
89
+ output some other way, mark your wrapper `data-ms-message`.
90
+
91
+ ### `ms-repair`
92
+
93
+ | Value | Default | Meaning |
94
+ |---|---|---|
95
+ | `data-ms-repair-manifest-url-value` | — | required |
96
+ | `data-ms-repair-blocks-url-value` | — | required |
97
+ | `data-ms-repair-interval-value` | `4000` | keyframe period in ms; `0` disables the timer |
98
+ | `data-ms-repair-seq-value` | `0` | last sequence seen |
99
+ | `data-ms-repair-rollup-value` | `""` | last rollup digest seen |
100
+
101
+ It fetches on a final seal, on a sequence gap, on reconnect or tab visibility,
102
+ and on the keyframe timer. See [repair.md](repair.md).
103
+
104
+ ### `ms-reveal`
105
+
106
+ Mounts on the message element, has no targets and no actions, and needs one
107
+ stylesheet:
108
+
109
+ ```erb
110
+ <%= stylesheet_link_tag "maquina_stream/reveal" %>
111
+ ```
112
+
113
+ When a block's text grows, the tail that just arrived is wrapped in **one**
114
+ `<span data-ms-revealing>`, animated in, and unwrapped again on `animationend` —
115
+ so a block is plain text between frames and the DOM gains at most one extra live
116
+ node per block, not one per word.
117
+
118
+ | Value | Default | Meaning |
119
+ |---|---|---|
120
+ | `data-ms-reveal-duration-value` | `320` | milliseconds; writes `--ms-reveal-duration` |
121
+ | `data-ms-reveal-disabled-value` | `false` | markup without the animation |
122
+
123
+ Nothing animates when the message element has no `data-ms-streaming`, while the
124
+ tab is hidden, while suppressed, or under `prefers-reduced-motion: reduce` — the
125
+ last is answered in the controller as well as in CSS, because with
126
+ `animation: none` no `animationend` fires and a span wrapped anyway would never
127
+ be unwrapped.
128
+
129
+ Suppression is an event, not a method call. `ms-repair` dispatches `ms:suppress`
130
+ on the message element before a repair morph and `ms:resume` after it;
131
+ `ms-reveal` listens for both in `connect`. Suppression unwraps whatever is
132
+ mid-flight so the morph never sees reveal chrome, and resume re-baselines every
133
+ block to the text currently on screen — which is what keeps a repair from
134
+ re-revealing what the reader has already read.
135
+
136
+ ### `ms-autoscroll`
137
+
138
+ Wrap whatever actually scrolls, and bind the three actions:
139
+
140
+ ```erb
141
+ <div data-controller="ms-autoscroll"
142
+ data-action="scroll->ms-autoscroll#track
143
+ wheel->ms-autoscroll#release
144
+ touchmove->ms-autoscroll#release"
145
+ style="overflow-y: auto">
146
+ <%= render @messages %>
147
+ </div>
148
+ ```
149
+
150
+ For a page that scrolls as a whole:
151
+
152
+ ```erb
153
+ <div data-controller="ms-autoscroll"
154
+ data-ms-autoscroll-scroller-value="window"
155
+ data-action="scroll@window->ms-autoscroll#track
156
+ wheel@window->ms-autoscroll#release
157
+ touchmove@window->ms-autoscroll#release">
158
+ ```
159
+
160
+ | Value | Default | Meaning |
161
+ |---|---|---|
162
+ | `scroller` | `"self"` | `"self"` or `"window"` |
163
+ | `threshold` | `32` | how close to the bottom still counts as at the bottom, in px |
164
+ | `pinned` | `true` | current state, serialized so it survives a morph |
165
+
166
+ The rule the whole controller exists to keep: **it never scrolls unless it is
167
+ pinned, and only the reader can pin it.** Pinning is derived from position and
168
+ never remembered, so scrolling back to the bottom re-pins by the same rule that
169
+ unpinned you.
170
+
171
+ ### `ms-link-safety`
172
+
173
+ Mount it on the container, not on each anchor: the anchors are model output,
174
+ their count is unbounded, and one delegated listener survives a repair morph
175
+ that replaces every one of them.
176
+
177
+ ```erb
178
+ <div data-controller="ms-link-safety" data-action="click->ms-link-safety#intercept">
179
+ <%= MaquinaStream.render(message) %>
180
+
181
+ <dialog data-ms-link-safety-target="dialog" aria-labelledby="link-safety-title">
182
+ <h3 id="link-safety-title"><%= t("maquina_stream.link_safety.title") %></h3>
183
+ <p data-ms-link-safety-target="url"></p>
184
+ <label>
185
+ <input type="checkbox" data-ms-link-safety-target="remember">
186
+ <%= t("maquina_stream.link_safety.always_allow") %>
187
+ </label>
188
+ <button type="button" data-action="ms-link-safety#confirm"><%= t("maquina_stream.link_safety.confirm") %></button>
189
+ <button type="button" data-action="ms-link-safety#cancel"><%= t("maquina_stream.link_safety.cancel") %></button>
190
+ </dialog>
191
+ </div>
192
+ ```
193
+
194
+ | Value | Default | Meaning |
195
+ |---|---|---|
196
+ | `origin` | `""` | same-origin links are followed with no prompt |
197
+ | `bypass` | `false` | turns the guard off, for a host with its own interstitial |
198
+ | `rememberKey` | `"ms-link-safety.trusted"` | session-storage key for hosts the reader chose to trust |
199
+
200
+ Trust is kept in session storage, not local: trust granted mid-conversation
201
+ should not outlive the tab. The controller refuses anything outside `http:`,
202
+ `https:` and `mailto:` whatever the document says.
203
+
204
+ The allowlist is a function, not an attribute — an allowlist an injected
205
+ fragment can rewrite is not an allowlist:
206
+
207
+ ```js
208
+ import { linkSafety } from "maquina_stream"
209
+ linkSafety.allow = (url) => url.hostname.endsWith("example.com")
210
+ ```
211
+
212
+ `url` is a parsed `URL`. Returning true follows the link with no dialog.
213
+
214
+ ### `ms-code`
215
+
216
+ Mounted by the renderer on a code block. Copy and download read the
217
+ `<pre hidden data-ms-code-source>` carrier, never the highlighted markup, so a
218
+ copy returns raw source rather than Rouge's spans.
219
+
220
+ | Value | Default | Meaning |
221
+ |---|---|---|
222
+ | `sourceSelector` | `"[data-ms-code-source]"` | late-bound, so a host with a different carrier need not fork the controller |
223
+ | `filename` | `""` | download name; otherwise derived from the language |
224
+
225
+ An unknown language downloads as `.txt` rather than guessing an extension.
226
+
227
+ ### `ms-table`
228
+
229
+ Mounted by the renderer on the table wrapper. It reconstructs the table from the
230
+ DOM cell by cell using `textContent` — nothing here reads or produces HTML.
231
+
232
+ The engine renders its own control bar unless you turn the `table` controls off.
233
+ If you render your own:
234
+
235
+ ```html
236
+ <div data-ms-table data-controller="ms-table">
237
+ <button data-ms-control data-action="ms-table#copy" data-ms-table-format-param="markdown">…</button>
238
+ <button data-ms-control data-action="ms-table#download" data-ms-table-format-param="csv">…</button>
239
+ <button data-ms-control data-action="ms-table#toggleFullscreen">…</button>
240
+ <table>…</table>
241
+ </div>
242
+ ```
243
+
244
+ Formats are `markdown`, `csv` and `tsv`.
245
+
246
+ ## Controls are inert while streaming
247
+
248
+ Two things make that true, and both are needed:
249
+
250
+ 1. the message element carries `data-ms-streaming` while the message is open;
251
+ 2. every control carries `data-ms-control`.
252
+
253
+ The base controller disables marked controls whenever the attribute is present
254
+ and re-enables them the moment the seal removes it, so the UI never offers half
255
+ a code block to copy. **Controls you render yourself must carry
256
+ `data-ms-control` too**, or they stay clickable mid-stream.
257
+
258
+ Turning controls off in configuration removes the buttons the renderer emits.
259
+ See [configuration.md](configuration.md).
260
+
261
+ ## Events
262
+
263
+ Every controller dispatches through Stimulus with the `ms` prefix, so listen for
264
+ `ms:<name>` on or above the element.
265
+
266
+ | Event | Detail | From |
267
+ |---|---|---|
268
+ | `ms:copied` | `{length, fallback?}` | any copy control |
269
+ | `ms:copy-failed` | `{text}` | any copy control |
270
+ | `ms:downloaded` | `{filename}` | any download control |
271
+ | `ms:refused` | `{reason: "streaming"}` | a control clicked while the message is open |
272
+ | `ms:repaired` | `{reason, blocks}` | `ms-repair` |
273
+ | `ms:repair-failed` | `{reason, error}` | `ms-repair` |
274
+ | `ms:rendered` | `{controller}` | a deferred renderer |
275
+ | `ms:render-failed` | `{controller, error}` | a deferred renderer |
276
+ | `ms:fullscreen` | `{fullscreen}` | `ms-table` |
277
+ | `ms:autoscroll` | `{pinned}` | `ms-autoscroll` |
278
+ | `ms:link-prompted` | `{href}` | `ms-link-safety` |
279
+ | `ms:link-followed` | `{href}` | `ms-link-safety` |
280
+ | `ms:link-cancelled` | `{href}` | `ms-link-safety` |
281
+ | `ms:link-refused` | `{href}` | `ms-link-safety` |
282
+
283
+ `ms:suppress` and `ms:resume` are dispatched on the message element as plain
284
+ `CustomEvent`s, without the prefix, and do not bubble.
285
+
286
+ ## Locale
287
+
288
+ Labels follow `I18n.locale`, like any Rails app. `config.locale` is the engine's
289
+ own fallback for a host that has expressed no preference. Spanish and English
290
+ both ship complete.
291
+
292
+ ## Treat everything the DOM says as untrusted
293
+
294
+ No `ms-` controller assigns a DOM-derived or payload-derived string as HTML. The
295
+ server sanitizer allows `data-controller`, restricted to the `ms-` namespace,
296
+ but it cannot tell a controller our post-pass emitted from one an injected
297
+ fragment asked for. A controller of your own that reads values out of rendered
298
+ message markup should hold the same line. See [security.md](security.md).