hibiki_rails 0.13.0 → 0.15.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: dfd1da2a9d64baab573c47345a55fbe7f985c3e6781f2e0d04879043b0889dcc
4
- data.tar.gz: f012a899f4c6b675b75102f217dc9e37feaa827e4c63097cc1a86de353dce543
3
+ metadata.gz: ce497d3d19ee46dede8bb9973f3a408e8b724f588ee2ebce3a4c0a0e5aa998f1
4
+ data.tar.gz: 1eea58578f6a73f3617a8f717cc1407023d741c1bbd1fb68f534b1fe135a4caa
5
5
  SHA512:
6
- metadata.gz: 46110572a666ccd8f7c44114433259ebb901d50d7625ce7ff3468de7c0a34bbc6038766cf611eb977b1126d54fab8dbc018a55aa0a3aaab91553cf43fdcca048
7
- data.tar.gz: 8b71c3b742e04e93b9e58707dcf7ebd1dda9319a0894108e07a5a560008f24591a43969e3ce1c4572e4c3ad76d0cf4fc025dd9fe3f518141bdf870e58261ab73
6
+ metadata.gz: fd67705cbe7db3a1eb1ddcd5dd914e7f8377f01070d564f3dca40a96f205833ef0a442f3a74bc68a8928d6577461a8bc83427c0e0117c711d3fd5733fa478b9e
7
+ data.tar.gz: eefeee042142381f1e2f1f2dba0d49d2884db32409a029d08215a9479aefce564bc7e03333c23b525ade2dc37fc3111a89ebbabd52fed6adf450bfe19c9f63bf
data/CHANGELOG.md CHANGED
@@ -4,6 +4,85 @@ 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.15.0 — 2026-09-09
8
+
9
+ ### Added
10
+
11
+ **Motion for generated pages, and the `hibiki-rails/motion` module.** A
12
+ re-rendered fragment is merged into the page in place, so a removed element
13
+ was gone before the browser could draw it leaving. The new opt-in module
14
+ holds a render while a marked element's transitions finish: mark an element
15
+ `data-motion` (it needs an id), give it a transition, and `import
16
+ "hibiki-rails/motion"` once beside your controllers. The module writes
17
+ `data-motion-leaving` on each marked element the incoming fragment lacks,
18
+ waits for its transitions, then lets the render run; it brackets an inserted
19
+ marked element with `data-motion-entering` so a clip can be scoped to the
20
+ motion. `data-motion="own"` animates the leave only when the gesture that
21
+ removed the element came from inside it, read from the island's own trip
22
+ records. Renders queue in arrival order; a hidden tab waits for nothing;
23
+ every wait races a one-second ceiling. Both transports hold. This is a
24
+ client change: the four `data-motion*` attributes are public, unlike the
25
+ private `data-hibiki-*` ones. Docs: [Motion].
26
+
27
+ - The scaffold emits it by default under `--css=daisyui` and `tailwind`:
28
+ the inline create card and the row's edit form slide open and shut
29
+ (`hbk-slide` / `hbk-slide-body`), and a row you destroy slides out to the
30
+ left before the list closes the gap (`hbk-slide-x` / `hbk-slide-x-body`,
31
+ marked `own`). The row's display markup gains an id-bearing `contents`
32
+ wrapper, because the merge pairs an anonymous `div` with the edit wrapper
33
+ and rewrites it in place, and `@starting-style` fires only on a real
34
+ insertion; under motion the row's card and card-body are one element, so
35
+ the add-on generators' anchors still hold.
36
+ - `app/assets/stylesheets/hibiki_motion.css`, once per app beside the busy
37
+ sheet and wired the same four ways: slide, slide-x, fade, fly, scale and
38
+ blur as Tailwind `@utility` rules (the utilities layer sits above daisyUI's
39
+ components, and a single utility on the element still wins), with duration
40
+ and easing tuned there. Needs Tailwind 4.1. The generator appends
41
+ `import "hibiki-rails/motion"` to `app/javascript/application.js` once
42
+ and prints a `motion` notice when it cannot.
43
+ - `--skip-motion` on both scaffold commands leaves every trace out.
44
+ `--css=none` never emits it: the transitions are Tailwind utilities, and
45
+ its output is byte-identical to a `--skip-motion` run.
46
+ - Packaging: the engine pins `hibiki-rails/motion`; the npm package exports
47
+ `./motion`. The module imports `"hibiki-rails"` by its bare name, which
48
+ the pin and the package self-reference both resolve.
49
+
50
+ ### Changed
51
+
52
+ - `hibiki.js` gains three seams for the module: an exported `islandFor(element)`,
53
+ the island's `lastControl` (set in `trackControl`, kept past the trip's
54
+ settle), and a `hibiki:before-render` event dispatched from the island root
55
+ before a transmit swap, with `event.detail.render` replaceable the way
56
+ Turbo's `turbo:before-stream-render` is. `received` returns the render's
57
+ result: `undefined` for a synchronous swap, a promise for a held one. An ack
58
+ settles its trip even while a render is held.
59
+ - `CssVariant` gains two tokens, `row_card` and `contents`.
60
+
61
+ [Motion]: https://planetaska.github.io/hibiki/motion/
62
+
63
+ ## 0.14.0 — 2026-09-07
64
+
65
+ ### Changed
66
+
67
+ **The infinite-scroll Load-more control has a degraded path.** Under
68
+ `--infinite-scroll` the scaffold emitted one button that answered both a
69
+ click and the `visible` sentinel event, and gave it no `fallback:` on
70
+ purpose: a sentinel that navigates on a dead socket would make scrolling
71
+ navigate. Without JavaScript the button rendered and did nothing. The control
72
+ is now two elements. The wrapper (`books_load_more`, the same id) is the
73
+ sentinel and carries only `visible`. A link inside it is the click path, with
74
+ `fallback: true` and a real href: the query already reads `?page=N` as a
75
+ growing window, so a degraded click shows every window up to N, and the
76
+ link's fragment lands on the last row the reader had already seen. The JS
77
+ client is unchanged.
78
+
79
+ - Both view layers. The Phlex component uses a bare `a`, so no `LinkTo`
80
+ include; `page_url` is now emitted in both pagination modes.
81
+ - Regenerating an app changes only `_list.html.erb` / `list.rb`. A system
82
+ test that clicked `#books_load_more button` now clicks the `a`.
83
+ - Docs: the fallback contract in [The JS client] gains the rule — never give
84
+ a `visible` control a `fallback:`; split it as the scaffold does.
85
+
7
86
  ## 0.13.0 — 2026-09-07
8
87
 
9
88
  ### Changed
