hibiki_rails 0.9.1 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8ac38db2808ab9f20f6fccf7cf50c202454fc222cba122f6466e25cf5fe2eb3
4
- data.tar.gz: 89b5a3ab98b4bf10ba10466de9f352d9e9e3c3a814abb35175df798aa4fafc89
3
+ metadata.gz: e95a440701dfeb3c02a3f6535b59cc18e51d66569dd76f9d793153f671120edc
4
+ data.tar.gz: 14869e53518e44f14089c7a1be15c166a51e9f61ff87867cd9fcbb848b20a3dc
5
5
  SHA512:
6
- metadata.gz: 1d970c278b67be036e36a4703e3a38f6581b27bb890a4a7b599000755085a74f311646ae1519752e6457633590326bb9e6ca0fcf1b7fb1925e784d5f3a2c70b3
7
- data.tar.gz: 468b0706478f48ee97019baf35b4da08912647b7a649575b8f970d05a21186cb85cf9c6c5dc89d9d0e6c75c6b0ad5c936c0b5cb6d8b223b5924edbb094ea2304
6
+ metadata.gz: 3937c28fbcd8ec47e540a2837b6c48d34b4152553037491a576a67b22679d58578c09c4b63ea316390a1a5d5d49e9c37b29fe277dd8150484a881899ee8f1b00
7
+ data.tar.gz: 88ca77fa4732bf3d08f7b207c57ec457bc601afa7111dda3d4660b51d62a1b305b88c9a98b1801cfdc5a29f2723f8d29f5b617b545cd3c9c952e1b3c9aef8329
data/CHANGELOG.md CHANGED
@@ -4,6 +4,86 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.11.0 — 2026-09-05
8
+
9
+ ### Changed
10
+
11
+ **The client rides turbo-rails' Action Cable consumer.** `hibiki.js` now
12
+ takes its consumer from `cable.getConsumer()` (exported by
13
+ `@hotwired/turbo-rails`) instead of importing `@rails/actioncable` itself.
14
+ Islands and `turbo_stream_from` share ONE websocket where each page used to
15
+ open two, an app's `cable.setConsumer(...)` applies to islands too, and the
16
+ library stops reaching the page twice: bundler apps shed the second copy
17
+ (~14.6 KB; turbo-rails imports `@rails/actioncable/src`, the client imported
18
+ the package root, and a bundler cannot dedupe two specifiers), importmap apps
19
+ stop fetching `actioncable.esm.js` next to the copy `turbo.min.js` already
20
+ carries (~14.8 KB, one request).
21
+
22
+ - npm: the `@rails/actioncable` peer dependency is gone; `@hotwired/turbo-rails
23
+ >= 8.0` is the peer instead (every Rails 8 app has it).
24
+ - `hibiki:rails:install` no longer pins `@rails/actioncable`. An existing pin
25
+ is harmless — nothing imports it — and may be deleted.
26
+ - Subscribing now awaits the consumer once (a microtask after the first
27
+ island); nothing observable changes, actions are still queued until the
28
+ subscription confirms.
29
+
30
+ ## 0.10.0 — 2026-08-22
31
+
32
+ ### Added
33
+
34
+ **`hibiki:rails:upload_field` — a `has_one_attached` upload on both edit
35
+ surfaces.** `bin/rails g hibiki:rails:upload_field Album cover` extends a
36
+ hibiki:rails scaffold: the classic form gains a direct-upload file field plus
37
+ a remove checkbox (a virtual `remove_cover` attribute whose purge yields to a
38
+ same-submit upload), and the inline channel form gains a nameless file input
39
+ that direct-uploads on change and hands only the signed blob id to the graph.
40
+ The pending state lives in the channel (a generated concern under
41
+ `app/channels/concerns`, one `include` in the channel), keyed by the form's
42
+ dom, so it survives repaints and the edit row and inline create form stay
43
+ independent; attach/purge happens after a successful commit. Both channel
44
+ queries are preloaded with `with_attached_cover`, `:cover`/`:remove_cover`
45
+ join the permitted params, and one shared
46
+ `app/javascript/controllers/upload_field_controller.js` is emitted per app —
47
+ parameterized by Stimulus values, so a second attachment reuses it. One
48
+ attachment per run; `has_one_attached` only.
49
+
50
+ **`--accept` — non-image formats.** `--accept=pdf,doc` narrows both file
51
+ inputs (image by default; tokens `image`, `audio`, `video`, `pdf`, `csv`,
52
+ `text`, `doc`, `xls`, `ppt`, plus any raw `type/subtype` or `.ext` passed
53
+ through; an unknown token refuses before anything is written). Every display
54
+ site — the inline form, the classic form, and the row on the index and show
55
+ page — branches per blob at render time on `variable?`: an image gets its
56
+ variant thumbnail, anything else its filename and size, with a
57
+ `rails_blob_path` link on the row. That is one template for every accept
58
+ list, so mixed lists work and a wrong-typed file past the advisory `accept`
59
+ no longer breaks the index at `variant`. The image_processing notice fires
60
+ only when an image type is accepted. The accept list stays advisory — the
61
+ app owns any server-side content-type validation.
62
+
63
+ **`--many` — `has_many_attached` galleries.** `bin/rails g
64
+ hibiki:rails:upload_field Album photos --many` (implied when the model
65
+ already declares `has_many_attached :photos`; `--many` against an existing
66
+ `has_one_attached` refuses) generates the gallery shape on both surfaces.
67
+ The inline form's input takes several files — the shared
68
+ `upload_field_controller.js` now uploads one file after another, so a
69
+ gallery's pending list keeps the pick order; a single-file input behaves
70
+ as before, and a many-mode run refreshes a copy from before the loop. Each
71
+ attached file carries a graph-owned ✕ (toggle: ✕ marks, Undo unmarks) and
72
+ each pending file its own badge with a ✕ to drop it; the pending state per
73
+ form dom is `{ adds:, removes: }`, applied after a successful commit by
74
+ APPENDING through `attach` and purging the marked ids — the collection is
75
+ never assigned, which since Rails 7.1 replaces it. The classic form gets
76
+ the same append/remove split as two virtual model attributes,
77
+ `add_photos` (a `multiple` direct-upload field, attached in `before_save`)
78
+ and `remove_photo_ids` (a checkbox per attached file, purged in
79
+ `after_save`), riding `params.expect` as a braced group. The row and show
80
+ page render the gallery with the same per-blob thumbnail-or-filename
81
+ branch, so `--many --accept=pdf` is a document list. Two css tokens
82
+ (`gallery_item`, `upload_status_list`).
83
+
84
+ The packaged client did not change — the emitted Stimulus controller imports
85
+ `@rails/activestorage` from the app's own dependencies.
86
+
7
87
  ## 0.9.1 — 2026-08-18
