studio-engine 0.17.0 → 0.19.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +620 -5
  4. data/app/controllers/concerns/studio/magic_link_issuing.rb +39 -0
  5. data/app/controllers/magic_links_controller.rb +5 -14
  6. data/app/controllers/studio/local_emails_controller.rb +1 -1
  7. data/app/controllers/studio/local_reviews_controller.rb +50 -0
  8. data/app/controllers/style_controller.rb +25 -0
  9. data/app/mailers/user_mailer.rb +6 -9
  10. data/app/views/studio/modals/_crop_photo.html.erb +5 -1
  11. data/app/views/studio/modals/_host.html.erb +5 -0
  12. data/app/views/studio/modals/_image_upload.html.erb +16 -5
  13. data/app/views/studio/modals/_saving.html.erb +6 -2
  14. data/app/views/studio/modals/auth/_resend_footer.html.erb +25 -0
  15. data/app/views/studio/modals/blocks/_card_header.html.erb +83 -0
  16. data/app/views/studio/modals/blocks/_cta_redirect.html.erb +73 -0
  17. data/app/views/studio/modals/blocks/_onchain_success.html.erb +52 -0
  18. data/app/views/studio/modals/blocks/_progress_pill.html.erb +23 -0
  19. data/app/views/studio/modals/blocks/_shell.html.erb +35 -0
  20. data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +42 -0
  21. data/app/views/studio/modals/shared/_age_attestation.html.erb +34 -0
  22. data/app/views/studio/modals/shared/_email_field.html.erb +70 -0
  23. data/app/views/studio/modals/templates/_action.html.erb +25 -0
  24. data/app/views/studio/modals/templates/_form.html.erb +25 -0
  25. data/app/views/studio/modals/templates/_status.html.erb +45 -0
  26. data/app/views/studio/modals/templates/_success.html.erb +23 -0
  27. data/app/views/studio/modals/templates/_wizard.html.erb +75 -0
  28. data/app/views/style/_modal_specimen.html.erb +120 -0
  29. data/app/views/style/_modals.html.erb +690 -0
  30. data/app/views/style/_specimen.html.erb +64 -0
  31. data/app/views/style/_tasks.html.erb +112 -0
  32. data/app/views/style/_theme.html.erb +222 -0
  33. data/app/views/style/_tricks.html.erb +323 -0
  34. data/app/views/style/index.html.erb +60 -0
  35. data/app/views/style/modals/_auth.html.erb +252 -0
  36. data/app/views/style/modals/_onchain_tx.html.erb +62 -0
  37. data/app/views/style/modals/_wallet_connect.html.erb +138 -0
  38. data/app/views/style/modals/_wallet_deposit.html.erb +56 -0
  39. data/lib/studio/version.rb +1 -1
  40. data/lib/studio.rb +43 -1
  41. metadata +29 -4
  42. data/app/controllers/design_system_controller.rb +0 -12
  43. data/app/views/design_system/_specimen.html.erb +0 -27
  44. data/app/views/design_system/index.html.erb +0 -232