@@ -0,0 +1,160 @@
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
+ const ownsGesture = (island, element) => {
48
+ if (!island) return false
49
+ for (const record of island.busy.values()) {
50
+ if (record.control && element.contains(record.control)) return true
51
+ }
52
+ return Boolean(island.lastControl) && element.contains(island.lastControl)
53
+ }
54
+
55
+ const animates = (element, island) =>
56
+ element.getAttribute("data-motion") !== "own" || ownsGesture(island, element)
57
+
58
+ // Marked elements under `target` (and `target` itself when the action
59
+ // replaces it) whose id the incoming content lacks.
60
+ const leavingFrom = (target, includeSelf, incoming, island) => {
61
+ const candidates =
62
+ includeSelf && target.hasAttribute("data-motion") ? [target, ...marked(target)] : marked(target)
63
+ return candidates.filter(
64
+ (element) => element.id && !incoming.has(element.id) && animates(element, island)
65
+ )
66
+ }
67
+
68
+ const outermost = (elements) =>
69
+ elements.filter((element) => !elements.some((other) => other !== element && other.contains(element)))
70
+
71
+ // Resolves once the element's own transitions have run. Element-only, not
72
+ // {subtree: true}: a spinner inside a leaving row is an infinite animation.
73
+ const settled = async (element) => {
74
+ if (document.visibilityState === "hidden") return
75
+ await new Promise(requestAnimationFrame)
76
+ await Promise.allSettled((element.getAnimations?.() ?? []).map((animation) => animation.finished))
77
+ }
78
+
79
+ const finished = (elements) => {
80
+ let timer
81
+ const ceiling = new Promise((resolve) => {
82
+ timer = setTimeout(resolve, CEILING)
83
+ })
84
+ return Promise.race([Promise.all(elements.map(settled)), ceiling]).finally(() => clearTimeout(timer))
85
+ }
86
+
87
+ // One chain per document. A failed render must not close it.
88
+ let queue = Promise.resolve()
89
+ const enqueue = (job) => (queue = queue.catch(() => {}).then(job))
90
+
91
+ // The hold. `plan` runs when the render's turn comes (earlier renders may
92
+ // have changed the DOM) and returns { leaving, islands }; `render` is what
93
+ // the transport would have run at once.
94
+ const held = (plan, render) =>
95
+ enqueue(async () => {
96
+ const { leaving: candidates, islands } = plan()
97
+ const leaving = outermost(candidates)
98
+ const before = idsOf(document)
99
+ for (const element of leaving) element.setAttribute("data-motion-leaving", "")
100
+ if (leaving.length) await finished(leaving)
101
+ try {
102
+ await render()
103
+ } finally {
104
+ // A render that kept the element after all, and the gesture consumed.
105
+ for (const element of leaving) {
106
+ if (element.isConnected) element.removeAttribute("data-motion-leaving")
107
+ }
108
+ for (const island of islands) island.lastControl = null
109
+ }
110
+ const entering = outermost(marked(document).filter((element) => element.id && !before.has(element.id)))
111
+ for (const element of entering) {
112
+ element.setAttribute("data-motion-entering", "")
113
+ finished([element]).then(() => element.removeAttribute("data-motion-entering"))
114
+ }
115
+ })
116
+
117
+ // Turbo streams. Registered at import, so this wrapper sits INSIDE the
118
+ // islands' own render wrappers (theirs count the paint) and the hold ends
119
+ // before they count it. The gate keeps unmarked pages at two querySelectors;
120
+ // a hold is pending only while a marked element is still in the document,
121
+ // so any render arriving during one sees marks and queues behind it.
122
+ document.addEventListener("turbo:before-stream-render", (event) => {
123
+ const stream = event.detail.newStream
124
+ if (!stream?.templateElement) return
125
+ const content = stream.templateElement.content
126
+ if (!hasMarks(document) && !hasMarks(content)) return
127
+
128
+ const render = event.detail.render
129
+ event.detail.render = (streamElement) =>
130
+ held(() => {
131
+ const plan = { leaving: [], islands: new Set() }
132
+ // A refresh stream has no target: targetElements throws.
133
+ if (stream.action === "refresh") return plan
134
+ const incoming = idsOf(content)
135
+ for (const target of stream.targetElements) {
136
+ const island = islandFor(target)
137
+ if (island) plan.islands.add(island)
138
+ if (REMOVING.has(stream.action)) {
139
+ plan.leaving.push(...leavingFrom(target, stream.action !== "update", incoming, island))
140
+ }
141
+ }
142
+ return plan
143
+ }, () => render(streamElement))
144
+ })
145
+
146
+ // The transmit transport: the client's own swap, by root id. A root id
147
+ // always survives a by-id swap, so only descendants can leave.
148
+ document.addEventListener("hibiki:before-render", (event) => {
149
+ const { island, content, render } = event.detail
150
+ if (!hasMarks(document) && !hasMarks(content)) return
151
+
152
+ event.detail.render = () =>
153
+ held(() => ({
154
+ islands: new Set([island]),
155
+ leaving: [...content.children].flatMap((fragment) => {
156
+ const target = document.getElementById(fragment.id)
157
+ return target ? leavingFrom(target, false, idsOf(fragment), island) : []
158
+ })
159
+ }), render)
160
+ })
@@ -71,6 +71,14 @@
71
71
  // and per-button feedback needs no server state. Content is stale during
72
72
  // a round trip, never absent.
73
73
  //
74
+ // Motion is the one PUBLIC data-* contract, and it lives in the opt-in
75
+ // module `hibiki-rails/motion`: an app marks an element `data-motion` (or
76
+ // `data-motion="own"`), the module stamps `data-motion-leaving` /
77
+ // `data-motion-entering` around a swap and holds the render while a
78
+ // leaving element's transitions run. Its header carries the rules; this
79
+ // file only gives it two seams — `islandFor` and the `hibiki:before-render`
80
+ // event the transmit swap dispatches (the same shape Turbo gives streams).
81
+ //
74
82
  // The left side of `->` is a hibiki event name: click, change, input,
75
83
  // and submit are delegated DOM listeners; `visible` is a pseudo-event
76
84
  // backed by an IntersectionObserver (the element entering the viewport).
@@ -131,6 +139,18 @@ let consumer
131
139
  // island pins nothing even if disconnect never ran.
132
140
  const islands = new WeakMap()
133
141
 
142
+ // The generic island CONTAINING element, or undefined. The walk performOn
143
+ // uses, exported for the motion module's `own` policy — which reads the
144
+ // island's trip records, so a ChannelController subclass (not in the map)
145
+ // gets plain marks only.
146
+ export function islandFor(element) {
147
+ for (let node = element; node; node = node.parentElement) {
148
+ const island = islands.get(node)
149
+ if (island) return island
150
+ }
151
+ return undefined
152
+ }
153
+
134
154
  // camelCase Stimulus method name → snake_case Ruby channel action.
135
155
  const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
136
156
 
