modal_stack 0.1.1 → 0.3.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: 44bb2750129e5c72cb364e6fd217603b35e838cd2b42d8f7d55b93902cb1409c
4
- data.tar.gz: 76aa67df55336d851d2c11ce06d00ab99fd1959bb4542331dba3b129ad3cacad
3
+ metadata.gz: a4ea30705b3d178cd2be1ad1a3b0a7b5487b40490b327d0e727b29e9e644f06f
4
+ data.tar.gz: '049b2eafb22af9cffd3ec3d454a23550dff04273cb23e4b96146a3716a62a383'
5
5
  SHA512:
6
- metadata.gz: 64e9cb4d64f3cabb2d78b3b7f37aa608300008cbab3195fb1cc3f0c57f9b7492907577d0fe4db4415aaebc75a54226ab9114a632ce905864d8de96d8b078766e
7
- data.tar.gz: 2f525716be7b232ada7ce0a3499c859707e6e61e5e4990ae6097b416e876e05f62e56d5d945ac5819aeb355a464811213fb6b9f1bd149ab4b849ff87d75c42ad
6
+ metadata.gz: da14584321b5c28cd93ecbc9dd260865313c5ccd62e17d7846a734c3da47b54e3f0ac20b703542cb14d8cc93fe3611c5de0d7e17c06900ecab92c554ae5c806f
7
+ data.tar.gz: 111ac685cf469d968fb71288cc13822d059e97212a01b098d3766ce0136990ac1b0db4384777a86245fb6923cb225fc62c23543979d79fe18efbc1d2b1e8dbfe
data/CHANGELOG.md CHANGED
@@ -12,6 +12,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
 
13
13
  ### Fixed
14
14
 