8
88
 
9
89
  ### Added
data/README.md CHANGED
@@ -157,7 +157,7 @@ A few things that make a change easier to accept:
157
157
 
158
158
  The gem side needs nothing special: point an app's Gemfile at your clone with `gem "hibiki_rails", path: "../hibiki-rails"`.
159
159
 
160
- The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@rails/actioncable` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
160
+ The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@hotwired/turbo-rails` and `@hotwired/stimulus` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
161
161
 
162
162
  ```sh
163
163
  cd hibiki-rails && bun install # before linking, not after
@@ -1,17 +1,16 @@
1
1
  // The packaged client for hibiki_rails. Two shapes, one plumbing:
2
2
  //
3
- // 1. The generic controller (HibikiController, register as "hibiki"):
3
+ // 1. The generic controller (HibikiController, registered as "hibiki"):
4
4
  // drives any island stamped by the Hibiki::Rails::Helpers Ruby
5
- // helpers. Stimulus is the lifecycle host only (connect/disconnect
5
+ // helpers. Stimulus is only the lifecycle host (connect/disconnect
6
6
  // across Turbo navigation, morphs, and dynamic insertion); the wire
7
7
  // protocol is hibiki-owned data attributes, so server-side components
8
- // never write Stimulus vocabulary and a non-Stimulus client can speak
9
- // the same attributes (toy-phlex's vanilla driver is the proof).
10
- // 2. The subclassable base (ChannelController): for apps that prefer the
8
+ // never write Stimulus vocabulary.
9
+ // 2. The subclassable base (ChannelController): for apps that prefer
11
10
  // familiar Stimulus structure (data-controller="counter",
12
11
  // data-action="counter#increment"). The base owns the consumer, the
13
- // subscription lifecycle, and transport handling; plain actions are
14
- // auto-forwarded to the channel, so a subclass only declares a method
12
+ // subscription lifecycle, and transport handling. Plain actions are
13
+ // auto-forwarded to the channel; a subclass declares a method only
15
14
  // when it needs something custom:
16
15
  //
17
16
  // import { ChannelController } from "hibiki-rails"
@@ -27,18 +26,17 @@
27
26
  // Both shapes speak both transports. Transmit: the server's render
28
27
  // effects `transmit({ html: })` fragments that are swapped in by their
29
28
  // root DOM id, and `transmit_value` messages that update every
30
- // data-hibiki-value placeholder; `received` is registered at subscribe
31
- // time — before the
32
- // server runs build_graph — so the effects' first transmits always land
33
- // (the server-rendered initial HTML is only a paint-avoidance
34
- // placeholder). Turbo broadcasts: when the controller's element contains
35
- // its own <turbo-cable-stream-source> (turbo_stream_from), the graph's
36
- // first broadcast is lost unless that stream has already confirmed ITS
37
- // subscription — so connect awaits it (streamConnected) before
38
- // subscribing, and rendering flows back over the Turbo stream while
39
- // `received` stays idle.
29
+ // data-hibiki-value placeholder. `received` is registered at subscribe
30
+ // time, before the server runs build_graph, so the effects' first
31
+ // transmits always land; the server-rendered HTML fills the space only
32
+ // until they do. Turbo broadcasts: when the controller's element
33
+ // contains its own <turbo-cable-stream-source> (turbo_stream_from), the
34
+ // graph's first broadcast is lost unless that stream has already
35
+ // confirmed ITS subscription — so connect awaits it (streamConnected)
36
+ // before subscribing, and rendering flows back over the Turbo stream
37
+ // while `received` stays idle.
40
38
  //
41
- // The attribute contract of the generic controller (private to this gem —
39
+ // The generic controller's attribute contract (private to this gem —
42
40
  // emitted by the Ruby helpers, interpreted here, versioned together):
43
41
  //
44
42
  // island root data-controller="hibiki"
@@ -60,25 +58,24 @@
60
58
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
61
59
  // the server's transmit_value message updates every match
62
60
  //
63
- // The protocol also has a client-written half — the first attributes in it
64
- // that no Ruby helper emits. These are stamped here at runtime and are
65
- // read-only to app code; app CSS is their whole audience:
61
+ // The client writes the protocol's other half at runtime — read-only to
62
+ // app code, addressed to app CSS:
66
63
  //
67
64
  // island root data-hibiki-busy present while an action is in flight
68
65
  // aria-busy="true" the same fact, for assistive tech
69
66
  // data-hibiki-state connecting | ready | offline | stalled
70
67
  // firing control data-hibiki-busy on the control that started it
71
68
  //
72
- // Everything the app wants out of that is a descendant selector —
73
- // `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row and
74
- // per-button feedback needs no server state and no `{#if loading}` branch.
75
- // Content is stale during a round trip, never absent.
69
+ // App CSS reaches all of it with descendant selectors —
70
+ // `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row
71
+ // and per-button feedback needs no server state. Content is stale during
72
+ // a round trip, never absent.
76
73
  //