@@ -228,6 +248,7 @@ export class ChannelController extends Controller {
228
248
  this.seq = 0
229
249
  this.renders = 0
230
250
  this.busy = new Map()
251
+ this.lastControl = null
231
252
  this.queued = []
232
253
  this.setState("connecting")
233
254
  }
@@ -348,7 +369,11 @@ export class ChannelController extends Controller {
348
369
  // A depth counter, not a boolean: typing while a page loads is one island
349
370
  // with two actions outstanding. No requestAnimationFrame anywhere — it
350
371
  // never fires in a background tab, which is exactly when a stuck
351
- // indicator goes unnoticed.
372
+ // indicator goes unnoticed. (The motion module is the one place a frame
373
+ // is waited for, and it waits for nothing in a hidden document.) An ack
374
+ // settles a trip even while that module is holding the render: the flag
375
+ // means the server has not answered, and a hold is motion after the
376
+ // answer.
352
377
 
353
378
  beginBusy(seq) {
354
379
  const record = {
@@ -373,6 +398,11 @@ export class ChannelController extends Controller {
373
398
  // has none); controls outside it — the search field — need the removal
374
399
  // in settle().
375
400
  trackControl(seq, control) {
401
+ // The most recent gesture, kept past its trip's settle for the motion
402
+ // module's `own` policy (a render can land after the ack's grace). Set
403
+ // before the record check: a send that died on the socket was still a
404
+ // gesture. A ChannelController subclass wanting `own` calls this itself.
405
+ this.lastControl = control
376
406
  const record = this.busy.get(seq)
377
407
  if (!record) return
378
408
  record.control = control
@@ -453,7 +483,13 @@ export class ChannelController extends Controller {
453
483
  //
454
484
  // { url } — mirror graph state into the address bar (transmit_url).
455
485
  //
456
- // { html } — a fragment: swap it in by its root id.
486
+ // { html } — a fragment: swap it in by its root id. The swap is holdable
487
+ // the way Turbo's is: `hibiki:before-render` bubbles from the island
488
+ // first (detail { island, content, render }), and a listener may replace
489
+ // detail.render with one that waits — the motion module does, to let a
490
+ // leaving element's transition finish. Returns whatever that render
491
+ // returns: undefined when the swap ran synchronously, a promise when it
492
+ // was held, which is what a subclass reading the DOM afterwards awaits.
457
493
  //
458
494
  // Anything else is not ours to interpret. Subclasses may override, but
459
495
  // should call super (or handle `value`) to keep reactive values live.
@@ -469,9 +505,18 @@ export class ChannelController extends Controller {
469
505
  if (!html) return
470
506
  const template = document.createElement("template")
471
507
  template.innerHTML = html
472
- for (const fragment of [...template.content.children]) {
473
- document.getElementById(fragment.id)?.replaceWith(fragment)
508
+ const fragments = [...template.content.children]
509
+ const render = () => {
510
+ for (const fragment of fragments) {
511
+ document.getElementById(fragment.id)?.replaceWith(fragment)
512
+ }
474
513
  }
514
+ const event = new CustomEvent("hibiki:before-render", {
515
+ bubbles: true,
516
+ detail: { island: this, content: template.content, render }
517
+ })
518
+ this.element.dispatchEvent(event)
519
+ return event.detail.render()
475
520
  }
476
521
 
477
522
  // replaceState, never pushState: the URL is a mirror of graph state, not
@@ -629,10 +674,13 @@ export default class HibikiController extends ChannelController {
629
674
  super.disconnect()
630
675
  }
631
676
 
632
- // The other swap point: hibiki's own transmit transport.
677
+ // The other swap point: hibiki's own transmit transport. A held swap
678
+ // hands back a promise; the replacement sentinel exists only after it.
633
679
  received(data) {
634
- super.received(data)
635
- if (data.html) this.scanSentinels()
680
+ const swapped = super.received(data)
681
+ if (!data.html) return
682
+ if (swapped?.then) swapped.then(() => this.scanSentinels())
683
+ else this.scanSentinels()
636
684
  }
637
685
 
638
686
  // Observe every `visible->` sentinel this island owns. observe() is a
@@ -771,10 +819,8 @@ export { HibikiController }
771
819
  // element (a structural mistake, hence the warn; the offline case stays
772
820
  // quiet because it is expected weather).
773
821
  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
- }
822
+ const island = islandFor(element)
823
+ if (island) return island.perform(action, payload)
778
824
  console.warn("hibiki: performOn found no island containing", element)
779
825
  return undefined
780
826
  }
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.
@@ -12,6 +12,7 @@ require_relative "../scaffold_phlex_helpers"
12
12
  require_relative "../scaffold_post_install"
13
13
  require_relative "../scaffold_shared_views"
14
14
  require_relative "../scaffold_transport_stylesheet"
15
+ require_relative "../scaffold_motion"
15
16
  require_relative "../scaffold_schema"
16
17
  require_relative "../css_variant"
17
18
 
@@ -39,6 +40,7 @@ module Hibiki
39
40
  include ScaffoldPostInstall
40
41
  include ScaffoldSharedViews
41
42
  include ScaffoldTransportStylesheet
43
+ include ScaffoldMotion
42
44
 
43
45
  TEMPLATE_ROOT = File.expand_path("templates", __dir__)
44
46
 
@@ -59,6 +61,8 @@ module Hibiki
59
61
  desc: "Omit the search box and the LIKE terms behind it"
60
62
  class_option :skip_create, type: :boolean, default: false,
61
63
  desc: "Omit the inline create form — New always navigates"
64
+ class_option :skip_motion, type: :boolean, default: false,
65
+ desc: "Omit the enter/leave motion — no hibiki_motion.css, no marks"
62
66
  class_option :page_size, type: :numeric, default: 20,
63
67
  desc: "Rows per page"
64
68
  class_option :skip_routes, type: :boolean,
@@ -89,14 +93,19 @@ module Hibiki
89
93
  @new_app_dirs = %w[app/forms app/queries app/channels app/models app/views].reject { exists?(it) }
90
94
  end
91
95
 
92
- # One file per app, not per resource, and the only thing this generator
93
- # writes outside app/{channels,models,forms,views,controllers}. It
94
- # carries no model knowledge at all — the rules key on the client's
95
- # attributes — so a second resource finds it already there.
96
+ # One file per app, not per resource (two under motion), and the only
97
+ # thing this generator writes outside app/{channels,models,forms,views,
98
+ # controllers} besides the motion import. They carry no model knowledge
99
+ # at all — the rules key on the client's attributes — so a second
100
+ # resource finds them already there.
96
101
  def create_stylesheet
97
102
  create_transport_stylesheet
98
103
  end
99
104
 
105
+ def wire_motion
106
+ wire_motion_module
107
+ end
108
+
100
109
  def create_channels
101
110
  template "channel.rb.tt", collection_channel_path
102
111
  template "member_channel.rb.tt", member_channel_path
@@ -292,6 +301,10 @@ module Hibiki
292
301
  def css(token) = CssVariant.token(css_variant, token)
293
302
  def css? = css_variant != :none
294
303
 
304
+ # Motion rides the stylesheet: --css=none is a hand-styled app, and its
305
+ # views must stay byte-identical to a --skip-motion run.
306
+ def motion? = css? && !options[:skip_motion]
307
+
295
308
  # nil means "no window": .limit(nil) and .offset(nil) are both relation
296
309
  # no-ops, so one constant replaces template conditionals across five
297
310
  # files.
@@ -48,8 +48,15 @@ class <%= view_class_name(:list) %> < Views::Base
48
48
  <% if inline_create? -%>
49
49
 
50
50
  def create_form
51
+ <% if motion? -%>
52
+ # data-motion: the client holds the card while it slides shut; the slide
53
+ # itself is hbk-slide in hibiki_motion.css.
54
+ div<%= arg_list(%(id: "#{row_dom_id_prefix}_new"), "data: { motion: true }", *hbk_args("hbk-slide", token: :card)) %> do
55
+ div<%= arg_list(*hbk_args("hbk-slide-body", token: :card_body)) %> do
56
+ <% else -%>
51
57
  div<%= arg_list(%(id: "#{row_dom_id_prefix}_new"), *css_args(:card)) %> do
52
58
  div<%= arg_list(*css_args(:card_body)) %> do
59
+ <% end -%>
53
60
  <%= render_call(:row_form, create_form_render_args, indent: 8) %>
54
61
  end
55
62
  end
@@ -61,10 +68,11 @@ class <%= view_class_name(:list) %> < Views::Base
61
68
  pages = <%= query_class_name %>.page_numbers(page: @page, page_count: @page_count)
62
69
  <%= render_call(:pagination, ["page: @page", "page_count: @page_count", "pages: pages", %(anchor: "##{list_dom_id}"), "id: id", "url: page_url"], indent: 4, shared: true) %>
63
70
  end
71
+ <% end -%>
64
72
 
65
- # Real pagination hrefs carrying the current query state — the channel
66
- # re-renders them with url_params fresh, so the fallback URLs stay
67
- # truthful as the live state moves.
73
+ # Real hrefs for the <%= infinite? ? "Load-more link" : "page links" %>, carrying the current query
74
+ # state — the channel re-renders them with url_params fresh, so the
75
+ # fallback URLs stay truthful as the live state moves.
68
76
  def page_url
69
77
  ->(n) do
70
78
  query = @url_params.except(:page)
@@ -72,19 +80,22 @@ class <%= view_class_name(:list) %> < Views::Base
72
80
  <%= index_helper(type: :path) %>(**query)
73
81
  end
74
82
  end
75
- <% end -%>
76
83
  <% if infinite? -%>
77
84
 
78
- # `visible` is a pseudo-event backed by an IntersectionObserver, so this
79
- # button doubles as the infinite-scroll sentinel. The client stamps
80
- # data-hibiki-busy on it while the next page is in flight.
85
+ # `visible` is a pseudo-event backed by an IntersectionObserver: the wrapper
86
+ # is the scroll sentinel, the link inside it is the click path and its href
87
+ # the fallback. A sentinel must never fall back — scrolling into view on a
88
+ # dead socket would navigate.
81
89
  def load_more
82
- div<%= arg_list(%(id: "#{load_more_dom_id}"), *css_args(:load_more_wrap)) %> do
83
- button<%= arg_list(%(type: "button"), *css_args(:btn_wide), "**on(:go_to_page, event: %i[click visible], with: { page: @page + 1 })", indent: 13) %> do
90
+ div<%= arg_list(%(id: "#{load_more_dom_id}"), *css_args(:load_more_wrap), "**on(:go_to_page, event: :visible, with: { page: @page + 1 })", indent: 8) %> do
91
+ # The STRING "false", not the boolean — Phlex omits false-valued attributes.
92
+ a<%= arg_list("href: load_more_href", *css_args(:btn_wide), "data: { turbo: \"false\" }.merge(on(:go_to_page, with: { page: @page + 1 },\n#{' ' * 42}fallback: true)[:data])", indent: 8) %> do
84
93
  plain "Load more"
85
94
  span<%= arg_list(*css_args(:muted_inline)) %> { " · #{@remaining} remaining" }
86
95
  end
87
96
  end
88
97
  end
98
+
99
+ def load_more_href = "#{page_url.(@page + 1)}#<%= row_dom_id_prefix %>_#{@<%= controller_file_name %>.last.id}"
89
100
  <% end -%>
90
101
  end
@@ -19,18 +19,47 @@ class <%= view_class_name(:row) %> < Views::Base
19
19
  def view_template
20
20
  # The root id stays stable across display/edit, so idiomorph pairs the
21
21
  # subtrees instead of rebuilding them (and losing focus).
22
+ <% if motion? -%>
23
+ # data-motion="own": the row slides out when its own Destroy removes it;
24
+ # rows that page or filter away go at once. The wrapper closes the gap
25
+ # while the card inside slides left (hbk-slide-x, hibiki_motion.css).
26
+ div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}"), %(data: { motion: "own" }), *hbk_args("hbk-slide-x")) %> do
27
+ div<%= arg_list(*hbk_args("hbk-slide-x-body", token: :row_card)) %> do
28
+ @editing ? edit_form : display
29
+ end
30
+ end
31
+ <% else -%>
22
32
  div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}"), *css_args(:card)) %> do
23
33
  div<%= arg_list(*css_args(:card_body)) %> do
24
34
  @editing ? edit_form : fields
25
35
  end
26
36
  end
37
+ <% end -%>
27
38
  end
28
39
 
29
40
  private
30
41
 
42
+ <% if motion? -%>
43
+ # The edit and display wrappers carry distinct ids so idiomorph swaps them
44
+ # instead of morphing one into the other — starting: fires only on a real
45
+ # insertion. The client holds the edit wrapper while it slides shut.
46
+ def edit_form
47
+ div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}_edit"), "data: { motion: true }", *hbk_args("hbk-slide")) %> do
48
+ div<%= arg_list(*hbk_args("hbk-slide-body")) %> do
49
+ <%= render_call(:row_form, row_form_render_args, indent: 8) %>
50
+ end
51
+ end
52
+ end
53
+
54
+ # contents: no box of its own, so the card's layout is unchanged.
55
+ def display
56
+ div<%= arg_list(%(id: "#{row_dom_id_prefix}_\#{#{record_ref}.id}_display"), *css_args(:contents)) %> { fields }
57
+ end
58
+ <% else -%>
31
59
  def edit_form
32
60
  <%= render_call(:row_form, row_form_render_args, indent: 4) %>
33
61
  end
62
+ <% end -%>
34
63
 
35
64
  def fields
36
65
  <% schema.columns.each do |column| -%>
@@ -0,0 +1,70 @@
1
+ <%#- ESCAPING: all of this is literal CSS. There is no runtime ERB here at all —
2
+ a .css file is never rendered — and no generation-time branch either: this
3
+ file is not installed under --css=none. -%>
4
+ /* The app's half of the motion contract: the hibiki-rails/motion module
5
+ stamps data-motion-leaving / data-motion-entering on a marked element
6
+ around a swap, and these classes turn that into motion. Only ever READ
7
+ those attributes — never write them.
8
+
9
+ One file per app. Duration and easing are tuned HERE — the views carry the
10
+ class names only. @utility, not a plain class: that puts each one in the
11
+ utilities layer, above daisyUI's components (a .card would otherwise win
12
+ on display and transition), and Tailwind orders multi-property utilities
13
+ first, so a single utility on the element (duration-500) still wins.
14
+ Needs Tailwind >= 4.1 (the in-* variant). */
15
+
16
+ /* Slide open on insertion, shut on removal. The body class goes on the grid
17
+ child: the collapsed height's floor is its padding, so that slides too.
18
+ mb-0 keeps a space-y-* list's margin from jumping at either end. */
19
+ @utility hbk-slide {
20
+ @apply grid grid-rows-[1fr] transition-all duration-300 ease-out
21
+ starting:grid-rows-[0fr] starting:opacity-0 starting:mb-0
22
+ data-motion-leaving:grid-rows-[0fr] data-motion-leaving:opacity-0
23
+ data-motion-leaving:mb-0 data-motion-leaving:ease-in
24
+ data-motion-entering:overflow-hidden data-motion-leaving:overflow-hidden
25
+ motion-reduce:transition-none;
26
+ }
27
+ @utility hbk-slide-body {
28
+ @apply min-h-0 transition-[padding] duration-300 ease-out
29
+ starting:py-0 in-data-motion-leaving:py-0 in-data-motion-leaving:ease-in;
30
+ }
31
+
32
+ /* Slide out sideways, then close the gap — the swipe-to-delete shape, for a
33
+ removal only. Two boxes: the wrapper closes the gap on a delay, and the
34
+ body (the visible card) slides left and fades first. */
35
+ @utility hbk-slide-x {
36
+ @apply grid grid-rows-[1fr] transition-[grid-template-rows,margin] duration-300 delay-200 ease-out
37
+ data-motion-leaving:grid-rows-[0fr] data-motion-leaving:mb-0 data-motion-leaving:overflow-hidden
38
+ motion-reduce:transition-none;
39
+ }
40
+ @utility hbk-slide-x-body {
41
+ @apply min-h-0 transition-[translate,opacity] duration-300 ease-in
42
+ in-data-motion-leaving:-translate-x-full in-data-motion-leaving:opacity-0;
43
+ }
44
+
45
+ /* The rest of the set. Only the slides animate height; these leave the
46
+ neighbours jumping. Parameters are custom properties, overridable on the
47
+ element: class="hbk-fly [--hbk-fly-y:2rem]". */
48
+ @utility hbk-fade {
49
+ @apply transition-opacity duration-300
50
+ starting:opacity-0 data-motion-leaving:opacity-0
51
+ motion-reduce:transition-none;
52
+ }
53
+ @utility hbk-fly {
54
+ @apply [--hbk-fly-x:0px] [--hbk-fly-y:1rem] transition-[translate,opacity] duration-300 ease-out
55
+ starting:opacity-0 starting:translate-x-(--hbk-fly-x) starting:translate-y-(--hbk-fly-y)
56
+ data-motion-leaving:opacity-0 data-motion-leaving:translate-x-(--hbk-fly-x) data-motion-leaving:translate-y-(--hbk-fly-y)
57
+ motion-reduce:transition-none;
58
+ }
59
+ @utility hbk-scale {
60
+ @apply [--hbk-scale:0.95] transition-[scale,opacity] duration-300 ease-out
61
+ starting:opacity-0 starting:scale-(--hbk-scale)
62
+ data-motion-leaving:opacity-0 data-motion-leaving:scale-(--hbk-scale)
63
+ motion-reduce:transition-none;
64
+ }
65
+ @utility hbk-blur {
66
+ @apply [--hbk-blur:4px] transition-[filter,opacity] duration-300
67
+ starting:opacity-0 starting:blur-(--hbk-blur)
68
+ data-motion-leaving:opacity-0 data-motion-leaving:blur-(--hbk-blur)
69
+ motion-reduce:transition-none;
70
+ }
@@ -3,23 +3,28 @@
3
3
  <%%# locals: (<%= list_locals_signature %>) -%>
4
4
  <%%# The island's re-rendered fragment. Its root id is the broadcast target —
5
5
  deliberately not the island root, which carries the stream source. %>
6
- <% unless infinite? -%>
7
- <%%# Real pagination hrefs carrying the current query state — the channel
8
- re-renders them with url_params fresh, so the fallback URLs stay
9
- truthful as the live state moves. %>
6
+ <%%# Real hrefs for the <%= infinite? ? "Load-more link" : "page links" %>, carrying the current query
7
+ state — the channel re-renders them with url_params fresh, so the
8
+ fallback URLs stay truthful as the live state moves. %>
10
9
  <%% page_url = ->(n) do
11
10
  query = url_params.except(:page)
12
11
  query[:page] = n unless n == 1
13
12
  <%= index_helper(type: :path) %>(**query)
14
13
  end %>
15
- <% end -%>
16
14
  <div id="<%= list_dom_id %>"<%= css_attr(:list) %>>
17
15
  <% if inline_create? -%>
18
16
  <%%# The inline create form — only the channel ever renders it (the New
19
17
  link's action sets creating), so the first paint never carries it. %>
20
18
  <%% if creating %>
19
+ <% if motion? -%>
20
+ <%%# data-motion: the client holds the card while it slides shut; the
21
+ slide itself is hbk-slide in hibiki_motion.css. %>
22
+ <div id="<%= row_dom_id_prefix %>_new" data-motion<%= hbk_attr("hbk-slide", token: :card) %>>
23
+ <div<%= hbk_attr("hbk-slide-body", token: :card_body) %>>
24
+ <% else -%>
21
25
  <div id="<%= row_dom_id_prefix %>_new"<%= css_attr(:card) %>>
22
26
  <div<%= css_attr(:card_body) %>>
27
+ <% end -%>
23
28
  <%%= render "<%= partial_path(row_form_partial) %>", <%= wrapped_list(create_form_render_locals, indent: render_indent(partial_path(row_form_partial)) + 4, width: 0).lstrip %> %>
24
29
  </div>
25
30
  </div>
@@ -44,20 +49,25 @@
44
49
  <%% end %>
45
50
 
46
51
  <% if infinite? -%>
47
- <%%# `visible` is a pseudo-event backed by an IntersectionObserver, so this
48
- button doubles as the infinite-scroll sentinel. The client stamps
49
- data-hibiki-busy on it while the next page is in flight. %>
52
+ <%%# `visible` is a pseudo-event backed by an IntersectionObserver: the wrapper
53
+ is the scroll sentinel, the link inside it is the click path and its href
54
+ the fallback. A sentinel must never fall back — scrolling into view on a
55
+ dead socket would navigate. %>
50
56
  <%% if remaining.positive? %>
51
- <div id="<%= load_more_dom_id %>"<%= css_attr(:load_more_wrap) %>>
52
- <%%= tag.button(type: "button",
57
+ <%%= tag.div(id: "<%= load_more_dom_id %>",
53
58
  <% if css? -%>
54
- class: "<%= css(:btn_wide) %>",
59
+ class: "<%= css(:load_more_wrap) %>",
55
60
  <% end -%>
56
- **on(:go_to_page, event: %i[click visible],
57
- with: { page: page + 1 })) do %>
61
+ **on(:go_to_page, event: :visible, with: { page: page + 1 })) do %>
62
+ <%%= link_to "#{page_url.(page + 1)}#<%= row_dom_id_prefix %>_#{<%= controller_file_name %>.last.id}",
63
+ <% if css? -%>
64
+ class: "<%= css(:btn_wide) %>",
65
+ <% end -%>
66
+ data: { turbo: false }.merge(on(:go_to_page, with: { page: page + 1 },
67
+ fallback: true)[:data]) do %>
58
68
  Load more<span<%= css_attr(:muted_inline) %>>&nbsp;·&nbsp;<%%= remaining %> remaining</span>
59
69
  <%% end %>
60
- </div>
70
+ <%% end %>
61
71
  <%% end %>
62
72
  <% else -%>
63
73
  <%%= render "shared/pagination", page: page, page_count: page_count, pages: pages,
@@ -9,11 +9,34 @@
9
9
  default. %>
10
10
  <%%# The root id stays stable across display/edit, so idiomorph pairs the
11
11
  subtrees instead of rebuilding them (and losing focus). %>
12
+ <% if motion? -%>
13
+ <%%# data-motion="own": the row slides out when its own Destroy removes it;
14
+ rows that page or filter away go at once. The wrapper closes the gap
15
+ while the card inside slides left (hbk-slide-x, hibiki_motion.css). %>
16
+ <div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>" data-motion="own"<%= hbk_attr("hbk-slide-x") %>>
17
+ <div<%= hbk_attr("hbk-slide-x-body", token: :row_card) %>>
18
+ <% else -%>
12
19
  <div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>"<%= css_attr(:card) %>>
13
20
  <div<%= css_attr(:card_body) %>>
21
+ <% end -%>
14
22
  <%% if editing %>
23
+ <% if motion? -%>
24
+ <%%# The edit and display wrappers carry distinct ids so idiomorph swaps
25
+ them instead of morphing one into the other — starting: fires only
26
+ on a real insertion. The client holds this one while it slides shut. %>
27
+ <div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>_edit" data-motion<%= hbk_attr("hbk-slide") %>>
28
+ <div<%= hbk_attr("hbk-slide-body") %>>
29
+ <%%= render "<%= partial_path(row_form_partial) %>", <%= wrapped_list(row_form_render_locals, indent: render_indent(partial_path(row_form_partial)) + 4).lstrip %> %>
30
+ </div>
31
+ </div>
32
+ <% else -%>
15
33
  <%%= render "<%= partial_path(row_form_partial) %>", <%= wrapped_list(row_form_render_locals, indent: render_indent(partial_path(row_form_partial))).lstrip %> %>
34
+ <% end -%>
16
35
  <%% else %>
36
+ <% if motion? -%>
37
+ <%%# contents: no box of its own, so the card's layout is unchanged. %>
38
+ <div id="<%= row_dom_id_prefix %>_<%%= <%= singular_name %>.id %>_display"<%= css_attr(:contents) %>>
39
+ <% end -%>
17
40
  <% schema.columns.each do |column| -%>
18
41
  <div>
19
42
  <strong><%= column.human_name %>:</strong>
@@ -53,6 +76,9 @@
53
76
  confirm: "Are you sure?", fallback: true)[:data]) } %>
