hibiki_rails 0.14.0 → 0.16.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: 6b163c0d20f8fcca4839e8cfcae98316cae9b8d01bd4f81eece18e02a9ae39be
4
- data.tar.gz: bf61ca3db0cd1ad769a7f5ee931a06362124841e4d62ba0bd55856d892511b85
3
+ metadata.gz: d7bfeee9d4e44c3404ac6582548e0bdb46ae139cfa26eec2c71b7d8b95c40c77
4
+ data.tar.gz: ab0505b42c8450e0a8014f94c380ed2f3e995448a9cf8cfc8ed16f4eb450d524
5
5
  SHA512:
6
- metadata.gz: 317d9a267de7853075cc35f089099f5f5fc89060afa2db3afd298d8b955f5dd441fdd90d1d2780be561280c6a9e514f827901112ede534a6dd82f06cf763e9a3
7
- data.tar.gz: 2623fa07782f9d5264592c4bc3e977cb229967b1810f1b05c121bd16dbbc546335c420fd855751ae0dda71e6a148b648623a010344dbec21283f764f76d9f813
6
+ metadata.gz: 6c032ed168913d5c4cc0a56afdf8b6a58cd89ad61581acfda17fc6e2a8030f0ebe4a81f35e0f4ec58691b79df2e6831be4bfa75f4d850db2ff334d9504a30bbb
7
+ data.tar.gz: 79144e77c309815099be858e605ddad09c6a655ebc2b66f3e20c68fdfe6cf0dceca295bc418304ea6fb0865dfda732a9a4704018029fd8f52a39f21148e15532
data/CHANGELOG.md CHANGED
@@ -4,6 +4,108 @@ 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.16.0 — 2026-10-07
8
+
9
+ ### Added
10
+
11
+ **`props`: DOM properties from the view.** Some state of a control has no
12
+ HTML attribute, so no re-rendered fragment could carry it: a checkbox's
13
+ `indeterminate`, a container's scroll position. A page that needed one
14
+ brought JavaScript of its own. The new helper writes the wanted properties
15
+ beside the element's other attributes, and the client assigns them when the
16
+ island connects and after every render, on both transports.
17
+
18
+ ```erb
19
+ <%= check_box_tag "all", "1", all_selected,
20
+ **on(:select_all, event: :change)
21
+ .deep_merge(props(indeterminate: some_selected)) %>
22
+ <%= tag.div class: "list", **props(scroll_top: 0, key: page) do %>
23
+ ```
24
+
25
+ - `indeterminate:` is assigned after every render, so the server's value wins
26
+ over a click (the browser clears the property when the box is clicked).
27
+ - `scroll_top:` and `scroll_left:` are assigned to a new element, and again
28
+ only when the helper's output changes, so the visitor's own scrolling
29
+ survives unrelated renders. `key:` takes any JSON value and exists to change
30
+ the output: `scroll_top: 0, key: page` scrolls back to the top on each page
31
+ change, though the `0` never changes.
32
+ - The list is an allowlist kept on both sides: an unknown name raises in Ruby
33
+ and is ignored by the client, so a fragment cannot reach `innerHTML` or an
34
+ event handler.
35
+ - `props` returns a `{ data: }` hash like `on`. Two splats of the same key do
36
+ not merge, so combine them with `deep_merge` (or Phlex's `mix`).
37
+ - The element must be inside an island. This is a client change: the scan that
38
+ re-observes `visible` sentinels after a render now assigns properties in the
39
+ same walk.
40
+
41
+ Not covered, by design: properties that already have an attribute (`value`,
42
+ `checked`, `disabled`, `open` on `<details>`) ride the fragment as before.
43
+ Modal dialogs, focus and media playback are method calls, not properties, and
44
+ are left for a later release.
45
+
46
+ ### Fixed
47
+
48
+ - A `visible` sentinel inside a page that a refresh stream
49
+ (`broadcast_refresh_effect`) morphed was not re-observed: the page morph
50
+ runs after the stream's own render returns. The island now also rescans on
51
+ `turbo:render`.
52
+
53
+ ## 0.15.0 — 2026-09-09
54
+
55
+ ### Added
56
+
57
+ **Motion for generated pages, and the `hibiki-rails/motion` module.** A
58
+ re-rendered fragment is merged into the page in place, so a removed element
59
+ was gone before the browser could draw it leaving. The new opt-in module
60
+ holds a render while a marked element's transitions finish: mark an element
61
+ `data-motion` (it needs an id), give it a transition, and `import
62
+ "hibiki-rails/motion"` once beside your controllers. The module writes
63
+ `data-motion-leaving` on each marked element the incoming fragment lacks,
64
+ waits for its transitions, then lets the render run; it brackets an inserted
65
+ marked element with `data-motion-entering` so a clip can be scoped to the
66
+ motion. `data-motion="own"` animates the leave only when the gesture that
67
+ removed the element came from inside it, read from the island's own trip
68
+ records. Renders queue in arrival order; a hidden tab waits for nothing;
69
+ every wait races a one-second ceiling. Both transports hold. This is a
70
+ client change: the four `data-motion*` attributes are public, unlike the
71
+ private `data-hibiki-*` ones. Docs: [Motion].
72
+
73
+ - The scaffold emits it by default under `--css=daisyui` and `tailwind`:
74
+ the inline create card and the row's edit form slide open and shut
75
+ (`hbk-slide` / `hbk-slide-body`), and a row you destroy slides out to the
76
+ left before the list closes the gap (`hbk-slide-x` / `hbk-slide-x-body`,
77
+ marked `own`). The row's display markup gains an id-bearing `contents`
78
+ wrapper, because the merge pairs an anonymous `div` with the edit wrapper
79
+ and rewrites it in place, and `@starting-style` fires only on a real
80
+ insertion; under motion the row's card and card-body are one element, so
81
+ the add-on generators' anchors still hold.
82
+ - `app/assets/stylesheets/hibiki_motion.css`, once per app beside the busy
83
+ sheet and wired the same four ways: slide, slide-x, fade, fly, scale and
84
+ blur as Tailwind `@utility` rules (the utilities layer sits above daisyUI's
85
+ components, and a single utility on the element still wins), with duration
86
+ and easing tuned there. Needs Tailwind 4.1. The generator appends
87
+ `import "hibiki-rails/motion"` to `app/javascript/application.js` once
88
+ and prints a `motion` notice when it cannot.
89
+ - `--skip-motion` on both scaffold commands leaves every trace out.
90
+ `--css=none` never emits it: the transitions are Tailwind utilities, and
91
+ its output is byte-identical to a `--skip-motion` run.
92
+ - Packaging: the engine pins `hibiki-rails/motion`; the npm package exports
93
+ `./motion`. The module imports `"hibiki-rails"` by its bare name, which
94
+ the pin and the package self-reference both resolve.
95
+
96
+ ### Changed
97
+
98
+ - `hibiki.js` gains three seams for the module: an exported `islandFor(element)`,
99
+ the island's `lastControl` (set in `trackControl`, kept past the trip's
100
+ settle), and a `hibiki:before-render` event dispatched from the island root
101
+ before a transmit swap, with `event.detail.render` replaceable the way
102
+ Turbo's `turbo:before-stream-render` is. `received` returns the render's
103
+ result: `undefined` for a synchronous swap, a promise for a held one. An ack
104
+ settles its trip even while a render is held.
105
+ - `CssVariant` gains two tokens, `row_card` and `contents`.
106
+
107
+ [Motion]: https://planetaska.github.io/hibiki/motion/
108
+
7
109
  ## 0.14.0 — 2026-09-07
