Adds a shared modal layer in the same shape as v0.4.0's toast system —
consumers render `studio/modals/host` once, open through
`Alpine.store('modals')`, and compose `studio/modals/blocks/_*` for
the inside-the-card content. Backward compatible; pure additive.
The host partial bundles together:
• body.modal-open scroll-lock CSS
• bfcache + Turbo Drive snapshot cleanup
• Alpine.store('modals') registration — stack API:
open(id, props, opts), close(), closeAll(), isOpen(id), current()
opts.replace flips top-of-stack for flicker-free wizard transitions
• window.StudioModals.holdAtLeast(ms) — { then(cb) } that delays
the callback by the remaining ms so loading states don't flash
past the user when the operation finishes fast (mirrors the
_navSpinnerMinMs pattern in _head.html.erb)
• Host markup — z-[120] backdrop, fade-and-scale transitions,
escape / click-outside, role="dialog" aria-modal
Consumers wire content partials by id through a block:
<%= render "studio/modals/host" do %>
<template x-if="$store.modals.current().id === 'auth'">
<%= render "modals/auth" %>
</template>
<% end %>
Reusable content blocks (`studio/modals/blocks/_*`) — composable
inside any modal content partial:
_success_card — icon (check or any emoji) + title + message +
optional Solana tx explorer link + primary
CTA (href OR dispatch event) + secondary CTA +
self-driven auto-redirect countdown (bar +
"Xs…" text + window.location.href at zero) +
opt-in confetti via window.fireSuccessConfetti
_error_card — emoji icon + title + message (static OR Alpine
expression key) + retry CTA (reload OR event)
_processing_card — spinner (sm/md/lg × primary/success/warning) +
title + message, pairs with holdAtLeast()
_progress_countdown — standalone bar + text, reads from Alpine
expressions so caller drives state
Architecture note in the CHANGELOG: modal CONTENT partials stay in
each consumer app (product-specific flows). Modal HOST and BLOCKS
live in the engine (universal vocabulary). Same split as toasts.
KNOWN LANDMINE captured in the CHANGELOG: a modal content partial
mounted via Alpine's <template x-if> MUST have a single root element.
Sibling <style> blocks or trailing whitespace get silently dropped —
yields an empty modal shell. Bake the style inside the partial's
outer wrapping <div>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>