54
77
  </div>
55
78
  <%% end %>
79
+ <% if motion? -%>
80
+ </div>
81
+ <% end -%>
56
82
  <%% end %>
57
83
  </div>
58
84
  </div>
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ module Generators
6
+ # Getting the motion module onto the page. The views carry the marks and
7
+ # the class names, the stylesheet carries the rules, and this is the
8
+ # third leg: the client module that holds a render while a marked
9
+ # element's transition runs. Without it everything still works — rows
10
+ # pop instead of sliding — which is exactly the kind of silent gap a
11
+ # notice exists for.
12
+ #
13
+ # One import line, in the file both worlds have: importmap apps resolve
14
+ # it through the engine's pin, bundler apps through the npm package.
15
+ module ScaffoldMotion
16
+ APPLICATION_JS = "app/javascript/application.js"
17
+ MOTION_IMPORT = %(import "hibiki-rails/motion"\n)
18
+
19
+ private
20
+
21
+ def wire_motion_module
22
+ return unless motion?
23
+
24
+ @motion_wiring =
25
+ if wired?(APPLICATION_JS, MOTION_IMPORT.strip)
26
+ :already
27
+ elsif exists?(APPLICATION_JS)
28
+ append_to_file APPLICATION_JS, MOTION_IMPORT
29
+ :import
30
+ else
31
+ :manual
32
+ end
33
+ end
34
+
35
+ def motion_notice
36
+ case @motion_wiring
37
+ when :import
38
+ say_status :motion, "#{APPLICATION_JS} now imports hibiki-rails/motion, the client module " \
39
+ "that lets a marked element finish its transition before a render " \
40
+ "removes it", :blue
41
+ when :manual
42
+ say_status :motion, "#{APPLICATION_JS} not found, so nothing will slide. Import the motion " \
43
+ "module from your JavaScript entry point: #{MOTION_IMPORT.strip}", :yellow
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -27,6 +27,7 @@ module Hibiki
27
27
  # These live with the code that chose their branch, like