8
110
 
9
111
  ### Changed
@@ -0,0 +1,164 @@
1
+ // hibiki-rails/motion — enter/leave motion for server-rendered swaps.
2
+ //
3
+ // Opt-in: `import "hibiki-rails/motion"` once, beside the controllers. This
4
+ // is the PUBLIC motion contract (unlike the client's private data-hibiki-*):
5
+ //
6
+ // app-written data-motion the element animates its leave
7
+ // data-motion="own" …only when the gesture that removed
8
+ // it came from inside it — a destroyed
9
+ // row, not one that paged or filtered
10
+ // away. Marked elements need an id: both
11
+ // sets below are keyed by id, as the
12
+ // transports' swaps are.
13
+ // written here data-motion-leaving on a marked element the incoming
14
+ // render lacks, until its transitions end
15
+ // data-motion-entering on a marked element the render inserted,
16
+ // until its first transitions end
17
+ //
18
+ // Addressed to app CSS — Tailwind's `starting:` / `data-motion-leaving:` /
19
+ // `in-data-motion-leaving:` variants, or plain attribute selectors. The
20
+ // enter needs no help: @starting-style is the from-state on a real
21
+ // insertion. The exit does, because a morph drops the node before any CSS
22
+ // could run — so a render that would remove a marked element is HELD until
23
+ // the element's transitions finish. Renders queue in arrival order, one
24
+ // chain per document, so a hold never lets a later render overtake it. Only
25
+ // the outermost marked element of a subtree moves, in either phase.
26
+ //
27
+ // One requestAnimationFrame per wait, because transitions exist only after
28
+ // the next style pass. A hidden document waits for nothing (its frames never
29
+ // come), and every wait races a one-second ceiling, so a tab hidden mid-hold
30
+ // cannot wedge the queue. Nothing here reads layout: a forced style read
31
+ // between a morph's insert and its class sync defeats @starting-style.
32
+ import { islandFor } from "hibiki-rails"
33
+
34
+ const CEILING = 1000
35
+
36
+ // Turbo stream actions that remove content. morph rides replace/update;
37
+ // append/prepend/before/after/refresh remove nothing.
38
+ const REMOVING = new Set(["replace", "update", "remove"])
39
+
40
+ const marked = (root) => [...root.querySelectorAll("[data-motion]")]
41
+ const hasMarks = (root) => root.querySelector("[data-motion]") !== null
42
+ const idsOf = (root) => new Set(marked(root).map((element) => element.id))
43
+
44
+ // `own`: the island's trip records are the honest gesture signal — the
45
+ // control of an in-flight trip, or the last control the island tracked
46
+ // (kept past its trip's settle; a render can land after the ack's grace).
47
+ // No island is no evidence, not a plain mark: an `own` element outside any
48
+ // generic island (a ChannelController subclass over Turbo streams) never
49
+ // animates its leave, since the policy exists to keep rows that paged or
50
+ // filtered away from sliding. A plain mark is the way to always animate.
51
+ const ownsGesture = (island, element) => {
52
+ if (!island) return false
53
+ for (const record of island.busy.values()) {
54
+ if (record.control && element.contains(record.control)) return true
55
+ }
56
+ return Boolean(island.lastControl) && element.contains(island.lastControl)
57
+ }
58
+
59
+ const animates = (element, island) =>
60
+ element.getAttribute("data-motion") !== "own" || ownsGesture(island, element)
61
+
62
+ // Marked elements under `target` (and `target` itself when the action
63
+ // replaces it) whose id the incoming content lacks.
64
+ const leavingFrom = (target, includeSelf, incoming, island) => {
65
+ const candidates =
66
+ includeSelf && target.hasAttribute("data-motion") ? [target, ...marked(target)] : marked(target)
67
+ return candidates.filter(
68
+ (element) => element.id && !incoming.has(element.id) && animates(element, island)
69
+ )
70
+ }
71
+
72
+ const outermost = (elements) =>
73
+ elements.filter((element) => !elements.some((other) => other !== element && other.contains(element)))
74
+
75
+ // Resolves once the element's own transitions have run. Element-only, not
76
+ // {subtree: true}: a spinner inside a leaving row is an infinite animation.
77
+ const settled = async (element) => {
78
+ if (document.visibilityState === "hidden") return
79
+ await new Promise(requestAnimationFrame)
80
+ await Promise.allSettled((element.getAnimations?.() ?? []).map((animation) => animation.finished))
81
+ }
82
+
83
+ const finished = (elements) => {
84
+ let timer
85
+ const ceiling = new Promise((resolve) => {
86
+ timer = setTimeout(resolve, CEILING)
87
+ })
88
+ return Promise.race([Promise.all(elements.map(settled)), ceiling]).finally(() => clearTimeout(timer))
89
+ }
90
+
91
+ // One chain per document. A failed render must not close it.
92
+ let queue = Promise.resolve()
93
+ const enqueue = (job) => (queue = queue.catch(() => {}).then(job))
94
+
95
+ // The hold. `plan` runs when the render's turn comes (earlier renders may
96
+ // have changed the DOM) and returns { leaving, islands }; `render` is what
97
+ // the transport would have run at once.
98
+ const held = (plan, render) =>
99
+ enqueue(async () => {
100
+ const { leaving: candidates, islands } = plan()
101
+ const leaving = outermost(candidates)
102
+ const before = idsOf(document)
103
+ for (const element of leaving) element.setAttribute("data-motion-leaving", "")
104
+ if (leaving.length) await finished(leaving)
105
+ try {
106
+ await render()
107
+ } finally {
108
+ // A render that kept the element after all, and the gesture consumed.
109
+ for (const element of leaving) {
110
+ if (element.isConnected) element.removeAttribute("data-motion-leaving")
111
+ }
112
+ for (const island of islands) island.lastControl = null
113
+ }
114
+ const entering = outermost(marked(document).filter((element) => element.id && !before.has(element.id)))
115
+ for (const element of entering) {
116
+ element.setAttribute("data-motion-entering", "")
117
+ finished([element]).then(() => element.removeAttribute("data-motion-entering"))
118
+ }
119
+ })
120
+
121
+ // Turbo streams. Registered at import, so this wrapper sits INSIDE the
122
+ // islands' own render wrappers (theirs count the paint) and the hold ends
123
+ // before they count it. The gate keeps unmarked pages at two querySelectors;
124
+ // a hold is pending only while a marked element is still in the document,
125
+ // so any render arriving during one sees marks and queues behind it.
126
+ document.addEventListener("turbo:before-stream-render", (event) => {
127
+ const stream = event.detail.newStream
128
+ if (!stream?.templateElement) return
129
+ const content = stream.templateElement.content
130
+ if (!hasMarks(document) && !hasMarks(content)) return
131
+
132
+ const render = event.detail.render
133
+ event.detail.render = (streamElement) =>
134
+ held(() => {
135
+ const plan = { leaving: [], islands: new Set() }
136
+ // A refresh stream has no target: targetElements throws.
137
+ if (stream.action === "refresh") return plan
138
+ const incoming = idsOf(content)
139
+ for (const target of stream.targetElements) {
140
+ const island = islandFor(target)
141
+ if (island) plan.islands.add(island)
142
+ if (REMOVING.has(stream.action)) {
143
+ plan.leaving.push(...leavingFrom(target, stream.action !== "update", incoming, island))
144
+ }
145
+ }
146
+ return plan
147
+ }, () => render(streamElement))
148
+ })
149
+
150
+ // The transmit transport: the client's own swap, by root id. A root id
151
+ // always survives a by-id swap, so only descendants can leave.
152
+ document.addEventListener("hibiki:before-render", (event) => {
153
+ const { island, content, render } = event.detail
154
+ if (!hasMarks(document) && !hasMarks(content)) return
155
+
156
+ event.detail.render = () =>
157
+ held(() => ({
158
+ islands: new Set([island]),
159
+ leaving: [...content.children].flatMap((fragment) => {
160
+ const target = document.getElementById(fragment.id)
161
+ return target ? leavingFrom(target, false, idsOf(fragment), island) : []
162
+ })
163
+ }), render)
164
+ })
@@ -57,6 +57,10 @@
57
57
  // browser does what the markup says