77
- // The left side of `->` is a hibiki event name, of which DOM events are a
78
- // subset: click, change, input, submit are delegated listeners, and
79
- // `visible` is a pseudo-event backed by an IntersectionObserver (the
80
- // element entering the viewport). Everything that is not "which event"
81
- // is a sibling attribute, so the token grammar never has to grow.
74
+ // The left side of `->` is a hibiki event name: click, change, input,
75
+ // and submit are delegated DOM listeners; `visible` is a pseudo-event
76
+ // backed by an IntersectionObserver (the element entering the viewport).
77
+ // Everything that is not "which event" is a sibling attribute, so the
78
+ // token grammar never has to grow.
82
79
  //
83
80
  // App JS reaching the graph — the ONE public seam. A gesture that needs
84
81
  // script (drag-and-drop, a third-party widget) fires its action through
@@ -96,19 +93,18 @@
96
93
  // import { performOn } from "hibiki-rails"
97
94
  // performOn(element, "nested_move", { path, to })
98
95
  //
99
- // The return value is the whole contract: truthy (the trip's seq) means
100
- // the action was ACCEPTED — sent live, or queued during the initial
101
- // connect window — so a repaint is coming and the caller should leave the
102
- // DOM as the user arranged it (the morph lands as a visual no-op). Falsy
103
- // (undefined) means it was DROPPED — the island is offline, or the socket
104
- // turned out to be dead at send — and the caller owns recovery: revert
105
- // the gesture, or stand back and let the next repaint self-heal. Nothing
106
- // queues across an offline gap on purpose (a reconnect builds a fresh
107
- // server-side graph, so replayed intent would land on state it was not
108
- // formed against). Everything else is NOT a seam: the data-hibiki-*
109
- // attributes are private, and subclassing ChannelController to reach an
110
- // existing island opens a SECOND subscription — a second server-side
111
- // graph nobody paints from.
96
+ // The return value is the whole contract. Truthy (the trip's seq): the
97
+ // action was ACCEPTED — sent live, or queued during the initial connect
98
+ // window — a repaint is coming, so leave the DOM as the user arranged it
99
+ // and let the morph land as a visual no-op. Falsy (undefined): it was
100
+ // DROPPED — the island is offline, or the socket was dead at send — and
101
+ // the caller owns recovery: revert the gesture, or let the next repaint
102
+ // self-heal. Nothing queues across an offline gap, on purpose: a
103
+ // reconnect builds a fresh server-side graph, so replayed intent would
104
+ // land on state it was not formed against. Everything else is NOT a
105
+ // seam: the data-hibiki-* attributes are private, and subclassing
106
+ // ChannelController to reach an existing island opens a SECOND
107
+ // subscription — a second server-side graph nobody paints from.
112
108
  //