@@ -0,0 +1,70 @@
1
+ <%#
2
+ Email input for the modal family — a themed <input type="email"> that either
3
+ posts through its own `name` or feeds an enclosing x-model. Ported from
4
+ turf-monster; the live client-side validity decorator (window.emailValidator)
5
+ is intentionally NOT a dependency here so the engine partial is self-contained
6
+ — the server stays the real boundary. A consumer that ships emailValidator can
7
+ pass validator: true to opt back into the right-edge spinner / check / X.
8
+
9
+ Locals (all optional, sensible defaults via local_assigns.fetch):
10
+ name — input name attr (default "email"; pass nil/false to omit,
11
+ e.g. a modal that dispatches its x-model value instead)
12
+ id — input id attr (default: the name)
13
+ value — prefill value (default "")
14
+ placeholder — (default "you@example.com")
15
+ required — (default false)
16
+ autocomplete — (default "email")
17
+ autofocus — (default false)
18
+ input_class — base input classes (default "input-field")
19
+ x_model — Alpine x-model target resolved in an ENCLOSING scope
20
+ x_ref — Alpine x-ref name
21
+ disabled_expr — Alpine :disabled expression (e.g. "props.submitting")
22
+ validator — when true, wraps in x-data="emailValidator()" and paints the
23
+ right-edge validity indicator (consumer must ship the factory)
24
+ %>
25
+ <%
26
+ name = local_assigns.fetch(:name, "email")
27
+ field_id = local_assigns.fetch(:id, name)
28
+ field_value = local_assigns.fetch(:value, "")
29
+ placeholder = local_assigns.fetch(:placeholder, "you@example.com")
30
+ required = local_assigns.fetch(:required, false)
31
+ autocomplete = local_assigns.fetch(:autocomplete, "email")
32
+ autofocus = local_assigns.fetch(:autofocus, false)
33
+ input_class = local_assigns.fetch(:input_class, "input-field")
34
+ x_model = local_assigns.fetch(:x_model, nil)
35
+ x_ref = local_assigns.fetch(:x_ref, nil)
36
+ disabled_expr = local_assigns.fetch(:disabled_expr, nil)
37
+ validator = local_assigns.fetch(:validator, false)
38
+ %>
39
+ <div class="relative"<%= ' x-data="emailValidator()"'.html_safe if validator %>>
40
+ <input type="email" inputmode="email"
41
+ class="<%= input_class %><%= ' pr-10' if validator %>"
42
+ <% if validator %>:class="{ 'email-reject': _rejecting }"<% end %>
43
+ placeholder="<%= placeholder %>"
44
+ autocomplete="<%= autocomplete %>"
45
+ <%= "required" if required %>
46
+ <%= "autofocus" if autofocus %>
47
+ <% if name %>name="<%= name %>"<% end %>
48
+ <% if field_id %>id="<%= field_id %>"<% end %>
49
+ value="<%= field_value %>"
50
+ <% if x_model %>x-model="<%= x_model %>"<% end %>
51
+ <% if x_ref %>x-ref="<%= x_ref %>"<% end %>
52
+ <% if disabled_expr %>:disabled="<%= disabled_expr %>"<% end %>
53
+ <% if validator %>@input="onInput($event)" @change="onInput($event)" @blur="onBlur($event)"<% end %>>
54
+
55
+ <% if validator %>
56
+ <%# Right-edge validity indicator — consumer's emailValidator drives status.
57
+ pointer-events-none so it never eats a click on the input behind it. %>
58
+ <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
59
+ <span x-show="status === 'loading'" x-cloak class="spinner text-secondary" aria-hidden="true"></span>
60
+ <svg x-show="status === 'valid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
61
+ style="color: var(--color-success)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
62
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
63
+ </svg>
64
+ <svg x-show="status === 'invalid'" x-cloak xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
65
+ style="color: var(--color-danger)" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
66
+ <path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z" />
67
+ </svg>
68
+ </div>
69
+ <% end %>
70
+ </div>
@@ -0,0 +1,25 @@
1
+ <%# === Template: ACTION modal ======================================
2
+ One-question dialog: centered icon pill + title + subtitle + primary /
3
+ secondary CTAs. Use for confirmations, alerts, destructive prompts.
4
+ Composes card_header for the top chunk. Ported from turf-monster.
5
+
6
+ Variants: pass icon :error / :external_tab / icon_emoji / spinner OR
7
+ icon_color :primary / :success / :warning to card_header.
8
+
9
+ Local: modal_store — Alpine store name backing close(). Default "modals". %>
10
+ <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
11
+ <div x-data>
12
+ <%= render "studio/modals/blocks/card_header",
13
+ size: :lg,
14
+ icon: :error,
15
+ title: "Action Template",
16
+ subtitle: "A centered confirmation card with a primary + secondary CTA. Best for one-question dialogs and destructive prompts." %>
17
+
18
+ <button @click="$store.<%= modal_store %>.close()" class="btn btn-danger btn-lg w-full">
19
+ Confirm Action
20
+ </button>
21
+ <button @click="$store.<%= modal_store %>.close()"
22
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
23
+ Cancel
24
+ </button>
25
+ </div>
@@ -0,0 +1,25 @@
1
+ <%# === Template: FORM modal ======================================
2
+ Starting point for any modal that captures input — settings, edit forms,
3
+ rename dialogs. Wraps content in the shared shell for a consistent title row
4
+ + close ×. Ported from turf-monster.
5
+
6
+ Local: modal_store — Alpine store name backing close(). Default "modals". %>
7
+ <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
8
+ <div x-data="{ value: '' }">
9
+ <%= render layout: "studio/modals/blocks/shell", locals: { title: "Form Template", modal_store: modal_store } do %>
10
+ <p class="text-sm text-muted mb-4">
11
+ Title row + close × come from <code class="text-xs bg-inset px-1 rounded">studio/modals/blocks/shell</code>.
12
+ Slot in whatever inputs + primary CTA your form needs.
13
+ </p>
14
+
15
+ <form @submit.prevent="$store.<%= modal_store %>.close()" class="space-y-4">
16
+ <textarea x-model="value"
17
+ rows="3"
18
+ placeholder="Type something…"
19
+ class="input-field w-full resize-none"></textarea>
20
+ <button type="submit" class="btn btn-primary btn-lg w-full" :disabled="!value.trim()">
21
+ Save
22
+ </button>
23
+ </form>
24
+ <% end %>
25
+ </div>
@@ -0,0 +1,45 @@
1
+ <%# === Template: STATUS modal ======================================
2
+ Small-title in-flight card for short async work — server save, poll,
3
+ side-effect that finishes in a second or two. card_header default :md keeps
4
+ the title subordinate to the spinner. For end-state celebrations use the
5
+ success template. Ported from turf-monster; the demo cycles loading → error
6
+ at 1.6s so a viewer can step through both states.
7
+
8
+ Local: modal_store — Alpine store name backing close(). Default "modals". %>
9
+ <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
10
+ <div x-data="{
11
+ state: 'loading',
12
+ start() {
13
+ this.state = 'loading';
14
+ var self = this;
15
+ setTimeout(function() { self.state = 'error'; }, 1600);
16
+ }
17
+ }"
18
+ x-init="start()">
19
+ <template x-if="state === 'loading'">
20
+ <div>
21
+ <%= render "studio/modals/blocks/card_header",
22
+ spinner: true,
23
+ title: "Working on it…",
24
+ subtitle: "Status template — small-title in-flight card for short async work." %>
25
+ <button @click="$store.<%= modal_store %>.close()"
26
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
27
+ Cancel
28
+ </button>
29
+ </div>
30
+ </template>
31
+
32
+ <template x-if="state === 'error'">
33
+ <div>
34
+ <%= render "studio/modals/blocks/card_header",
35
+ icon: :error,
36
+ title: "Something went wrong",
37
+ subtitle: "Surface the real error message here. Try Again should re-run the failed op." %>
38
+ <button @click="start()" class="btn btn-primary btn-lg w-full">Try Again</button>
39
+ <button @click="$store.<%= modal_store %>.close()"
40
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
41
+ Cancel
42
+ </button>
43
+ </div>
44
+ </template>
45
+ </div>
@@ -0,0 +1,23 @@
1
+ <%# === Template: SUCCESS modal ======================================
2
+ Large-title celebration for end states — action completed, item created.
3
+ card_header at :lg gives a text-3xl title; the primary CTA pulls the user
4
+ forward. For a success that warrants a Solana tx link / auto-redirect drain,
5
+ use studio/modals/blocks/_onchain_success instead. Ported from turf-monster.
6
+
7
+ Local: modal_store — Alpine store name backing close(). Default "modals". %>
8
+ <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
9
+ <div x-data>
10
+ <%= render "studio/modals/blocks/card_header",
11
+ size: :lg,
12
+ icon_color: 'primary',
13
+ title: "Done!",
14
+ subtitle: "Operation completed. Swap in the real success copy + CTA destination." %>
15
+
16
+ <button @click="$store.<%= modal_store %>.close()" class="btn btn-primary btn-lg w-full">
17
+ Continue
18
+ </button>
19
+ <button @click="$store.<%= modal_store %>.close()"
20
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
21
+ Dismiss
22
+ </button>
23
+ </div>
@@ -0,0 +1,75 @@
1
+ <%# === Template: WIZARD modal ======================================
2
+ Multi-step flow with a segmented progress pill + centered header. Use for
3
+ onboarding, checkout journeys, anything that breaks into 2-4 screens. Ported
4
+ from turf-monster.
5
+
6
+ Visual identity (distinct from form/action/status): centered title above the
7
+ pill; N-segment pill (filled = primary, upcoming = inset+subtle); single
8
+ primary CTA pulling forward; Back as a subtle link, hidden on step 1.
9
+
10
+ Local: modal_store — Alpine store name backing close(). Default "modals". %>
11
+ <% modal_store = local_assigns.fetch(:modal_store, "modals") %>
12
+ <div x-data="{ step: 1, total: 3 }">
13
+ <div class="relative mb-3 -mt-2">
14
+ <h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Wizard Template</h3>
15
+ <button type="button" @click="$store.<%= modal_store %>.close()"
16
+ class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
17
+ aria-label="Close">&times;</button>
18
+ </div>
19
+
20
+ <%# Inline x-for pill — the demo's step counter lives in local x-data, so the
21
+ static-ERB progress_pill block doesn't fit. Real wizards driven by props
22
+ can render studio/modals/blocks/progress_pill directly. %>
23
+ <div class="flex items-center justify-center gap-1.5 mx-auto max-w-[200px] mb-6">
24
+ <template x-for="i in total" :key="i">
25
+ <div class="h-1.5 flex-1 rounded-full transition-colors duration-300"
26
+ :class="step >= i ? 'bg-primary' : 'bg-inset border border-subtle'"></div>
27
+ </template>
28
+ </div>
29
+
30
+ <template x-if="step === 1">
31
+ <div class="text-center space-y-3 min-h-[120px]">
32
+ <h4 class="text-heading font-semibold text-base">Welcome</h4>
33
+ <p class="text-sm text-secondary">
34
+ First step — collect any prerequisites or intro context here. Replace this
35
+ block with whatever your wizard's opening screen needs.
36
+ </p>
37
+ </div>
38
+ </template>
39
+
40
+ <template x-if="step === 2">
41
+ <div class="text-center space-y-3 min-h-[120px]">
42
+ <h4 class="text-heading font-semibold text-base">Configure</h4>
43
+ <p class="text-sm text-secondary">
44
+ Middle step(s) — forms, selections, anything that needs user input. Each step
45
+ gets its own <code class="text-xs bg-inset px-1 rounded">&lt;template x-if&gt;</code> block.
46
+ </p>
47
+ </div>
48
+ </template>
49
+
50
+ <template x-if="step === 3">
51
+ <div class="text-center space-y-3 min-h-[120px]">
52
+ <h4 class="text-heading font-semibold text-base">Confirm</h4>
53
+ <p class="text-sm text-secondary">
54
+ Final step — show the summary + primary commit action. The Finish button
55
+ below typically calls your real submit handler then closes the modal.
56
+ </p>
57
+ </div>
58
+ </template>
59
+
60
+ <button type="button"
61
+ @click="step < total ? step++ : $store.<%= modal_store %>.close()"
62
+ class="btn btn-primary btn-lg w-full mt-2"
63
+ x-text="step === total ? 'Finish' : 'Next'"></button>
64
+ <button type="button"
65
+ x-show="step > 1"
66
+ @click="step--"
67
+ class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
68
+ &larr; Back
69
+ </button>
70
+ <button type="button"
71
+ @click="$store.<%= modal_store %>.close()"
72
+ class="block mx-auto mt-2 text-xs text-muted hover:text-heading transition">
73
+ Close
74
+ </button>
75
+ </div>
@@ -0,0 +1,120 @@
1
+ <%# Modal specimen card for the living style guide (admin/style) Modals section.
2
+
3
+ Unlike the generic style/_specimen (Tricks), a modal specimen is INTERACTIVE:
4
+
5
+ - the WHOLE card is the trigger (role=button, Enter/Space) — click it to
6
+ open the real modal with the current toggle config (item 3).
7
+ - the header carries a small Copy that yields a CONVERSATIONAL reference
8
+ an agent can act on (not a raw JS blob) — the operator references this
9
+ page when chatting (item 1).
10
+ - an optional row of option toggles lets the user configure the modal
11
+ before opening; their clicks/keys do NOT bubble to the card open (item 2).
12
+ - an optional active-card GLOW (.studio-team-glow) that reactively lights
13
+ when this card represents the currently-open modal + step, and slides to
14
+ the next card as the step machine advances (items 5/6). The glow rides an
15
+ un-clipped wrapper (the card itself is overflow-hidden), mirroring TM's
16
+ holo-wrap / holo-card split, and fades via --studio-team-glow-opacity so
17
+ it transitions smoothly between cards.
18
+
19
+ Locals:
20
+ label (String, required) - the specimen name (header, uppercase).
21
+ reference (String, required) - the conversational copy text.
22
+ open_expr (String, required) - Alpine expression run to open the modal.
23
+ May read `opts.*` from card_data (the toggle state).
24
+ card_data (String, optional) - INNER x-data (no braces), merged after
25
+ copiedRef. E.g. "opts: { magicLink: true, google: true }".
26
+ glow_when (String, optional) - Alpine boolean expr; truthy => active glow.
27
+ toggles (Array<Hash{model:,label:}>, optional) - option checkboxes bound
28
+ into the card x-data (e.g. { model: 'opts.google', label: 'Google' }).
29
+ disabled (Boolean, optional) - capability off: greyed + badged.
30
+ openable (Boolean, optional) - with disabled, keep it clickable (preview).
31
+ disabled_label (String, optional) - the flag text.
32
+ Block: the mini-mock stage (a not-to-scale visual representation). %>
33
+ <%
34
+ label = local_assigns.fetch(:label)
35
+ # Developer-authored reference sentence, rendered as hidden text content (the
36
+ # Copy button reads textContent). html_safe keeps its quotes literal in the
37
+ # markup; it holds no HTML-special chars.
38
+ reference = local_assigns.fetch(:reference).to_s.html_safe
39
+ # open_expr / glow_when are developer-authored Alpine expressions (not user
40
+ # input); mark html_safe so their single quotes render literally into the
41
+ # attribute rather than as &#39; (cleaner HTML; the browser decodes either way).
42
+ open_expr = local_assigns.fetch(:open_expr).to_s.html_safe
43
+ card_data = local_assigns.fetch(:card_data, "").to_s.strip
44
+ glow_when = local_assigns[:glow_when]&.to_s&.html_safe
45
+ toggles = local_assigns[:toggles] || []
46
+ disabled = local_assigns[:disabled]
47
+ openable = local_assigns[:openable]
48
+ disabled_label = local_assigns[:disabled_label].presence || "disabled on this app"
49
+ clickable = !(disabled && !openable)
50
+ inner_cls = openable ? "opacity-60 grayscale" : "opacity-40 grayscale select-none pointer-events-none"
51
+ card_x_data = "{ copiedRef: false#{card_data.present? ? ", #{card_data}" : ''} }"
52
+ %>
53
+ <%# Wrapper — carries the active-card glow (un-clipped) + the shared x-data. %>
54
+ <%# h-full so the card stretches to its grid row's tallest (per-row equal
55
+ height via the grid's default align-items: stretch). %>
56
+ <div class="relative h-full<%= " studio-team-glow rounded-xl" if glow_when %>"
57
+ x-data="<%= card_x_data %>"
58
+ <% if glow_when %>:style="{ '--studio-team-glow-opacity': (<%= glow_when %>) ? '0.95' : '0' }"<% end %>>
59
+ <article
60
+ class="card overflow-hidden flex flex-col h-full<%= " opacity-95" if disabled %><%= " cursor-pointer" if clickable %>"
61
+ <%= 'aria-disabled="true"'.html_safe if disabled %>
62
+ <% if clickable %>
63
+ role="button" tabindex="0"
64
+ @click="<%= open_expr %>"
65
+ @keydown.enter.prevent="<%= open_expr %>"
66
+ @keydown.space.prevent="<%= open_expr %>"
67
+ aria-label="Open the <%= label %> modal"
68
+ <% end %>>
69
+
70
+ <%# Header — the centered TITLE is itself the copy control (a button + a small
71
+ clipboard icon that flips to a check). @click.stop + @keydown.stop keep the
72
+ copy from bubbling to the card's open handler: clicking the title copies
73
+ and does NOT open; clicking anywhere else on the card still opens. The
74
+ button activates on Enter/Space natively (firing @click.stop = copy). %>
75
+ <div class="flex items-center justify-center px-3 pt-3">
76
+ <button type="button"
77
+ class="inline-flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-muted hover:text-heading transition"
78
+ title="Copy an agent-ready reference to this modal"
79
+ aria-label="Copy an agent-ready reference to this modal"
80
+ @click.stop="navigator.clipboard?.writeText($refs.ref.textContent.trim()); copiedRef = true; setTimeout(() => copiedRef = false, 1400)"
81
+ @keydown.stop>
82
+ <span><%= label %></span>
83
+ <svg x-show="!copiedRef" class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24" aria-hidden="true">
84
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
85
+ </svg>
86
+ <svg x-show="copiedRef" x-cloak class="w-3 h-3 shrink-0" style="color: var(--color-success)" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
87
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
88
+ </svg>
89
+ <span class="sr-only" x-text="copiedRef ? 'Copied' : ''"></span>
90
+ </button>
91
+ </div>
92
+ <span x-ref="ref" class="hidden"><%= reference %></span>
93
+
94
+ <%# Stage — the not-to-scale mini-mock. %>
95
+ <div class="relative flex flex-1 flex-wrap items-center justify-center gap-4 p-6 min-h-32">
96
+ <% if disabled %>
97
+ <span class="absolute top-2 right-2 badge z-10" style="color: var(--color-warning); border-color: var(--color-warning)"><%= disabled_label %></span>
98
+ <div class="flex flex-wrap items-center justify-center gap-4 <%= inner_cls %>"><%= yield %></div>
99
+ <% else %>
100
+ <%= yield %>
101
+ <% end %>
102
+ <% if clickable %>
103
+ <span class="pointer-events-none absolute bottom-2 right-2 text-2xs text-secondary opacity-70">Open &nearr;</span>
104
+ <% end %>
105
+ </div>
106
+
107
+ <%# Toggles — configure the modal before opening (clicks/keys stay local). %>
108
+ <% if toggles.any? %>
109
+ <div class="border-t border-subtle px-3 py-2.5 flex flex-wrap gap-x-4 gap-y-1.5"
110
+ @click.stop @keydown.stop>
111
+ <% toggles.each do |tg| %>
112
+ <label class="flex items-center gap-1.5 text-2xs text-secondary cursor-pointer select-none">
113
+ <input type="checkbox" x-model="<%= tg[:model] %>" class="w-3.5 h-3.5 rounded accent-primary cursor-pointer">
114
+ <span><%= tg[:label] %></span>
115
+ </label>
116
+ <% end %>
117
+ </div>
118
+ <% end %>
119
+ </article>
120
+ </div>