58
58
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
59
59
  // the server's transmit_value message updates every match
60
+ // prop sites data-hibiki-props='{"indeterminate":true}' DOM properties
61
+ // that have no attribute, assigned on connect and after each
62
+ // render; only the names in PROPS below, an optional "key"
63
+ // beside them existing to change the text
60
64
  //
61
65
  // The client writes the protocol's other half at runtime — read-only to
62
66
  // app code, addressed to app CSS:
@@ -71,6 +75,14 @@
71
75
  // and per-button feedback needs no server state. Content is stale during
72
76
  // a round trip, never absent.
73
77
  //
78
+ // Motion is the one PUBLIC data-* contract, and it lives in the opt-in
79
+ // module `hibiki-rails/motion`: an app marks an element `data-motion` (or
80
+ // `data-motion="own"`), the module stamps `data-motion-leaving` /
81
+ // `data-motion-entering` around a swap and holds the render while a
82
+ // leaving element's transitions run. Its header carries the rules; this
83
+ // file only gives it two seams — `islandFor` and the `hibiki:before-render`
84
+ // event the transmit swap dispatches (the same shape Turbo gives streams).
85
+ //
74
86
  // The left side of `->` is a hibiki event name: click, change, input,
75
87
  // and submit are delegated DOM listeners; `visible` is a pseudo-event