113
109
  // Register the generic controller under the identifier "hibiki" (the
114
110
  // helpers hardcode it):
@@ -116,11 +112,16 @@
116
112
  // import HibikiController from "hibiki-rails"
117
113
  // application.register("hibiki", HibikiController)
118
114
  import { Controller } from "@hotwired/stimulus"
119
- import { createConsumer } from "@rails/actioncable"
120
-
121
- // One consumer shared by every controller — ActionCable multiplexes
122
- // subscriptions over a single websocket. Never disconnected: islands come
123
- // and go with the DOM, the socket stays.
115
+ import { cable } from "@hotwired/turbo-rails"
116
+
117
+ // One consumer shared by every controller — and with Turbo Streams: it is
118
+ // turbo-rails' own (`cable.getConsumer()`), so islands and
119
+ // `turbo_stream_from` multiplex over ONE websocket, and an app's
120
+ // `cable.setConsumer(...)` applies to both. Never disconnected: islands
121
+ // come and go with the DOM, the socket stays. Importing turbo-rails'
122
+ // consumer rather than @rails/actioncable also keeps the library out of
123
+ // the page twice (turbo-rails bundles it by a subpath specifier that no
124
+ // importmap can serve).
124
125
  let consumer
125
126
 
126
127
  // Live islands by root element, for performOn's ancestor walk — membership
@@ -165,12 +166,12 @@ const formPayload = (form) => {
165
166
  }
166
167
 
167
168
  // Before a fallback form goes native, re-stamp its CSRF token from the