28
28
  # parent_notices — the outcomes are those modules' vocabulary.
29
29
  stylesheet_notice
30
+ motion_notice
30
31
  stale_erb_views_notice
31
32
  shared_views_notice
32
33
  stale_shared_views_notice
@@ -3,20 +3,44 @@
3
3
  module Hibiki
4
4
  module Rails
5
5
  module Generators
6
- # Emitting the transport stylesheet, and getting it onto the page.
6
+ # Emitting the app-wide stylesheets, and getting them onto the page.
7
7
  #
8
- # The rules key on the client's attributes and know nothing about any
9
- # model, so this is ONE file per app rather than one per resource — which
10
- # is also why every write here is idempotent: scaffolding a second
11
- # resource finds it already present and leaves it, and the wiring line it
12
- # needs is added at most once.
8
+ # Two of them: the transport stylesheet (the client's loading and
9
+ # connection state) and, when motion is on, the motion stylesheet (the
10
+ # classes the views name). Both key on attributes the client stamps and
11
+ # know nothing about any model, so each is ONE file per app rather than
12
+ # one per resource — which is also why every write here is idempotent:
13
+ # scaffolding a second resource finds it already present and leaves it,
14
+ # and the wiring line it needs is added at most once.
13
15
  #
14
- # Getting it onto the page is the part with branches, because "the main
15
- # stylesheet" is not one thing. Three app shapes cover everything the
16
- # generators have been run against, and the fourth branch is a printed
17
- # instruction rather than a guess.
16
+ # Getting a file onto the page is the part with branches, because "the
17
+ # main stylesheet" is not one thing. Three app shapes cover everything
18
+ # the generators have been run against, and the fourth branch is a
19
+ # printed instruction rather than a guess.
18
20
  module ScaffoldTransportStylesheet