76
88
  // backed by an IntersectionObserver (the element entering the viewport).
@@ -131,6 +143,20 @@ let consumer
131
143
  // island pins nothing even if disconnect never ran.
132
144
  const islands = new WeakMap()
133
145
 
146
+ // The generic island CONTAINING element, or undefined. The walk performOn
147
+ // uses, exported for the motion module's `own` policy — which reads the
148
+ // island's trip records, so a ChannelController subclass (not in the map)
149
+ // yields no records: its `own` marks never animate a leave a Turbo stream
150
+ // removes. (The transmit swap's event carries the subclass itself, so
151
+ // `own` works there once the subclass tracks its controls.)
152
+ export function islandFor(element) {
153
+ for (let node = element; node; node = node.parentElement) {
154
+ const island = islands.get(node)
155
+ if (island) return island
156
+ }
157
+ return undefined
158
+ }
159
+
134
160
  // camelCase Stimulus method name → snake_case Ruby channel action.
135
161
  const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
136
162
 
@@ -228,6 +254,7 @@ export class ChannelController extends Controller {
228
254
  this.seq = 0
229
255
  this.renders = 0
230
256
  this.busy = new Map()
257
+ this.lastControl = null
231
258
  this.queued = []
232
259
  this.setState("connecting")
233
260
  }