168
- // page's csrf-token meta — which is first-paint fresh and session-valid.
169
- // Server-side repaints render without a session (ApplicationController
170
- // .render has none), so a repainted form embeds a stale token or none at
171
- // all; and this only ever runs with scripts alive, which is exactly when
172
- // the DOM may have been repainted. A truly script-free page still holds
173
- // its first-paint token and never needed the help.
169
+ // page's csrf-token meta tag, which is first-paint fresh and
170
+ // session-valid. Server-side repaints render without a session
171
+ // (ApplicationController.render has none), so a repainted form embeds a
172
+ // stale token or none at all. This only runs with scripts alive —
173
+ // exactly when the DOM may have been repainted; a script-free page
174
+ // still holds its first-paint token.
174
175
  const freshenToken = (control) => {
175
176
  if (!(control instanceof HTMLFormElement)) return
176
177
  const meta = document.querySelector('meta[name="csrf-token"]')
@@ -191,21 +192,17 @@ export class ChannelController extends Controller {
191
192
  static values = { cid: String }
192
193
 
193
194
  // Transport-state timings. Class properties on purpose: not Stimulus
194
- // values and not helper options, so the Ruby surface stays unchanged and
195
- // an island stamps nothing about them. An app that wants different
195
+ // values and not helper options, so the Ruby surface stays unchanged
196
+ // and an island stamps nothing about them. An app that wants different
196
197
  // numbers subclasses and re-registers.
197
198
  //
198
- // busyDelay ms before a round trip is worth mentioning. A localhost
199
- // trip measures 18–25 ms, so 150 suppresses that flicker
200
- // outright while a 150 ms-RTT link crosses it about exactly.
201
- // Borrowed from Turbo's progress bar, which waits ~500 ms.
199
+ // busyDelay ms before a round trip is worth mentioning — long enough
200
+ // to swallow a localhost trip (18–25 ms) without flicker.
202
201
  // busyGrace ms to wait after an ack for a Turbo render still in
203
- // flight. The ack travels the island's own socket; a
204
- // broadcast takes a pubsub hop, and measured against
205
- // generated output the direct frame beats the paint by
206
- // 3.3–3.6 ms. That gap belongs to the server and its
207
- // backend, so it does not shrink on a fast link — and Redis
208
- // widens it. Hence a bound in tens of ms, not single digits.
202
+ // flight. The ack rides the island's own socket while the
203
+ // broadcast takes a pubsub hop, so the ack routinely wins
204
+ // by a few ms — a gap owned by the server's backend, which
205
+ // no fast link shrinks. Hence tens of ms, not single digits.
209
206
  // busyCeiling ms before a trip is declared stalled rather than silently
210
207
  // cleared. On a bad link "we lost it" beats "nothing
211
208
  // happened".
@@ -220,11 +217,10 @@ export class ChannelController extends Controller {
220
217
 
221
218
  // The synchronous half of connect: everything a listener firing in the
222
219
  // next millisecond depends on. HibikiController attaches its delegated
223
- // listeners before openSubscription is even called, and the window
224
- // before the subscription confirms is ~3 serialised round trips on the
225
- // Turbo-broadcast path (Turbo's own subscribe, a SECOND websocket
226
- // handshake, then ours) — tens of ms on localhost, about a second on a
227
- // real remote link. Clicks in it used to vanish without a trace.
220
+ // listeners before openSubscription is even called, and on the
221
+ // Turbo-broadcast path the subscription takes ~3 serialised round
222
+ // trips to confirm — up to a second on a real link. Clicks in that
223
+ // window used to vanish without a trace.
228
224
  prepareTransport() {
229
225
  this.aborted = false
230
226
  this.subscribed = false
@@ -237,15 +233,16 @@ export class ChannelController extends Controller {
237
233
  }
238
234
 
239
235
  async openSubscription() {
240
- consumer ??= createConsumer()
241
236
  this.defineForwarders()
242
237
  // Turbo-broadcast transport: wait for the element's own stream source
243
238
  // to confirm before subscribing, so the graph's dependency-collecting
244
239
  // first run broadcasts into a live stream. No source (transmit
245
- // transport) → this path is fully synchronous, exactly as before.
240
+ // transport) → only the consumer await below, a microtask once it
241
+ // exists (turbo-rails memoizes it).
246
242
  const source = this.streamSource()
247
243
  if (source) await streamConnected(source)
248
- if (this.aborted) return // disconnected during the await
244
+ consumer ??= await cable.getConsumer()
245
+ if (this.aborted) return // disconnected during the awaits
249
246
  this.subscription = consumer.subscriptions.create(this.subscribeParams(), {
250
247
  received: (data) => this.handleMessage(data),
251
248
  connected: () => this.linkOpened(),
@@ -274,24 +271,23 @@ export class ChannelController extends Controller {
274
271
  // can never overwrite it. (`hbk` is the second reserved payload key —
275
272
  // ActionCable's own Subscription#perform already writes `action`.)
276
273
  //
277
- // Returns the seq so a caller that knows which control fired can attach
278
- // it; nobody has to. Returns undefined instead when the payload went
279
- // nowhere: the socket turned out to be closed under a subscription still
280
- // believed live, or the island was already offline. Public API on the
281
- // island controller (the header's "App JS reaching the graph") — truthy
282
- // = accepted, falsy = dropped and the caller owns recovery.
274
+ // Public API on the island controller (the header's "App JS reaching
275
+ // the graph"). Returns the seq when the action was accepted, so a
276
+ // caller that knows which control fired can attach it; returns
277
+ // undefined when the payload went nowhere — the socket turned out to
278
+ // be closed, or the island was already offline — and the caller owns
279
+ // recovery.
283
280
  perform(action, payload = {}) {
284
281
  const seq = ++this.seq
285
282
  payload.hbk = seq
286
283
  if (this.subscribed) {
287
284
  this.beginBusy(seq)
288
- // Action Cable's Subscription#perform returns false when the socket
289
- // is not open — the gap between the socket dying and the connection
290
- // monitor noticing, during which `subscribed` still says live. The
291
- // frame went nowhere: settle rather than letting the trip stall out
292
- // at the ceiling, and stamp `offline` now instead of when the
293
- // monitor catches up. The monitor still owns reconnecting; its
294
- // `connected` callback restores `ready` exactly as after a real gap.
285
+ // Subscription#perform returns false when the socket is not open —
286
+ // the gap between the socket dying and the connection monitor
287
+ // noticing, during which `subscribed` still says live. The frame
288
+ // went nowhere: settle the trip rather than let it stall out at
289
+ // the ceiling, and stamp `offline` now instead of when the monitor
290
+ // catches up. The monitor still owns reconnecting.
295
291
  if (this.subscription.perform(action, payload) === false) {
296
292
  this.settle(seq)
297
293
  this.linkClosed()
@@ -299,19 +295,17 @@ export class ChannelController extends Controller {
299
295
  }
300
296
  return seq
301
297
  }
302
- // Queue rather than drop while the subscription is still coming up.
303
- // ActionCable's Subscription#perform silently returns false on a socket
304
- // that is not open yet, so this was a silent no-op for the whole
305
- // connect window — and the trigger has to be the `connected` callback,
306
- // not "the subscription object exists", because the second websocket's
307
- // handshake sits between the two.
298
+ // Queue rather than drop while the subscription is still coming up:
299
+ // Subscription#perform silently returns false on a socket not open
300
+ // yet, so the whole connect window used to be a silent no-op. The
301
+ // gate must be the `connected` callback, not "the subscription
302
+ // object exists" — the second websocket's handshake sits between
303
+ // the two.
308
304
  //
309
- // Only that first window. Once the link has been up, a gap means the
310
- // socket dropped, and reconnecting builds a FRESH graph server-side
311
- // with default state — so replaying intent formed against the old one
312
- // is worse than dropping it. The island is stamped `offline` for the
313
- // whole gap, which is the signal the connect window cannot give: there
314
- // the page is painted and looks live.
305
+ // Only that first window. Once the link has been up, a gap means
306
+ // the socket dropped, and the island is stamped `offline` for the
307
+ // whole gap — the signal the connect window cannot give, where the
308
+ // page is painted and looks live.
315
309
  if (!this.connectedOnce) {
316
310
  this.beginBusy(seq)
317
311
  this.queued.push([action, payload])
@@ -336,10 +330,10 @@ export class ChannelController extends Controller {
336
330
  linkClosed() {
337
331
  this.subscribed = false
338
332
  this.setState("offline")
339
- // Deliberately NOT queued across the gap. A reconnect builds a fresh
340
- // graph server-side with default state, so replaying intent formed
341
- // against the old one is worse than dropping it. Outstanding records
342
- // settle for the same reason: their acks are never coming.
333
+ // Deliberately NOT queued across the gap: a reconnect builds a fresh
334
+ // graph server-side, so replayed intent would land on state it was
335
+ // not formed against. Outstanding records settle for the same
336
+ // reason — their acks are never coming.
343
337
  this.queued = []
344
338
  this.settleAll()
345
339
  }
@@ -505,10 +499,10 @@ export class ChannelController extends Controller {
505
499
  // Auto-forwarding: every data-action token addressed to this identifier
506
500
  // whose method the subclass did NOT declare gets a generated forwarder
507
501
  // that performs the underscored action with no payload — so plain
508
- // forwards need zero code. Declared methods always win. Methods are
509
- // defined at connect: action NAMES first appearing in later-inserted
510
- // markup aren't discovered (names already seen keep working anywhere,
511
- // Stimulus binds the elements itself).
502
+ // forwards need zero code. Declared methods always win. Forwarders are
503
+ // defined at connect, so an action NAME first appearing in
504
+ // later-inserted markup isn't discovered; a name already seen keeps
505
+ // working anywhere, because Stimulus binds the elements itself.
512
506
  defineForwarders() {
513
507
  for (const control of this.element.querySelectorAll("[data-action]")) {
514
508
  for (const token of control.dataset.action.trim().split(/\s+/)) {
@@ -563,9 +557,9 @@ export default class HibikiController extends ChannelController {
563
557
  islands.set(this.element, this)
564
558
  // Before the listeners, not after: a click delegated in the next
565
559
  // millisecond reaches perform(), which needs the busy map and the
566
- // queue to exist. This is also what stamps data-hibiki-state
567
- // ="connecting" synchronously, so the island can be dimmed for the
568
- // whole window rather than from the middle of it.
560
+ // queue to exist. This also stamps data-hibiki-state="connecting"
561
+ // synchronously, so the island can be dimmed for the whole window
562
+ // rather than from the middle of it.
569
563
  this.prepareTransport()
570
564
 
571
565
  // Root-scoped delegation (bound to the island, not document): controls
@@ -674,13 +668,12 @@ export default class HibikiController extends ChannelController {
674
668
  // A fallback control's native behavior IS the degraded path: unless
675
669
  // the island is `ready`, stand aside — no perform, no queueing — and
676
670
  // the browser follows the href or submits the form to its own
677
- // action=. Deliberately not the connect-window queue: a queued gesture
678
- // renders nothing until the link comes up, while the control's
679
- // destination answers immediately. Two touches before stepping back:
680
- // a confirm: still gates the native behavior (scripts are running, so
681
- // a destructive submit must not slip past the dialog), and a form's
682
- // authenticity_token is freshened — server-rendered repaints carry no
683
- // session, so their forms embed a stale token or none at all.
671
+ // action=. Deliberately not the connect-window queue: a queued
672
+ // gesture renders nothing until the link comes up, while the
673
+ // control's destination answers immediately. Two touches before
674
+ // stepping back: a confirm: still gates the native behavior
675
+ // (scripts are running, so a destructive submit must not slip past
676
+ // the dialog), and the form's CSRF token is freshened.
684
677
  const fallback = "hibikiFallback" in control.dataset
685
678
  if (fallback && this.state !== "ready") {
686
679
  const message = control.dataset.hibikiConfirm
data/config/importmap.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Merged into the host app's import map by the Engine's importmap
4
4
  # initializer (importmap-rails reads every path in config.importmap.paths).
5
- # The app supplies the "@hotwired/stimulus" and "@rails/actioncable" pins —
6
- # both are part of the Rails 8 default stack.
5
+ # The app supplies the "@hotwired/stimulus" and "@hotwired/turbo-rails" pins
6
+ # — both are part of the Rails 8 default stack. The client takes Action
7
+ # Cable from turbo-rails' consumer, so no "@rails/actioncable" pin is needed.
7
8
  pin "hibiki-rails", to: "hibiki.js"
@@ -121,7 +121,25 @@ module Hibiki
121
121
  fieldset_legend: "fieldset-legend px-2",
122
122
  nested_row: "border border-base-300 rounded-box p-3 mb-2 space-y-2",
123
123
  btn_ghost_sm: "btn btn-sm btn-ghost",
124
- btn_outline_sm: "btn btn-sm btn-outline"
124
+ btn_outline_sm: "btn btn-sm btn-outline",
125
+
126
+ # The upload field (hibiki:rails:upload_field): the attachment row
127
+ # in both forms, its thumbnail, the non-image filename link on the
128
+ # row, and the pending badge on its own full-width line under the
129
+ # controls (the row wraps, so a long filename never pushes Remove
130
+ # off-screen). upload_row, upload_status and thumb are plain
131
+ # utilities, so the tailwind variant inherits them.
132
+ upload_row: "flex flex-wrap items-center gap-3",
133
+ upload_status: "w-full",
134
+ # --many: one chip per attached file (thumb + ✕) and the pending
135
+ # list, which wraps like the row.
136
+ gallery_item: "inline-flex items-center gap-1",
137
+ upload_status_list: "w-full flex flex-wrap gap-2",
138
+ thumb: "rounded",
139
+ file_link: "link link-hover",
140
+ file_input: "file-input w-full",
141
+ file_input_sm: "file-input file-input-sm",
142
+ badge_info: "badge badge-info"
125
143
  }.freeze
126
144
 
127
145
  # DaisyUI is a plugin over Tailwind, and the merge says so literally:
@@ -179,7 +197,16 @@ module Hibiki
179
197
  fieldset_legend: "px-2 text-sm font-medium text-gray-700",
180
198
  nested_row: "rounded-md border border-gray-200 p-3 mb-2 space-y-2",
181
199
  btn_ghost_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
182
- btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1")
200
+ btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
201
+ file_link: "text-indigo-600 hover:underline",
202
+ file_input: "block w-full text-sm text-gray-700 file:mr-3 file:rounded-md " \
203
+ "file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm " \
204
+ "file:font-medium file:text-gray-700 hover:file:bg-gray-200",
205
+ file_input_sm: "block text-xs text-gray-700 file:mr-2 file:rounded-md " \
206
+ "file:border-0 file:bg-gray-100 file:px-2 file:py-1 file:text-xs " \
207
+ "file:font-medium file:text-gray-700 hover:file:bg-gray-200",
208
+ badge_info: "inline-flex items-center rounded-full bg-sky-100 px-2 py-1 " \
209
+ "text-xs font-medium text-sky-800"
183
210
  ).freeze
184
211
 
185
212
  # Every lookup misses, so every class argument is omitted entirely.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ module Generators
6
+ # Threading the extras: hash through a pre-0.7.0 scaffold's partial
7
+ # chain. Scaffolds since 0.7.0 emit it themselves (list -> row ->
8
+ # row_form, untouched), so on those this is a no-op probe; older
9
+ # output gets the header/keyword plus the two render calls retrofitted,
10
+ # anchored on text every scaffold emitted verbatim.
11
+ #
12
+ # Shared by every add-on generator that rides extras:
13
+ # (hibiki:rails:multiselect, hibiki:rails:upload_field). Depends on the
14
+ # generator for view_path/row_partial/row_form_partial/phlex?.
15
+ module ExtrasThreading
16
+ private
17
+
18
+ def thread_extras_through_partials
19
+ return if wired?(extras_probe_path, "extras")
20
+
21
+ say_status :compat, "pre-0.7 scaffold — threading extras: through the partials", :yellow
22
+ phlex? ? thread_extras_phlex : thread_extras_erb
23
+ return if wired?(extras_probe_path, "extras")
24
+
25
+ say_status :warn, "could not thread extras: automatically. Pass an extras: {} local " \
26
+ "from the list partial down to #{row_form_partial} yourself.", :yellow
27
+ end
28
+
29
+ def extras_probe_path = phlex? ? view_path("row_form.rb") : view_path("_#{row_form_partial}.html.erb")
30
+
31
+ def thread_extras_erb
32
+ [view_path("_list.html.erb"), view_path("_#{row_partial}.html.erb"),
33
+ view_path("_#{row_form_partial}.html.erb")].each do |path|
34
+ # /m: the row form's locals header wraps onto a second line.
35
+ gsub_file path, /^(<%# locals: \(.*?)\) -%>/m, '\1, extras: {}) -%>', verbose: false
36
+ end
37
+ # `form: form` appears exactly once per file, inside the one render
38
+ # call that must pass extras on.
39
+ gsub_file view_path("_list.html.erb"), "form: form", "form: form, extras: extras"
40
+ gsub_file view_path("_#{row_partial}.html.erb"), "form: form", "form: form, extras: extras"
41
+ end
42
+
43
+ def thread_extras_phlex
44
+ %w[list.rb row.rb row_form.rb].each do |basename|
45
+ path = view_path(basename)
46
+ gsub_file path, /(def initialize\([^)]*)\)/, '\1, extras: {})', verbose: false
47
+ gsub_file path, /^(\s*)@form = form$/, "\\1@form = form\n\\1@extras = extras", verbose: false
48
+ end
49
+ gsub_file view_path("list.rb"), "form: @form", "form: @form, extras: @extras"
50
+ gsub_file view_path("row.rb"), "form: @form", "form: @form, extras: @extras"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -18,7 +18,7 @@ module Hibiki
18
18
 
19
19
  desc "Wires the packaged hibiki client: registers the \"hibiki\" Stimulus " \
20
20
  "controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
21
- "creates the ApplicationCable boilerplate, and pins @rails/actioncable."
21
+ "and creates the ApplicationCable boilerplate."
22
22
 
23
23
  # Exactly the lines `stimulus:manifest:update` emits for the shim,
24
24
  # so a later manifest run converges instead of duplicating.
@@ -38,14 +38,6 @@ module Hibiki
38
38
  "connection.rb.tt" => "app/channels/application_cable/connection.rb"
39
39
  }.freeze
40
40
 
41
- # Same event: the stock importmap has no @rails/actioncable pin
42
- # until the first `rails g channel` adds it. The packaged client
43
- # imports it, so pin it here (the asset ships in actioncable).
44
- PIN = <<~RUBY
45
-
46
- pin "@rails/actioncable", to: "actioncable.esm.js"
47
- RUBY
48
-
49
41
  # The registration lives in a file-backed shim so that
50
42
  # `stimulus:manifest:update` (which rewrites index.js wholesale from
51
43
  # the *_controller.js files) re-derives it instead of dropping it.
@@ -81,11 +73,9 @@ module Hibiki
81
73
  end
82
74
  end
83
75
 
84
- def pin_actioncable
85
- return bundler_note unless importmap?
86
- return say_status :identical, IMPORTMAP, :blue if wired?(IMPORTMAP, "@rails/actioncable")
87
-
88
- append_to_file IMPORTMAP, PIN
76
+ # Bundler apps get the client from npm, not from the engine's asset.
77
+ def note_npm_package
78
+ bundler_note unless importmap?
89
79
  end
90
80
 
91
81
  private