19
- STYLESHEET = "app/assets/stylesheets/hibiki_busy.css"
21
+ DIR = "app/assets/stylesheets"
22
+
23
+ # What each file is FOR, in the notice's words: what it does once
24
+ # wired, and what stays broken while it is not. The busy sheet is
25
+ # always installed; the motion sheet only under motion?.
26
+ STYLESHEETS = {
27
+ busy: {
28
+ file: "hibiki_busy.css",
29
+ wired: "It styles the client's loading and connection state, and is shared by every " \
30
+ "generated resource",
31
+ unwired: "the loading and connection state will be invisible"
32
+ },
33
+ motion: {
34
+ file: "hibiki_motion.css",
35
+ wired: "It defines the motion classes the views name (hbk-slide, hbk-slide-x, hbk-fade, " \
36
+ "…); duration and easing are tuned there",
37
+ unwired: "nothing will slide"
38
+ }
39
+ }.freeze
40
+
41
+ # The busy sheet's names, kept as constants: the specs and the docs
42
+ # refer to them.
43
+ STYLESHEET = "#{DIR}/#{STYLESHEETS.dig(:busy, :file)}".freeze
20
44
  LOGICAL_NAME = "hibiki_busy"
21
45
 
22
46
  # The LEADING ./ is not decoration. Tailwind 4's CLI resolves imports