@@ -348,7 +375,11 @@ export class ChannelController extends Controller {
348
375
  // A depth counter, not a boolean: typing while a page loads is one island
349
376
  // with two actions outstanding. No requestAnimationFrame anywhere — it
350
377
  // never fires in a background tab, which is exactly when a stuck
351
- // indicator goes unnoticed.
378
+ // indicator goes unnoticed. (The motion module is the one place a frame
379
+ // is waited for, and it waits for nothing in a hidden document.) An ack
380
+ // settles a trip even while that module is holding the render: the flag
381
+ // means the server has not answered, and a hold is motion after the
382
+ // answer.
352
383
 
353
384
  beginBusy(seq) {
354
385
  const record = {
@@ -373,6 +404,13 @@ export class ChannelController extends Controller {
373
404
  // has none); controls outside it — the search field — need the removal
374
405
  // in settle().
375
406
  trackControl(seq, control) {
407
+ // The most recent gesture, kept past its trip's settle for the motion
408
+ // module's `own` policy (a render can land after the ack's grace). Set
409
+ // before the record check: a send that died on the socket was still a
410
+ // gesture. A ChannelController subclass wanting `own` calls this itself —
411
+ // which serves the transmit swap only: a Turbo stream reaches the
412
+ // records through islandFor, and that walk lists generic islands alone.
413
+ this.lastControl = control
376
414
  const record = this.busy.get(seq)
377
415
  if (!record) return
378
416
  record.control = control
@@ -453,7 +491,13 @@ export class ChannelController extends Controller {
453
491
  //
454
492
  // { url } — mirror graph state into the address bar (transmit_url).
455
493
  //
456
- // { html } — a fragment: swap it in by its root id.
494
+ // { html } — a fragment: swap it in by its root id. The swap is holdable
495
+ // the way Turbo's is: `hibiki:before-render` bubbles from the island
496
+ // first (detail { island, content, render }), and a listener may replace
497
+ // detail.render with one that waits — the motion module does, to let a
498
+ // leaving element's transition finish. Returns whatever that render
499
+ // returns: undefined when the swap ran synchronously, a promise when it
500
+ // was held, which is what a subclass reading the DOM afterwards awaits.
457
501
  //
458
502
  // Anything else is not ours to interpret. Subclasses may override, but
459
503
  // should call super (or handle `value`) to keep reactive values live.
@@ -469,9 +513,18 @@ export class ChannelController extends Controller {
469
513
  if (!html) return
470
514
  const template = document.createElement("template")
471
515
  template.innerHTML = html
472
- for (const fragment of [...template.content.children]) {
473
- document.getElementById(fragment.id)?.replaceWith(fragment)
516
+ const fragments = [...template.content.children]
517
+ const render = () => {
518
+ for (const fragment of fragments) {
519
+ document.getElementById(fragment.id)?.replaceWith(fragment)
520
+ }
474
521
  }
522
+ const event = new CustomEvent("hibiki:before-render", {
523
+ bubbles: true,
524
+ detail: { island: this, content: template.content, render }
525
+ })
526
+ this.element.dispatchEvent(event)
527
+ return event.detail.render()
475
528
  }
476
529
 
477
530
  // replaceState, never pushState: the URL is a mirror of graph state, not
@@ -533,6 +586,40 @@ export class ChannelController extends Controller {
533
586
  }
534
587
  }
535
588
 
589
+ // The DOM properties a fragment may set (Helpers#props), and when. A
590
+ // property has no attribute, so no swap or morph carries it: the client
591
+ // assigns it after the fact. The table is the allowlist — a fragment must
592
+ // never reach innerHTML or onclick — and a value of the wrong type is
593
+ // skipped. `always` is re-assigned after every render (the browser clears
594
+ // indeterminate on click; the server's value comes back). `changed` is
595
+ // assigned only to a new element or when the attribute's text differs from
596
+ // the text last applied, so the visitor's own scrolling survives unrelated
597
+ // renders; the view's `key:` is how it says "again".
598
+ const PROPS = {
599
+ indeterminate: ["boolean", "always"],
600
+ scrollTop: ["number", "changed"],
601
+ scrollLeft: ["number", "changed"]
602
+ }
603
+ const appliedProps = new WeakMap()
604
+
605
+ function applyProps(element) {
606
+ const text = element.getAttribute("data-hibiki-props")
607
+ let wanted
608
+ try {
609
+ wanted = JSON.parse(text)
610
+ } catch {
611
+ return
612
+ }
613
+ if (!wanted || typeof wanted !== "object") return
614
+ const fresh = appliedProps.get(element) !== text
615
+ for (const [name, [type, policy]] of Object.entries(PROPS)) {
616
+ const value = wanted[name]
617
+ if (typeof value !== type || (type === "number" && !Number.isFinite(value))) continue
618
+ if (policy === "always" || fresh) element[name] = value
619
+ }
620
+ appliedProps.set(element, text)
621
+ }
622
+
536
623
  // The generic controller: adds the data-hibiki-* wire protocol on top of
537
624
  // the base's plumbing.
538
625
  export default class HibikiController extends ChannelController {
@@ -562,6 +649,12 @@ export default class HibikiController extends ChannelController {
562
649
  // rather than from the middle of it.
563
650
  this.prepareTransport()
564
651
 
652
+ // First paint, so before the subscription: a property is right while
653
+ // the island is still connecting, and stays right if it never does.
654
+ for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
655
+ applyProps(site)
656
+ }
657
+
565
658
  // Root-scoped delegation (bound to the island, not document): controls
566
659
  // inside server-replaced fragments keep working with no rebinding.
567
660
  // Set up synchronously so disconnect can always tear them down.
@@ -594,7 +687,7 @@ export default class HibikiController extends ChannelController {
594
687
  }
595
688
  })
596
689
 
597
- // Re-scan at the two points a fragment can be swapped under us, rather
690
+ // Re-scan at the points a fragment can be swapped under us, rather
598
691
  // than blanket-observing the document: a MutationObserver over the page
599
692
  // is a real per-mutation cost paid by every app on it.
600
693
  this.streamRender = (event) => {
@@ -607,14 +700,19 @@ export default class HibikiController extends ChannelController {
607
700
  // — which at worst settles an already-acked trip a few ms early,
608
701
  // never one that has not been acked at all.
609
702
  this.renders++
610
- this.scanSentinels()
703
+ this.scan()
611
704
  }
612
705
  }
613
706
  document.addEventListener("turbo:before-stream-render", this.streamRender)
707
+ // A refresh stream morphs the page after its own render has returned,
708
+ // so the wrapper above scans too early for it; turbo:render follows
709
+ // the morph.
710
+ this.pageRender = () => this.scan()
711
+ document.addEventListener("turbo:render", this.pageRender)
614
712
 
615
713
  await this.openSubscription()
616
714
  if (this.aborted) return
617
- this.scanSentinels()
715
+ this.scan()
618
716
  }
619
717
 
620
718
  disconnect() {
@@ -623,25 +721,40 @@ export default class HibikiController extends ChannelController {
623
721
  this.element.removeEventListener(type, handler)
624
722
  }
625
723
  document.removeEventListener("turbo:before-stream-render", this.streamRender)
724
+ document.removeEventListener("turbo:render", this.pageRender)
626
725
  for (const id of this.pending) clearTimeout(id)
627
726
  this.pending.clear()
628
727
  this.observer.disconnect()
629
728
  super.disconnect()
630
729
  }
631
730
 
632
- // The other swap point: hibiki's own transmit transport.
731
+ // The other swap point: hibiki's own transmit transport. A held swap
732
+ // hands back a promise; the replacement sentinel exists only after it.
633
733
  received(data) {
634
- super.received(data)
635
- if (data.html) this.scanSentinels()
636
- }
637
-
638
- // Observe every `visible->` sentinel this island owns. observe() is a
639
- // no-op for an element already being observed, so re-scanning is cheap
640
- // and cannot double-fire a sentinel that merely stayed put.
641
- scanSentinels() {
642
- for (const control of this.element.querySelectorAll('[data-hibiki-on*="visible->"]')) {
643
- if (control.closest('[data-controller~="hibiki"]') === this.element) {
644
- this.observer.observe(control)
734
+ const swapped = super.received(data)
735
+ if (!data.html) return
736
+ if (swapped?.then) swapped.then(() => this.scan())
737
+ else this.scan()
738
+ }
739
+
740
+ // What a render leaves for the client to finish, in one walk of the
741
+ // island: assign every prop site's properties, and observe every
742
+ // `visible->` sentinel this island owns. observe() is a no-op for an
743
+ // element already being observed, so re-scanning is cheap and cannot
744
+ // double-fire a sentinel that merely stayed put. Props need no ownership
745
+ // check: assigning one twice, from a nested island and its parent, lands
746
+ // the same value.
747
+ scan() {
748
+ const found = this.element.querySelectorAll(
749
+ '[data-hibiki-on*="visible->"], [data-hibiki-props]'
750
+ )
751
+ for (const element of found) {
752
+ if (element.hasAttribute("data-hibiki-props")) applyProps(element)
753
+ if (
754
+ element.dataset.hibikiOn?.includes("visible->") &&
755
+ element.closest('[data-controller~="hibiki"]') === this.element
756
+ ) {
757
+ this.observer.observe(element)
645
758
  }
646
759
  }
647
760
  }
@@ -771,10 +884,8 @@ export { HibikiController }
771
884
  // element (a structural mistake, hence the warn; the offline case stays
772
885
  // quiet because it is expected weather).
773
886
  export function performOn(element, action, payload = {}) {
774
- for (let node = element; node; node = node.parentElement) {
775
- const island = islands.get(node)
776
- if (island) return island.perform(action, payload)
777
- }
887
+ const island = islandFor(element)
888
+ if (island) return island.perform(action, payload)
778
889
  console.warn("hibiki: performOn found no island containing", element)
779
890
  return undefined
780
891
  }
data/config/importmap.rb CHANGED
@@ -6,3 +6,5 @@
6
6
  # — both are part of the Rails 8 default stack. The client takes Action
7
7
  # Cable from turbo-rails' consumer, so no "@rails/actioncable" pin is needed.
8
8
  pin "hibiki-rails", to: "hibiki.js"
9
+ # The opt-in motion module; it imports "hibiki-rails" by the pin above.
10
+ pin "hibiki-rails/motion", to: "hibiki-motion.js"
@@ -57,6 +57,11 @@ module Hibiki
57
57
  card: "card bg-base-100 shadow-sm",
58
58
  card_body: "card-body",
59
59
  card_actions: "card-actions justify-end",
60
+ # The row under motion: card and body as ONE element, so the sliding
61
+ # wrapper above it adds no depth to the row's content. And the
62
+ # display wrapper, a swap unit with no box of its own.
63
+ row_card: "card bg-base-100 shadow-sm card-body",
64
+ contents: "contents",
60
65
  toggle: "toggle",
61
66
  toggle_sm: "toggle toggle-sm",
62
67
  btn: "btn",
@@ -149,6 +154,7 @@ module Hibiki
149
154
  card: "rounded-lg border border-gray-200 bg-white shadow-sm",
150
155
  card_body: "p-6 space-y-2",
151
156
  card_actions: "flex flex-wrap justify-end gap-2 pt-4",
157
+ row_card: "rounded-lg border border-gray-200 bg-white shadow-sm p-6 space-y-2",
152
158
  toggle: CHECKBOX,
153
159
  toggle_sm: CHECKBOX,
154
160
  btn: SECONDARY_BUTTON,
@@ -25,8 +25,11 @@ Example:
25
25
  app/controllers/books_controller.rb
26
26
  app/views/books/*
27
27
  app/assets/stylesheets/hibiki_busy.css
28
+ app/assets/stylesheets/hibiki_motion.css
28
29
  And add to config/routes.rb:
29
30
  resources :books
31
+ And to app/javascript/application.js:
32
+ import "hibiki-rails/motion"
30
33
 
31
34
  Restart the server afterwards if it is running — app/forms and
32
35
  app/queries are new.
@@ -34,3 +37,7 @@ Example:
34
37
  Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
35
38
  ERB templates. The view layer is the only thing it changes; it needs the
36
39
  phlex-rails gem and `bin/rails g phlex:install`.
40
+
41
+ Rows and inline forms slide open and shut. Pass --skip-motion to leave
42
+ that out: no motion stylesheet, no import, no marks in the views.
43
+ --css=none never emits it either.
@@ -46,6 +46,8 @@ module Hibiki
46
46
  desc: "Omit the search box and the LIKE terms behind it"
47
47
  class_option :skip_create, type: :boolean, default: false,
48
48
  desc: "Omit the inline create form — New always navigates"
49
+ class_option :skip_motion, type: :boolean, default: false,
50
+ desc: "Omit the enter/leave motion — no hibiki_motion.css, no marks"
49
51
  class_option :page_size, type: :numeric, default: 20, desc: "Rows per page"
50
52
  class_option :phlex, type: :boolean, default: false,
51
53
  desc: "Emit Phlex components under app/views instead of ERB templates"
@@ -24,11 +24,17 @@ Example:
24
24
  app/controllers/books_controller.rb
25
25
  app/views/books/*
26
26
  app/assets/stylesheets/hibiki_busy.css
27
+ app/assets/stylesheets/hibiki_motion.css
27
28
  And modify:
28
29
  app/models/book.rb
29
30
  app/models/author.rb # each model a belongs_to points at
31
+ app/javascript/application.js # imports hibiki-rails/motion
30
32
  config/routes.rb
31
33
 
32
34
  Pass --phlex to emit Phlex components under app/views/books/*.rb instead of
33
35
  ERB templates. The view layer is the only thing it changes; it needs the
34
36
  phlex-rails gem and `bin/rails g phlex:install`.
37
+
38
+ Rows and inline forms slide open and shut. Pass --skip-motion to leave
39
+ that out: no motion stylesheet, no import, no marks in the views.
40
+ --css=none never emits it either.