15
+ ## [0.3.0] - 2026-05-03
16
+
17
+ ### Added
18
+ - **Prefetch cache + dedupe** in `Orchestrator`: fragments fetched for `push` / `replaceTop` are cached per URL (TTL 30 s) and concurrent prefetches for the same URL share a single in-flight request. Aborts pending requests on `closeAll` and on stack-mismatching `popstate`.
19
+ - **Hover/focus prefetch** on `modal-stack-link`: links warm the cache on `pointerenter` / `focus` so the modal opens with no network latency on click. Opt out with `data-modal-stack-link-prefetch="false"`.
20
+ - **`Orchestrator#prefetch(url)`** public method (and matching `ModalStackController#prefetch`) for warming the cache from app code.
21
+ - **Request-scoped configuration** via `controller.modal_stack_config` (helper-method exposed). Rendering helpers now read configuration once per request instead of once per call.
22
+ - **`:tailwind_v4` CSS preset** that chains on Tailwind v4 `@theme` tokens (`--color-*`, `--radius-*`, `--shadow-*`, `--ease-*`, `--container-*`), so the modal stack inherits the host project's Tailwind theme automatically. Fallbacks match the Tailwind v4 defaults so the preset still renders correctly when `@theme` isn't redefined (or when Tailwind v4 isn't installed).
23
+ - **`:tailwind_v3` CSS preset** — the previous `:tailwind` preset, renamed for clarity. Static values aligned with Tailwind v3 defaults (Tailwind v3 doesn't expose tokens as CSS variables).
24
+ - `Configuration::CSS_PROVIDER_ALIASES` constant for legacy `:tailwind` → `:tailwind_v3` normalization.
25
+ - New tests: prefetch dedupe / cache hit / TTL / abort-on-closeAll / `prefetch` API; CSS-derived leave timeout; `:tailwind` alias normalization; generator default + alias mapping.
26
+
27
+ ### Changed
28
+ - **Animation safety timeout** is now derived from the `--modal-stack-duration` CSS variable on the `<dialog>` (1.5× the declared duration, floored at 300 ms). Falls back to 600 ms when the variable is unset, so existing host CSS keeps working.
29
+ - `BrowserRuntime#fetchFragment` accepts an `{ signal }` option to support `AbortController` cancellation.
30
+ - **CSS preset perf overhaul**: removed animated blur from all four presets — animating `backdrop-filter: blur(2px)` on a fullscreen surface costs ~190 ms/frame on Hi-DPI displays (Retina, 4K), which collapsed modal animations to ~5 fps.
31
+ - The old `--modal-stack-backdrop-blur` variable (radius only) is replaced with `--modal-stack-backdrop-filter` (full filter expression). Default is `none`, which lets Chrome skip the filter pass entirely — `backdrop-filter: blur(0)` still allocated a filter layer, so a `0` radius wasn't actually free. Apps that want a blurred backdrop now opt in with `:root { --modal-stack-backdrop-filter: blur(8px); }` (any filter expression accepted, not just `blur()`).
32
+ - `backdrop-filter` is no longer in the backdrop's `transition` list — when the user opts in, the filter is applied statically when the dialog opens, so the cost is paid once and the per-frame compositor work disappears.
33
+ - `filter: blur(0.5px)` on inert (underlying) layers has been dropped — only `opacity: 0.5` remains. The blur was visually negligible on screen but forced an extra GPU layer per stacked modal.
34
+ - The `filter` property has been removed from the layer's `transition` list since nothing animates it any more.
35
+ - **Backdrop fade now runs in parallel with the layer's leave animation** when closing the last modal (or `closeAll`). Previously the reducer emitted `[unmountTopLayer, …, closeDialog, …]` and the orchestrator awaited each command sequentially — `closeDialog` only fired after the layer's 220 ms `transitionend`, so the backdrop fade-out was perceived *after* the modal was gone (effective close time ≈ 440 ms). The reducer now emits `closeDialog` first; it's synchronous on the runtime, so the dialog's exit transition (opacity, backdrop background, `display`/`overlay` `allow-discrete`) starts immediately and runs alongside the layer's `[data-leaving]` transition. Total close time is now ≈ 220 ms. Fix applied to `pop`, `closeAll`, and the two `handlePopstate` branches that close the dialog.
36
+ - **`config.css_provider` default** is now `:tailwind_v3` (was `:tailwind`). Existing apps with `config.css_provider = :tailwind` keep working — the alias is normalized to `:tailwind_v3` on assignment, with no rendered CSS change.
37
+ - **Generator default `--css-provider`** is now `tailwind_v4` for new installs (was `tailwind`). Run `bin/rails g modal_stack:install --css-provider=tailwind_v3` to opt back in to the v3 preset, or pass the legacy `tailwind` flag — it's normalized to `tailwind_v3` in the generated initializer.
38
+ - The `app/assets/stylesheets/modal_stack/tailwind.css` file has been renamed to `tailwind_v3.css`. Sprockets manifests written by previous installs that contain `//= link modal_stack/tailwind.css` need the line updated to `tailwind_v3.css` (or `tailwind_v4.css`). Importmap / jsbundling installs aren't affected — they don't reference the stylesheet by filename.
39
+ - `INITIALIZER_VERSION` bumped to `0.3.0` because the generator template documents the new providers.
40
+
41
+ ## [0.2.0] - 2026-05-03
42
+
43
+ ### Added
44
+ - **`max_depth` enforcement**: pushes past the cap are now intercepted by the reducer. The new `config.max_depth_strategy` (`:warn` default, `:raise`, `:silent`) controls behaviour. The cap can be disabled with `config.max_depth = nil`.
45
+ - **`ModalStackDepthError`** JS class, thrown by `push()` under the `:raise` strategy. Exported from `state.js`.
46
+ - **Scrollbar-width compensation**: `BrowserRuntime#lockScroll` now sets `--modal-stack-scrollbar-width` on `<html>` so the host CSS can offset fixed elements without layout shift. The CSS variable was already referenced by the Tailwind / Bootstrap / vanilla presets — this completes the wiring.
47
+ - **`modal_stack:error` custom event**: malformed Turbo Stream payloads (bad `data-*`, fetch failures) no longer crash the page. The error is logged and re-emitted as a bubbling `CustomEvent` on the `<dialog>` so apps can surface UI feedback.
48
+ - **JSDoc** on the JS public surface (`state.js`, `runtime.js`, `orchestrator.js`) — including `Layer`, `Stack`, `Command`, and `Transition` typedefs.
49
+ - New tests: max_depth strategies, scrollbar-width compensation, missing-handler error message, default_dismissible/max_depth/max_depth_strategy validation, dialog tag wiring.
50
+
51
+ ### Changed
52
+ - `Configuration#default_dismissible=` now raises `ArgumentError` on non-boolean values (was a silent `attr_accessor`).
53
+ - `Configuration#max_depth=` now coerces strings, accepts `nil`, and rejects non-positive integers.
54
+ - `Orchestrator` constructor accepts `maxDepth` + `maxDepthStrategy`. The Stimulus controller forwards them via `data-modal-stack-max-depth-value` / `data-modal-stack-max-depth-strategy-value`, which `modal_stack_dialog_tag` now emits from the gem's configuration.
55
+ - The "runtime missing handler" error message now lists the runtime's known handlers and the current stack depth.
56
+ - `INITIALIZER_VERSION` bumped to `0.2.0` because the generator template gained `config.max_depth_strategy`.
57
+
15
58
  ## [0.1.1] - 2026-05-02
16
59
 
17
60
  ### Added
data/README.md CHANGED
@@ -9,9 +9,10 @@ browser back/forward support, and drive everything from imperative Turbo
9
9
  Stream actions (`modal_push`, `modal_pop`, `modal_replace`, `modal_close_all`).
10
10
 
11
11
  [![CI](https://github.com/Metalzoid/modal_stack/actions/workflows/main.yml/badge.svg)](https://github.com/Metalzoid/modal_stack/actions)
12
- [![Gem Version](https://badge.fury.io/rb/modal_stack.svg)](https://rubygems.org/gems/modal_stack)
13
- [![Ruby](https://img.shields.io/gem/ruby-version/modal_stack?label=ruby)](https://www.ruby-lang.org/)
14
- [![Rails](https://img.shields.io/gem/dv/modal_stack/railties?label=rails)](https://rubyonrails.org/)
12
+ [![Gem Version](https://img.shields.io/gem/v/modal_stack.svg?label=gem)](https://rubygems.org/gems/modal_stack)
13
+ [![Downloads](https://img.shields.io/gem/dt/modal_stack?label=downloads)](https://rubygems.org/gems/modal_stack)
14
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
15
+ [![Rails](https://img.shields.io/badge/rails-7.2%20%7C%208.0%20%7C%208.1-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org/)
15
16
  [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE.txt)
16
17
 
17
18
  </div>
@@ -140,10 +141,11 @@ $ bin/rails g modal_stack:install --mode=sprockets # legacy apps
140
141
  Pick the CSS preset that matches your stack:
141
142
 
142
143
  ```bash
143
- $ bin/rails g modal_stack:install --css-provider=tailwind # default
144
- $ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
145
- $ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
146
- $ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
144
+ $ bin/rails g modal_stack:install --css-provider=tailwind_v4 # default — chains on Tailwind v4 @theme tokens
145
+ $ bin/rails g modal_stack:install --css-provider=tailwind_v3 # static values aligned with Tailwind v3
146
+ $ bin/rails g modal_stack:install --css-provider=bootstrap # picks up Bootstrap 5 vars
147
+ $ bin/rails g modal_stack:install --css-provider=vanilla # framework-free
148
+ $ bin/rails g modal_stack:install --css-provider=none # bring your own CSS
147
149
  ```
148
150
 
149
151
  ### What the generator does
@@ -204,13 +206,14 @@ Everything lives in `config/initializers/modal_stack.rb`:
204
206
  ```ruby
205
207
  ModalStack.configure do |config|
206
208
  # ─── Presentation ─────────────────────────────────────────────────
207
- config.css_provider = :tailwind # :tailwind | :bootstrap | :vanilla | :none
209
+ config.css_provider = :tailwind_v4 # :tailwind_v3 | :tailwind_v4 | :bootstrap | :vanilla | :none
208
210
  config.default_variant = :modal # :modal | :drawer | :bottom_sheet | :confirmation
209
211
  config.default_size = :md # :sm | :md | :lg | :xl
210
212
  config.default_dismissible = true # ESC + backdrop click close the layer
211
213
 
212
214
  # ─── Behavior ─────────────────────────────────────────────────────
213
- config.max_depth = 5 # hard cap on nested layers
215
+ config.max_depth = 5 # hard cap on nested layers (nil to disable)
216
+ config.max_depth_strategy = :warn # :warn | :raise | :silent
214
217
  config.respect_reduced_motion = true # honor prefers-reduced-motion
215
218
  config.replace_turbo_confirm = false # use modal_stack confirmations for data-turbo-confirm
216
219
 
@@ -393,8 +396,17 @@ The `<dialog>` itself is opened on first push, closed on last pop. Page
393
396
  scroll is locked while any layer is open (`<body data-modal-stack-locked>`)
394
397
  so the page beneath doesn't scroll under your finger on touch devices.
395
398
 
396
- `max_depth` (default `5`) is a hard ceiling pushing past it raises a
397
- runtime error, on the assumption that you have a state-machine bug.
399
+ `max_depth` (default `5`) is a hard ceiling on the number of stacked layers,
400
+ on the assumption that going past it usually means you have a state-machine
401
+ bug. The behaviour is controlled by `config.max_depth_strategy`:
402
+
403
+ | Strategy | Behaviour |
404
+ | ---------- | -------------------------------------------------------------------- |
405
+ | `:warn` | (default) The push is dropped and `console.warn` logs a message. |
406
+ | `:raise` | The JS runtime throws `ModalStackDepthError` (caught by the stream-action error boundary, see below). |
407
+ | `:silent` | The push is dropped without logging. |
408
+
409
+ Set `config.max_depth = nil` to disable the cap entirely.
398
410
 
399
411
  ---
400
412
 
@@ -410,13 +422,14 @@ ModalStack.reset_configuration! # test-fixture helper
410
422
 
411
423
  | Attribute | Type | Default | Description |
412
424
  | ---------------------------- | ------- | ------------------------ | ----------- |
413
- | `css_provider` | Symbol | `:tailwind` | One of `:tailwind`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. Validated. |
425
+ | `css_provider` | Symbol | `:tailwind_v3` | One of `:tailwind_v3`, `:tailwind_v4`, `:bootstrap`, `:vanilla`, `:none`. Determines which stylesheet `modal_stack_stylesheet_link_tag` resolves to. The legacy `:tailwind` is accepted and normalized to `:tailwind_v3`. New installs default to `:tailwind_v4`. Validated. |
414
426
  | `assets_mode` | Symbol | `:auto` | One of `:importmap`, `:jsbundling`, `:sprockets`, `:auto`. Used by the generator. Validated. |
415
427
  | `default_variant` | Symbol | `:modal` | `:modal`, `:drawer`, `:bottom_sheet`, or `:confirmation`. Validated. |
416
428
  | `default_size` | Symbol | `:md` | `:sm`, `:md`, `:lg`, `:xl`. Validated. |
417
429
  | `default_dismissible` | Boolean | `true` | Default for `dismissible:` when omitted. |
418
430
  | `default_classes` | Hash | `{ ... }` | Hash of extra CSS class strings keyed by `:modal_panel`, `:drawer_panel`, `:bottom_sheet_panel`, `:confirmation_panel`. Useful for adding utility classes on top of the chosen preset. |
419
- | `max_depth` | Integer | `5` | Hard cap on stack depth pushing past it raises. |
431
+ | `max_depth` | Integer | `5` | Hard cap on stack depth. Coerced from strings; set to `nil` to disable. Validated. |
432
+ | `max_depth_strategy` | Symbol | `:warn` | One of `:warn`, `:raise`, `:silent`. See [Stack depth & inertness](#stack-depth--inertness). Validated. |
420
433
  | `request_header` | String | `"X-Modal-Stack-Request"` | HTTP header used by the JS runtime to signal stack-originated fetches. Read by `modal_stack_request?`. |
421
434
  | `dialog_id` | String | `"modal-stack-root"` | The id of the singleton `<dialog>`. Override only on name collision. |
422
435
  | `stack_root_data_attribute` | String | `"modal-stack"` | The Stimulus `data-controller` value attached to the `<dialog>`. |
@@ -434,7 +447,7 @@ Injected into `ActionView::Base` by the engine — available in every view.
434
447
  | ------------------------------------------------- | ----------- |
435
448
  | `modal_link_to(name, options, html_options)` | Renders a `link_to` wired to push a layer when clicked. Accepts the modal options (`as:`, `side:`, `size:`, `width:`, `height:`, `dismissible:`) on top of standard `link_to` arguments. Falls back to plain `link_to` for Hotwire Native requests. |
436
449
  | `modal_stack_container(size:, variant:, side:, width:, height:, dismissible:, html: {}) { ... }` | Wraps a panel view with the markup the JS runtime expects. Renders a `<div>` carrying the size/variant/dismissible/dimension data attributes. |
437
- | `modal_stack_stylesheet_link_tag(**options)` | Emits `<link rel="stylesheet">` for the configured preset (`modal_stack/tailwind.css`, etc.). Returns an empty SafeBuffer when `css_provider = :none`. |
450
+ | `modal_stack_stylesheet_link_tag(**options)` | Emits `<link rel="stylesheet">` for the configured preset (`modal_stack/tailwind_v4.css`, etc.). Returns an empty SafeBuffer when `css_provider = :none`. |
438
451
  | `modal_stack_dialog_tag(**html_options)` | Emits the singleton `<dialog id="modal-stack-root" data-controller="modal-stack">`. Drop just before `</body>`. |
439
452
  | `modal_stack_javascript_tag` | Reserved hook for layouts; currently a no-op (JS is loaded via your bundler / importmap). |
440
453
 
@@ -503,11 +516,11 @@ The package exports a small functional core + a browser adapter:
503
516
  import {
504
517
  // pure reducer — no IO, no DOM
505
518
  createStack, push, pop, replaceTop, closeAll, handlePopstate,
506
- snapshot, restore, topLayer, VARIANTS,
519
+ snapshot, restore, topLayer, VARIANTS, ModalStackDepthError,
507
520
 
508
521
  // orchestrator + browser runtime
509
522
  Orchestrator, BrowserRuntime,
510
- FRAGMENT_HEADER, SNAPSHOT_KEY,
523
+ FRAGMENT_HEADER, SNAPSHOT_KEY, SCROLLBAR_WIDTH_VAR,
511
524
  } from "modal_stack"
512
525
 
513
526
  import { install } from "modal_stack/install"
@@ -518,6 +531,39 @@ entry point your `application.js` calls. The reducer is
518
531
  side-effect-free and 100% covered; the browser adapter is the only
519
532
  file that touches `<dialog>`, `history`, `fetch`, and `sessionStorage`.
520
533
 
534
+ The reducer's command type vocabulary (`mountLayer`, `morphTopLayer`,
535
+ `unmountTopLayer`, `unmountAllLayers`, `showDialog`, `closeDialog`,
536
+ `lockScroll`, `unlockScroll`, `inertLayer`, `pushHistory`,
537
+ `replaceHistory`, `historyBack`, `rebuildFromSnapshot`, `persistSnapshot`,
538
+ `clearSnapshot`) forms the contract between `state.js` and any runtime —
539
+ swap in a custom adapter (e.g. for Hotwire Native) by implementing one
540
+ method per command name.
541
+
542
+ #### Custom events
543
+
544
+ The `<dialog>` emits two `CustomEvent`s that bubble to `document`:
545
+
546
+ | Event | `detail` | Fired when |
547
+ | ---------------------- | ------------------------------------------- | ---------- |
548
+ | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
549
+ | `modal_stack:error` | `{ action, error }` | A Turbo Stream action (`modal_push`/`modal_pop`/`modal_replace`/`modal_close_all`) threw or rejected. The page is not crashed; surface UI feedback in the listener. |
550
+
551
+ ```js
552
+ document.addEventListener("modal_stack:error", (event) => {
553
+ const { action, error } = event.detail;
554
+ showFlash(`Modal action ${action} failed: ${error.message}`);
555
+ });
556
+ ```
557
+
558
+ #### Scrollbar-width compensation
559
+
560
+ When the first layer is pushed, `BrowserRuntime#lockScroll` measures
561
+ `window.innerWidth - documentElement.clientWidth` and writes the result
562
+ to `--modal-stack-scrollbar-width` on `<html>`. The shipped CSS presets
563
+ already consume the variable (`padding-right: var(--modal-stack-scrollbar-width, 0)`)
564
+ so fixed elements don't jump rightward on lock. If you maintain custom
565
+ CSS, compose your fixed-position rules against the same variable.
566
+
521
567
  ### Capybara helpers
522
568
 
523
569
  For system specs, opt in by requiring the RSpec entrypoint:
@@ -550,7 +596,7 @@ $ bin/rails g modal_stack:install [flags]
550
596
  | Flag | Type | Default | Values |
551
597
  | --------------------- | ------- | ----------- | ------ |
552
598
  | `--mode` | String | `auto` | `auto`, `importmap`, `jsbundling`, `sprockets` |
553
- | `--css-provider` | String | `tailwind` | `tailwind`, `bootstrap`, `vanilla`, `none` |
599
+ | `--css-provider` | String | `tailwind_v4` | `tailwind_v3`, `tailwind_v4`, `bootstrap`, `vanilla`, `none` (legacy `tailwind` accepted, normalized to `tailwind_v3`) |
554
600
  | `--skip-layout` | Boolean | `false` | When set, doesn't inject the stylesheet/dialog helpers into `application.html.erb` |
555
601
  | `--skip-js` | Boolean | `false` | When set, skips the Importmap pin / package install / Stimulus install wiring |
556
602
  | `--skip-initializer` | Boolean | `false` | When set, doesn't generate `config/initializers/modal_stack.rb` |
@@ -569,17 +615,18 @@ safe.
569
615
 
570
616
  ## 🎨 CSS presets & theming
571
617
 
572
- Three opinionated stylesheets ship with the gem. Pick one with
618
+ Four opinionated stylesheets ship with the gem. Pick one with
573
619
  `config.css_provider`:
574
620
 
575
- | Preset | File | Best for |
576
- | ------------ | ------------------------------------------ | -------- |
577
- | `:tailwind` | `app/assets/stylesheets/modal_stack/tailwind.css` | Tailwind apps — uses Tailwind tokens by default but overridable |
578
- | `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
579
- | `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
580
- | `:none` | | Bring your own CSS |
621
+ | Preset | File | Best for |
622
+ | --------------- | ----------------------------------------------------- | -------- |
623
+ | `:tailwind_v4` | `app/assets/stylesheets/modal_stack/tailwind_v4.css` | Tailwind v4 apps — chains on `@theme` tokens (`--color-*`, `--radius-*`, `--shadow-*`, `--container-*`) so the modal picks up your theme automatically. Falls back to Tailwind defaults when `@theme` isn't redefined. |
624
+ | `:tailwind_v3` | `app/assets/stylesheets/modal_stack/tailwind_v3.css` | Tailwind v3 apps static values aligned with Tailwind v3 defaults (v3 doesn't expose tokens as CSS variables). Legacy `:tailwind` is accepted as an alias. |
625
+ | `:bootstrap` | `app/assets/stylesheets/modal_stack/bootstrap.css` | Picks up Bootstrap 5 CSS variables |
626
+ | `:vanilla` | `app/assets/stylesheets/modal_stack/vanilla.css` | Framework-free, neutral defaults |
627
+ | `:none` | — | Bring your own CSS |
581
628
 
582
- All three presets are driven by the same `--modal-stack-*` CSS variables.
629
+ All presets are driven by the same `--modal-stack-*` CSS variables.
583
630
  Override on `:root` to retheme without touching the gem:
584
631
 
585
632
  ```css
@@ -676,7 +723,7 @@ modal_stack/
676
723
  ├── app/
677
724
  │ ├── assets/
678
725
  │ │ ├── javascripts/modal_stack.js # pre-built importmap bundle (committed)
679
- │ │ └── stylesheets/modal_stack/ # tailwind / bootstrap / vanilla presets
726
+ │ │ └── stylesheets/modal_stack/ # tailwind_v3 / tailwind_v4 / bootstrap / vanilla presets
680
727
  │ ├── javascript/modal_stack/ # ES module sources + bun tests
681
728
  │ │ ├── state.js # pure reducer (100% coverage)
682
729
  │ │ ├── orchestrator.js # state → command translator