@@ -26,12 +50,13 @@ module Hibiki
26
50
  IMPORT_LINE = %(@import "./hibiki_busy.css";)
27
51
 
28
52
  # The entry stylesheet a bundler compiles, in the order the two
29
- # conventions are checked. cssbundling-rails keeps it beside the other
30
- # stylesheets; tailwindcss-rails gives it its own directory, so the
31
- # import has to climb out of it.
53
+ # conventions are checked, each with the path an import there needs.
54
+ # cssbundling-rails keeps it beside the other stylesheets;
55
+ # tailwindcss-rails gives it its own directory, so the import has to
56
+ # climb out of it.
32
57
  ENTRIES = {
33
- "app/assets/stylesheets/application.tailwind.css" => IMPORT_LINE,
34
- "app/assets/tailwind/application.css" => %(@import "../stylesheets/hibiki_busy.css";)
58
+ "app/assets/stylesheets/application.tailwind.css" => "./",
59
+ "app/assets/tailwind/application.css" => "../stylesheets/"
35
60
  }.freeze
36
61
 
37
62
  LAYOUT = "app/views/layouts/application.html.erb"
@@ -43,8 +68,10 @@ module Hibiki
43
68
 
44
69
  # Anchor for the fallback, and deliberately narrow: it matches the line
45
70
  # a stock layout carries so the new tag lands directly after it, in the
46
- # <head>, where a stylesheet belongs.
47
- SINGLE_LINK = /^.*stylesheet_link_tag\s+["':].*$\n/
71
+ # <head>, where a stylesheet belongs. Never one of OUR tags: Thor's
72
+ # insert_into_file lands after every match, and the second sheet must
73
+ # not land twice.
74
+ SINGLE_LINK = /^.*stylesheet_link_tag\s+(?!"hibiki_)["':].*$\n/
48
75
 
49
76
  LINK_TAG = %( <%= stylesheet_link_tag "hibiki_busy", "data-turbo-track": "reload" %>\n)
50
77
 
@@ -54,39 +81,48 @@ module Hibiki
54
81
  # always opens with its own import, which ours must follow anyway. An
55
82
  # entry someone rewrote without that line just gets the import
56
83
  # appended; assume they will place it themselves. (Order never protects
57
- # the rules from the utilities — being unlayered does, see the
84
+ # the rules from the utilities — being unlayered does, see the busy
58
85
  # stylesheet's own header.)
59
86
  TAILWIND_IMPORT = %(@import "tailwindcss";\n)
60
87
 
61
88
  private
62
89
 
63
90
  def create_transport_stylesheet
64
- template "hibiki_busy.css.tt", STYLESHEET
65
- wire_transport_stylesheet
91
+ @stylesheet_wiring = {}
92
+ install_stylesheet(:busy)
93
+ install_stylesheet(:motion) if motion?
66
94
  end
67
95
 
68
96
  # Records what happened for the post-install notice, which is the only
69
97
  # place the user learns which branch they landed on.
70
- def wire_transport_stylesheet
71
- @stylesheet_wiring =
72
- if (entry = ENTRIES.keys.find { exists?(it) })
73
- import_into_entry(entry)
74
- elsif wired?(LAYOUT, BULK_LINK)
75
- :bulk
76
- else
77
- link_from_layout
78
- end
98
+ def install_stylesheet(key)
99
+ sheet = STYLESHEETS.fetch(key)
100
+ template "#{sheet[:file]}.tt", stylesheet_path(sheet)
101
+ @stylesheet_wiring[key] = wire_stylesheet(sheet)
102
+ end
103
+
104
+ def wire_stylesheet(sheet)
105
+ if (entry = ENTRIES.keys.find { exists?(it) })
106
+ import_into_entry(entry, sheet)
107
+ elsif wired?(LAYOUT, BULK_LINK)
108
+ :bulk
109
+ else
110
+ link_from_layout(sheet)
111
+ end
79
112
  end
80
113
 
81
114
  # The :already probe uses the entry's OWN line, not IMPORT_LINE — the
82
115
  # tailwindcss-rails entry imports "../stylesheets/hibiki_busy.css", and
83
116
  # probing for the "./" spelling there re-appended on every re-run.
84
- def import_into_entry(entry)
85
- line = ENTRIES.fetch(entry)
117
+ # A later sheet lands below the one before it, so the entry reads in
118
+ # install order.
119
+ def import_into_entry(entry, sheet)
120
+ line = import_line(entry, sheet)
86
121
  return :already if wired?(entry, line)
87
122
 
88
- if wired?(entry, TAILWIND_IMPORT)
89
- inject_into_file entry, "#{line}\n", after: TAILWIND_IMPORT
123
+ anchor = [previous_import(entry, sheet), TAILWIND_IMPORT].compact.find { wired?(entry, it) }
124
+ if anchor
125
+ inject_into_file entry, "#{line}\n", after: anchor
90
126
  else
91
127
  append_to_file entry, "#{line}\n"
92
128
  end
@@ -97,27 +133,44 @@ module Hibiki
97
133
  # compiler rewrites url(...) — never @import. So an @import appended to
98
134
  # a plain application.css resolves to an undigested path and 404s
99
135
  # silently, which is why this branch edits the layout instead.
100
- def link_from_layout
101
- return :already if wired?(LAYOUT, LOGICAL_NAME)
136
+ def link_from_layout(sheet)
137
+ return :already if wired?(LAYOUT, logical_name(sheet))
102
138
  return :manual unless exists?(LAYOUT) && wired?(LAYOUT, SINGLE_LINK)
103
139
 
104
- inject_into_file LAYOUT, LINK_TAG, after: SINGLE_LINK
140
+ inject_into_file LAYOUT, link_tag(sheet), after: SINGLE_LINK
105
141
  :layout
106
142
  end
107
143
 
108
- # Which way it reached the page. Two of the three say what was EDITED,
109
- # because a file this generator touched outside app/{channels,models,
110
- # forms,views,controllers} should never be a surprise; :bulk and
111
- # :already edited nothing and say nothing.
144
+ def stylesheet_path(sheet) = "#{DIR}/#{sheet[:file]}"
145
+ def logical_name(sheet) = File.basename(sheet[:file], ".css")
146
+ def import_line(entry, sheet) = %(@import "#{ENTRIES.fetch(entry)}#{sheet[:file]}";)
147
+ def link_tag(sheet) = %( <%= stylesheet_link_tag "#{logical_name(sheet)}", "data-turbo-track": "reload" %>\n)
148
+
149
+ # The import line of the sheet installed before this one, or nil.
150
+ def previous_import(entry, sheet)
151
+ keys = STYLESHEETS.keys
152
+ index = keys.index { STYLESHEETS[it] == sheet }
153
+ return nil unless index&.positive?
154
+
155
+ "#{import_line(entry, STYLESHEETS[keys[index - 1]])}\n"
156
+ end
157
+
158
+ # Which way each sheet reached the page. Two of the four say what was
159
+ # EDITED, because a file this generator touched outside app/{channels,
160
+ # models,forms,views,controllers} should never be a surprise; :bulk
161
+ # and :already edited nothing and say nothing.
112
162
  #
113
163
  # :manual is the one that fails silently when ignored — the stylesheet
114
164
  # is on disk, nothing links it, every indicator is invisible, and
115
165
  # nothing raises.
116
166
  def stylesheet_notice
117
- case @stylesheet_wiring
118
- when :import then say_stylesheet_wired("imported from your entry stylesheet")
119
- when :layout then say_stylesheet_wired("linked from #{LAYOUT}")
120
- when :manual then say_stylesheet_manual
167
+ @stylesheet_wiring.each do |key, wiring|
168
+ sheet = STYLESHEETS.fetch(key)
169
+ case wiring
170
+ when :import then say_stylesheet_wired(sheet, "imported from your entry stylesheet")
171
+ when :layout then say_stylesheet_wired(sheet, "linked from #{LAYOUT}")
172
+ when :manual then say_stylesheet_manual(sheet)
173
+ end
121
174
  end
122
175
 
123
176
  stale_busy_partial_notice
@@ -136,16 +189,16 @@ module Hibiki
136
189
  "asset — nothing renders it now, you can safely delete it", :yellow
137
190
  end
138
191
 
139
- def say_stylesheet_wired(how)
140
- say_status :css, "#{STYLESHEET} was created and #{how}. It styles the client's loading and " \
141
- "connection state, and is shared by every generated resource", :blue
192
+ def say_stylesheet_wired(sheet, how)
193
+ say_status :css, "#{stylesheet_path(sheet)} was created and #{how}. #{sheet[:wired]}", :blue
142
194
  end
143
195
 
144
- def say_stylesheet_manual
145
- say_status :css, "#{STYLESHEET} was created but nothing links it, so the loading and " \
146
- "connection state will be invisible. Add it to your layout —\n " \
147
- "#{LINK_TAG.strip}\n " \
148
- ", or import from your entry stylesheet: #{IMPORT_LINE}", :yellow
196
+ def say_stylesheet_manual(sheet)
197
+ say_status :css, "#{stylesheet_path(sheet)} was created but nothing links it, so " \
198
+ "#{sheet[:unwired]}. Add it to your layout —\n " \
199
+ "#{link_tag(sheet).strip}\n " \
200
+ ", or import from your entry stylesheet: " \
201
+ "#{import_line(ENTRIES.keys.first, sheet)}", :yellow
149
202
  end
150
203
  end
151
204
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.13.0"
5
+ VERSION = "0.15.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -76,6 +76,7 @@ extra_rdoc_files: []
76
76
  files:
77
77
  - CHANGELOG.md
78
78
  - README.md
79
+ - app/assets/javascripts/hibiki-motion.js
79
80
  - app/assets/javascripts/hibiki.js
80
81
  - config/importmap.rb
81
82
  - lib/generators/hibiki/rails/css_variant.rb
@@ -136,6 +137,7 @@ files:
136
137
  - lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt
137
138
  - lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt
138
139
  - lib/generators/hibiki/rails/scaffold_controller/templates/shared/hibiki_busy.css.tt
140
+ - lib/generators/hibiki/rails/scaffold_controller/templates/shared/hibiki_motion.css.tt
139
141
  - lib/generators/hibiki/rails/scaffold_controller/templates/shared/member_channel.rb.tt
140
142
  - lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt
141
143
  - lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt
@@ -152,6 +154,7 @@ files:
152
154
  - lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt
153
155
  - lib/generators/hibiki/rails/scaffold_helpers.rb
154
156
  - lib/generators/hibiki/rails/scaffold_model_injection.rb
157
+ - lib/generators/hibiki/rails/scaffold_motion.rb
155
158
  - lib/generators/hibiki/rails/scaffold_parent_injection.rb
156
159
  - lib/generators/hibiki/rails/scaffold_parent_notices.rb
157
160
  - lib/generators/hibiki/rails/scaffold_phlex_helpers.rb