studio-engine 0.21.0 → 0.23.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 +4 -4
- data/CHANGELOG.md +147 -0
- data/app/assets/javascripts/studio/canvas_confetti.js +17 -0
- data/app/assets/javascripts/studio/sortable.js +11 -0
- data/app/assets/javascripts/studio/studio_confetti.js +93 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +47 -0
- data/app/controllers/concerns/studio/board/reorderable.rb +97 -0
- data/app/models/concerns/studio/board/rankable.rb +84 -0
- data/app/views/components/_badge.html.erb +4 -2
- data/app/views/layouts/studio/_head.html.erb +13 -1
- data/app/views/studio/_board_assets.html.erb +341 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +174 -0
- data/app/views/studio/board/_board.html.erb +151 -0
- data/app/views/studio/board/_card_shell.html.erb +47 -0
- data/app/views/studio/board/_column.html.erb +80 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +46 -0
- data/app/views/studio/modals/blocks/_leveling_activity.html.erb +159 -0
- data/app/views/style/_modals.html.erb +141 -0
- data/app/views/style/_tasks.html.erb +82 -11
- data/app/views/style/_tricks.html.erb +46 -0
- data/app/views/style/board/_demo_card.html.erb +22 -0
- data/lib/studio/engine.rb +3 -0
- data/lib/studio/version.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studio/board — the shared kanban / depth-chart board shell. An ADDITIVE engine
|
|
3
|
+
primitive (Phase D): the SortableJS drag-reorder, optimistic cross-column move →
|
|
4
|
+
revert → toast, and count/live wiring that the three near-identical McRitchie
|
|
5
|
+
Studio boards (tasks / news / content) and the depth chart each hand-rolled,
|
|
6
|
+
lifted into one place. Renders a horizontal row of columns, each a drop zone the
|
|
7
|
+
vendored SortableJS + window.studioBoard factory (studio/_board_assets, rendered
|
|
8
|
+
once at layout level — a <script> inside this component template would never run)
|
|
9
|
+
wires.
|
|
10
|
+
|
|
11
|
+
UI ONLY and NEUTRAL: no model, no routes, no domain. The app supplies its columns,
|
|
12
|
+
its card partial, and its endpoints. Custom behavior attaches by LISTENING to the
|
|
13
|
+
DOM events the factory dispatches on window — `studio:board-moved`,
|
|
14
|
+
`studio:board-reordered`, `studio:card-added` (detail `{ record, from, to }`) —
|
|
15
|
+
never by patching the factory. Heavier per-app animation defers OPAQUELY to a
|
|
16
|
+
`fx:` module (window[<name>]) and to `on_move_hook:` / `on_drop_hook:` fns.
|
|
17
|
+
|
|
18
|
+
The identity contract every card + zone must satisfy is emitted by
|
|
19
|
+
studio/board/_column (the `.kanban-dropzone`) and studio/board/_card_shell (the
|
|
20
|
+
`.kanban-card`) — an app may hand-roll a bespoke card as long as it matches.
|
|
21
|
+
|
|
22
|
+
locals:
|
|
23
|
+
columns: (req) [{ key:, label:, cards:, count:, badge_class:,
|
|
24
|
+
collapsible:, hidden:, header_extra: }]. `cards` are records
|
|
25
|
+
passed to `card_partial`; `count` defaults to cards.size.
|
|
26
|
+
card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
|
|
27
|
+
card_as: local the record binds to in card_partial. Default :card.
|
|
28
|
+
card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
|
|
29
|
+
reorder_url: POST endpoint the drag-reorder saves the new order to.
|
|
30
|
+
reorder_payload: :slugs | :ids — the key the ordered id list POSTs under.
|
|
31
|
+
Default :slugs.
|
|
32
|
+
move_url: PATCH template for a cross-column move, ":id" → the card's id
|
|
33
|
+
(e.g. "/tasks/:id.json"). nil disallows moves (reorder only).
|
|
34
|
+
move_param: { resource:, attr: } → PATCH body { resource => { attr => zone } }.
|
|
35
|
+
nil disallows moves.
|
|
36
|
+
group: SortableJS group — a shared string allows cross-column drags;
|
|
37
|
+
false keeps drags within a zone (the depth-chart shape).
|
|
38
|
+
Default "studio-board".
|
|
39
|
+
id_attr: card id data-attr + dom-id suffix. Default "slug".
|
|
40
|
+
zone_attr: column/zone data-attr. Default "stage".
|
|
41
|
+
handle: drag-handle selector (nil = whole card draggable).
|
|
42
|
+
filter: SortableJS filter (undraggable els). Default ".kanban-empty".
|
|
43
|
+
zone_selector: the drop-zone selector. Default ".kanban-dropzone".
|
|
44
|
+
draggable: the card selector. Default ".kanban-card".
|
|
45
|
+
empty_selector: the empty-state selector (revert anchor + count toggle).
|
|
46
|
+
Default ".kanban-empty"; nil for a board with no empty state.
|
|
47
|
+
live_channel: Turbo Stream channel for live updates (nil = static board).
|
|
48
|
+
optimistic: revert + red-ring flash a failed move. Default true.
|
|
49
|
+
toasts: show the toast host (false = window.alert on error). Default true.
|
|
50
|
+
empty_label: empty-zone text. Default "Drop here".
|
|
51
|
+
header_slot: raw HTML rendered above the columns (pass html_safe content).
|
|
52
|
+
above_board_slot: raw HTML rendered between the header and the columns.
|
|
53
|
+
fx: window[<name>] module the factory defers add/exit anims to.
|
|
54
|
+
on_move_hook: window[<name>] opaque fn, called with { record, from, to }.
|
|
55
|
+
on_drop_hook: window[<name>] opaque fn, called with { ids, zone }.
|
|
56
|
+
demo: style-guide preview — drag works locally, no network POST.
|
|
57
|
+
%>
|
|
58
|
+
<%
|
|
59
|
+
columns = local_assigns.fetch(:columns)
|
|
60
|
+
card_partial = local_assigns.fetch(:card_partial)
|
|
61
|
+
card_as = local_assigns.fetch(:card_as, :card)
|
|
62
|
+
card_locals = local_assigns.fetch(:card_locals, nil)
|
|
63
|
+
reorder_url = local_assigns.fetch(:reorder_url, "")
|
|
64
|
+
reorder_payload = local_assigns.fetch(:reorder_payload, :slugs).to_s
|
|
65
|
+
move_url = local_assigns.fetch(:move_url, nil)
|
|
66
|
+
move_param = local_assigns.fetch(:move_param, nil)
|
|
67
|
+
group = local_assigns.fetch(:group, "studio-board")
|
|
68
|
+
id_attr = local_assigns.fetch(:id_attr, "slug").to_s
|
|
69
|
+
zone_attr = local_assigns.fetch(:zone_attr, "stage").to_s
|
|
70
|
+
handle = local_assigns.fetch(:handle, nil)
|
|
71
|
+
sort_filter = local_assigns.fetch(:filter, ".kanban-empty")
|
|
72
|
+
zone_selector = local_assigns.fetch(:zone_selector, ".kanban-dropzone")
|
|
73
|
+
draggable_sel = local_assigns.fetch(:draggable, ".kanban-card")
|
|
74
|
+
empty_selector = local_assigns.fetch(:empty_selector, ".kanban-empty")
|
|
75
|
+
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
76
|
+
live_channel = local_assigns.fetch(:live_channel, nil)
|
|
77
|
+
optimistic = local_assigns.fetch(:optimistic, true)
|
|
78
|
+
toasts = local_assigns.fetch(:toasts, true)
|
|
79
|
+
empty_label = local_assigns.fetch(:empty_label, "Drop here")
|
|
80
|
+
header_slot = local_assigns.fetch(:header_slot, nil)
|
|
81
|
+
above_board_slot = local_assigns.fetch(:above_board_slot, nil)
|
|
82
|
+
fx = local_assigns.fetch(:fx, nil)
|
|
83
|
+
on_move_hook = local_assigns.fetch(:on_move_hook, nil)
|
|
84
|
+
on_drop_hook = local_assigns.fetch(:on_drop_hook, nil)
|
|
85
|
+
demo = local_assigns.fetch(:demo, false)
|
|
86
|
+
|
|
87
|
+
board_opts = {
|
|
88
|
+
zoneSelector: zone_selector,
|
|
89
|
+
draggable: draggable_sel,
|
|
90
|
+
emptySelector: empty_selector,
|
|
91
|
+
group: (group == false ? false : group.to_s),
|
|
92
|
+
handle: handle,
|
|
93
|
+
filter: sort_filter,
|
|
94
|
+
idAttr: id_attr,
|
|
95
|
+
zoneAttr: zone_attr,
|
|
96
|
+
domIdPrefix: dom_id_prefix,
|
|
97
|
+
moveUrl: move_url,
|
|
98
|
+
moveParam: (move_param ? { resource: move_param[:resource].to_s, attr: move_param[:attr].to_s } : nil),
|
|
99
|
+
reorderUrl: reorder_url,
|
|
100
|
+
reorderPayload: reorder_payload,
|
|
101
|
+
optimistic: !!optimistic,
|
|
102
|
+
toasts: !!toasts,
|
|
103
|
+
live: live_channel.present?,
|
|
104
|
+
demo: !!demo,
|
|
105
|
+
fx: fx,
|
|
106
|
+
onMoveHook: on_move_hook,
|
|
107
|
+
onDropHook: on_drop_hook,
|
|
108
|
+
labels: columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
|
|
109
|
+
}
|
|
110
|
+
%>
|
|
111
|
+
<section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
|
|
112
|
+
<% if live_channel.present? %><%= turbo_stream_from live_channel %><% end %>
|
|
113
|
+
|
|
114
|
+
<% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
|
|
115
|
+
<% if above_board_slot %><%= above_board_slot %><% end %>
|
|
116
|
+
|
|
117
|
+
<div class="flex flex-col gap-4 overflow-x-visible pb-4 sm:flex-row sm:overflow-x-auto">
|
|
118
|
+
<% columns.each do |column| %>
|
|
119
|
+
<%= render "studio/board/column",
|
|
120
|
+
column: column,
|
|
121
|
+
zone_attr: zone_attr,
|
|
122
|
+
dom_id_prefix: dom_id_prefix,
|
|
123
|
+
zone_selector_class: zone_selector.delete_prefix("."),
|
|
124
|
+
empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
|
|
125
|
+
empty_label: empty_label,
|
|
126
|
+
card_partial: card_partial,
|
|
127
|
+
card_as: card_as,
|
|
128
|
+
card_locals: card_locals %>
|
|
129
|
+
<% end %>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<% if toasts %>
|
|
133
|
+
<div class="studio-board-toasts fixed top-4 right-4 z-50 space-y-2" data-test="studio-board-toasts">
|
|
134
|
+
<template x-for="toast in toasts" :key="toast.id">
|
|
135
|
+
<div x-show="toast.visible"
|
|
136
|
+
x-transition:enter="transition ease-out duration-300"
|
|
137
|
+
x-transition:enter-start="opacity-0 translate-x-8"
|
|
138
|
+
x-transition:enter-end="opacity-100 translate-x-0"
|
|
139
|
+
x-transition:leave="transition ease-in duration-200"
|
|
140
|
+
x-transition:leave-start="opacity-100 translate-x-0"
|
|
141
|
+
x-transition:leave-end="opacity-0 translate-x-8"
|
|
142
|
+
class="bg-surface border rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg backdrop-blur-sm"
|
|
143
|
+
:style="toast.type === 'success'
|
|
144
|
+
? 'border-color: var(--color-success); color: var(--color-success)'
|
|
145
|
+
: 'border-color: var(--color-danger); color: var(--color-danger)'">
|
|
146
|
+
<span x-text="toast.message"></span>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</div>
|
|
150
|
+
<% end %>
|
|
151
|
+
</section>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studio/board/card_shell — OPTIONAL chrome that emits the board CARD half of the
|
|
3
|
+
identity contract, so a card partial can `render layout: "studio/board/card_shell"`
|
|
4
|
+
around its own body instead of hand-rolling the wrapper. It emits:
|
|
5
|
+
|
|
6
|
+
id="<dom_id_prefix><id>" (default id="card-<id>")
|
|
7
|
+
data-<id_attr>="<id>" (default data-slug — window.studioBoard reads this)
|
|
8
|
+
data-<zone_attr>="<zone>" (default data-stage — the card's current column)
|
|
9
|
+
class="kanban-card <extra>" (the SortableJS draggable + live-observe hook)
|
|
10
|
+
|
|
11
|
+
Apps may SKIP this and hand-roll a bespoke card, as long as it satisfies the same
|
|
12
|
+
contract (that is the point of the primitive — the contract, not this exact
|
|
13
|
+
markup). UI only; no domain, no persistence.
|
|
14
|
+
|
|
15
|
+
Checks local_assigns (never block_given?) for its locals, but always yields the
|
|
16
|
+
caller block — it is only ever used as a `render layout:` wrapper.
|
|
17
|
+
|
|
18
|
+
locals:
|
|
19
|
+
id: (req) the card id value (a slug / record id).
|
|
20
|
+
zone: (req) the card's current column/zone key.
|
|
21
|
+
id_attr: data-attr name for the id. Default "slug".
|
|
22
|
+
zone_attr: data-attr name for the zone. Default "stage".
|
|
23
|
+
dom_id_prefix: dom-id prefix. Default "card-".
|
|
24
|
+
card_class: the required base class. Default "kanban-card".
|
|
25
|
+
class: extra classes appended after card_class.
|
|
26
|
+
style: inline style string.
|
|
27
|
+
attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
|
|
28
|
+
values are HTML-escaped — the rebase seam for a card that carries
|
|
29
|
+
its own Alpine/data-* on the root.
|
|
30
|
+
%>
|
|
31
|
+
<%
|
|
32
|
+
id = local_assigns.fetch(:id)
|
|
33
|
+
zone = local_assigns.fetch(:zone)
|
|
34
|
+
id_attr = local_assigns.fetch(:id_attr, "slug")
|
|
35
|
+
zone_attr = local_assigns.fetch(:zone_attr, "stage")
|
|
36
|
+
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
37
|
+
card_class = local_assigns.fetch(:card_class, "kanban-card")
|
|
38
|
+
extra_class = local_assigns.fetch(:class, nil)
|
|
39
|
+
inline_style = local_assigns.fetch(:style, nil)
|
|
40
|
+
root_attrs = local_assigns.fetch(:attrs, {}) || {}
|
|
41
|
+
%>
|
|
42
|
+
<div id="<%= dom_id_prefix %><%= id %>"
|
|
43
|
+
class="<%= [card_class, extra_class].compact.join(" ") %>"
|
|
44
|
+
data-<%= id_attr %>="<%= id %>"
|
|
45
|
+
data-<%= zone_attr %>="<%= zone %>"
|
|
46
|
+
<% root_attrs.each do |name, value| %><%= name %>="<%= value %>" <% end %>
|
|
47
|
+
<% if inline_style %>style="<%= inline_style %>"<% end %>><%= yield %></div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studio/board/column — ONE board column: the header (label + count), the drop
|
|
3
|
+
zone (the SortableJS target), its cards, and the empty state. Emits the ZONE half
|
|
4
|
+
of the board identity contract:
|
|
5
|
+
|
|
6
|
+
id="dropzone-<key>" (window.studioBoard.updateCounts reads this)
|
|
7
|
+
class="kanban-dropzone …" (the SortableJS + live-observe target)
|
|
8
|
+
data-<zone_attr>="<key>" (the column key a dropped card moves TO)
|
|
9
|
+
|
|
10
|
+
Cards emit the other half (see studio/board/_card_shell). Rendered by
|
|
11
|
+
studio/board/_board; not usually rendered directly.
|
|
12
|
+
|
|
13
|
+
locals:
|
|
14
|
+
column: (req) { key:, label:, cards:, count:, badge_class:,
|
|
15
|
+
collapsible:, hidden:, header_extra: }. `cards` are the
|
|
16
|
+
records passed to the card partial; `count` defaults to
|
|
17
|
+
cards.size; `badge_class` picks a components/_badge scheme
|
|
18
|
+
for the count (else a plain neutral count chip).
|
|
19
|
+
zone_attr: data-attr name for the zone. Default "stage".
|
|
20
|
+
dom_id_prefix: card dom-id prefix, forwarded to the cards. Default "card-".
|
|
21
|
+
zone_selector_class: the drop-zone class. Default "kanban-dropzone".
|
|
22
|
+
empty_selector_class: the empty-state class. Default "kanban-empty".
|
|
23
|
+
empty_label: empty-zone text. Default "Drop here".
|
|
24
|
+
card_partial: (req) partial rendered per card.
|
|
25
|
+
card_as: local the record binds to in card_partial. Default :card.
|
|
26
|
+
card_locals: per-card extra locals — a Proc ->(record){ Hash } or Hash.
|
|
27
|
+
%>
|
|
28
|
+
<%
|
|
29
|
+
column = local_assigns.fetch(:column)
|
|
30
|
+
zone_attr = local_assigns.fetch(:zone_attr, "stage")
|
|
31
|
+
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
32
|
+
zone_selector_class = local_assigns.fetch(:zone_selector_class, "kanban-dropzone")
|
|
33
|
+
empty_selector_class = local_assigns.fetch(:empty_selector_class, "kanban-empty")
|
|
34
|
+
empty_label = local_assigns.fetch(:empty_label, "Drop here")
|
|
35
|
+
card_partial = local_assigns.fetch(:card_partial)
|
|
36
|
+
card_as = local_assigns.fetch(:card_as, :card)
|
|
37
|
+
card_locals = local_assigns.fetch(:card_locals, nil)
|
|
38
|
+
|
|
39
|
+
key = column[:key].to_s
|
|
40
|
+
label = column[:label] || key
|
|
41
|
+
cards = column[:cards] || []
|
|
42
|
+
count = column.fetch(:count) { cards.size }
|
|
43
|
+
badge_class = column[:badge_class]
|
|
44
|
+
collapsible = column[:collapsible]
|
|
45
|
+
hidden = column[:hidden]
|
|
46
|
+
header_extra = column[:header_extra]
|
|
47
|
+
|
|
48
|
+
# Merge the per-card hook (Proc or Hash) with the record binding.
|
|
49
|
+
resolve_locals = lambda do |record|
|
|
50
|
+
extra = card_locals.respond_to?(:call) ? card_locals.call(record) : (card_locals || {})
|
|
51
|
+
{ card_as => record }.merge(extra || {})
|
|
52
|
+
end
|
|
53
|
+
%>
|
|
54
|
+
<div class="studio-board-column w-full flex-none sm:flex-1 sm:min-w-[190px]"
|
|
55
|
+
data-board-column="<%= key %>"
|
|
56
|
+
<% if collapsible %>data-board-collapsible="true"<% end %>
|
|
57
|
+
<% if hidden %>x-cloak style="display:none"<% end %>>
|
|
58
|
+
<div class="mb-3 px-1 flex items-center gap-2">
|
|
59
|
+
<h3 class="text-sm font-bold text-heading uppercase tracking-wider"><%= label %></h3>
|
|
60
|
+
<% if badge_class %>
|
|
61
|
+
<%= render "components/badge", text: count.to_s, scheme: badge_class, data_board_count: key %>
|
|
62
|
+
<% else %>
|
|
63
|
+
<span class="stage-count inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold bg-surface-alt text-secondary border border-subtle"
|
|
64
|
+
data-board-count="<%= key %>"><%= count %></span>
|
|
65
|
+
<% end %>
|
|
66
|
+
<% if header_extra %><%= header_extra %><% end %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div id="dropzone-<%= key %>"
|
|
70
|
+
data-<%= zone_attr %>="<%= key %>"
|
|
71
|
+
class="<%= zone_selector_class %> rounded-xl border-2 border-dashed border-subtle min-h-40 p-2 space-y-2 transition-colors duration-150 sm:min-h-[calc(100vh-220px)]">
|
|
72
|
+
<% cards.each do |record| %>
|
|
73
|
+
<%= render partial: card_partial, locals: resolve_locals.call(record) %>
|
|
74
|
+
<% end %>
|
|
75
|
+
<div class="<%= empty_selector_class %> flex items-center justify-center h-24 text-muted text-sm"
|
|
76
|
+
<% if cards.any? %>style="display: none"<% end %>>
|
|
77
|
+
<%= empty_label %>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Change-username modal — a named engine primitive, and a thin specialization of
|
|
3
|
+
studio/modals/blocks/_leveling_activity with username defaults. TWO modes via
|
|
4
|
+
the :leveling flag (quest pill + seeds celebration when on; plain input + Save +
|
|
5
|
+
Saved when off).
|
|
6
|
+
|
|
7
|
+
CRITICAL BOUNDARY — UI ONLY. The engine renders the input, the Save button, the
|
|
8
|
+
saved state, and the optional leveling chrome. The actual username SAVE is an
|
|
9
|
+
APP-SUPPLIED callback: the modal POSTs to submit_url and, if the app returns a
|
|
10
|
+
second step, delegates it opaquely to an app-supplied finalize_hook (the app's
|
|
11
|
+
managed-wallet write). No wallet, signing, keys, or on-chain writes live here.
|
|
12
|
+
|
|
13
|
+
Locals (beyond _leveling_activity's):
|
|
14
|
+
current_username (String, REQUIRED) — seed value for the input (a plain
|
|
15
|
+
string; never current_user — no model coupling in the engine).
|
|
16
|
+
submit_url (String, REQUIRED) — app endpoint the username POSTs to.
|
|
17
|
+
description (String) — app-authored lead copy. Pass the wallet-aware line
|
|
18
|
+
here (e.g. "Your username is stored on-chain. Changes are
|
|
19
|
+
saved via your managed wallet") — the engine default stays
|
|
20
|
+
neutral so no on-chain assumption is baked in.
|
|
21
|
+
|
|
22
|
+
Everything else (finalize_url, finalize_hook, saved_event, quest_label,
|
|
23
|
+
leveling, modal_store, demo, celebration copy) passes straight through.
|
|
24
|
+
%>
|
|
25
|
+
<%
|
|
26
|
+
current_username = local_assigns.fetch(:current_username)
|
|
27
|
+
|
|
28
|
+
username_locals = local_assigns.except(:current_username).reverse_merge(
|
|
29
|
+
title: "Change Username",
|
|
30
|
+
description: "Pick a name that's right for you.",
|
|
31
|
+
input: true,
|
|
32
|
+
initial_value: current_username.to_s,
|
|
33
|
+
min_length: 3,
|
|
34
|
+
max_length: 30,
|
|
35
|
+
pattern: "[a-zA-Z0-9_-]+",
|
|
36
|
+
pattern_title: "3-30 characters — letters, numbers, hyphens, and underscores",
|
|
37
|
+
placeholder: "username",
|
|
38
|
+
cta_label: "Save",
|
|
39
|
+
saved_label: "Saved",
|
|
40
|
+
saved_event: "studio:username-saved",
|
|
41
|
+
icon_emoji: "🌱",
|
|
42
|
+
celebrate_title: "Great Username",
|
|
43
|
+
celebrate_subtitle: "Nice — you earned seeds for updating your profile. Keep completing quests to earn a Free Entry."
|
|
44
|
+
)
|
|
45
|
+
%>
|
|
46
|
+
<%= render "studio/modals/blocks/leveling_activity", username_locals %>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Leveling-activity modal — the engine primitive behind TM's quest/leveling
|
|
3
|
+
activities (change username, join newsletter, and the rest). ONE primitive,
|
|
4
|
+
TWO modes, decided by the app's :leveling capability:
|
|
5
|
+
|
|
6
|
+
leveling ON — the full quest framing: a "Quest N of N" pill, and on success
|
|
7
|
+
the seeds celebration (progress bar + level-up + Free Entry).
|
|
8
|
+
leveling OFF — the plain action modal: just the action + Save, and a Saved
|
|
9
|
+
state on success. No quest pill, no seeds. (This is the
|
|
10
|
+
McRitchie-Studio shape: MS has no leveling, TM does.)
|
|
11
|
+
|
|
12
|
+
Same primitive; the flag decides. The leveling chrome (quest pill, seeds bar) is
|
|
13
|
+
engine-owned UI — it is NOT on-chain.
|
|
14
|
+
|
|
15
|
+
CRITICAL BOUNDARY — UI ONLY. There is NO wallet, signing, key, or on-chain write
|
|
16
|
+
anywhere in this primitive. The actual save is an APP-SUPPLIED callback: the
|
|
17
|
+
modal POSTs to submit_url and reacts to a neutral JSON contract; any wallet step
|
|
18
|
+
is delegated opaquely to an app-supplied finalize_hook (see the factory,
|
|
19
|
+
studio/_leveling_activity_assets). TM wires the managed-wallet write; MS wires a
|
|
20
|
+
trivial endpoint. Requires window.levelingActionModal — render
|
|
21
|
+
studio/leveling_activity_assets once at layout level.
|
|
22
|
+
|
|
23
|
+
Locals:
|
|
24
|
+
submit_url (String, REQUIRED) — app endpoint the action POSTs to.
|
|
25
|
+
title (String) — modal title. Default "Complete activity".
|
|
26
|
+
description (String) — the app-authored lead copy. App-owned so wallet-aware
|
|
27
|
+
prose ("saved via your managed wallet") lives app-side, never here.
|
|
28
|
+
input (Boolean) — render a single-line input. Default false.
|
|
29
|
+
input_type (String) — the input type (e.g. "text", "email"). Default "text".
|
|
30
|
+
initial_value (String) — seed value for the input (a plain string).
|
|
31
|
+
placeholder / min_length / max_length / pattern / pattern_title — input attrs.
|
|
32
|
+
cta_label (String) — action button label. Default "Complete".
|
|
33
|
+
saved_label (String) — label once saved. Default "Done".
|
|
34
|
+
saving_label (String) — label while saving. Default "Saving…".
|
|
35
|
+
finalize_url (String) — endpoint for the opaque second step (omit if none).
|
|
36
|
+
finalize_hook (String) — window async fn name for the step (omit if none).
|
|
37
|
+
saved_event (String) — DOM event dispatched on success. Default
|
|
38
|
+
"studio:activity-saved".
|
|
39
|
+
quest_label (String) — the "Quest N of N" pill text (leveling only).
|
|
40
|
+
icon_emoji (String) — celebration glyph. Default "🌱".
|
|
41
|
+
celebrate_title (String) — celebration headline. Default "Activity complete".
|
|
42
|
+
celebrate_subtitle (String) — celebration subtext.
|
|
43
|
+
free_entry_label (String) — level-up reward line. Default "Free Entry Earned 🎟️".
|
|
44
|
+
seeds_per_level (Integer) — seeds per level. Default 100.
|
|
45
|
+
leveling (Boolean) — override the :leveling gate. Defaults to
|
|
46
|
+
Studio.feature?(:leveling); the style guide passes both to show
|
|
47
|
+
each mode.
|
|
48
|
+
modal_store (String) — Alpine store name backing close(). Default "modals".
|
|
49
|
+
demo (Boolean) — style-guide preview: resolve locally, no POST.
|
|
50
|
+
|
|
51
|
+
Single root element (the modal host mounts this through its own template x-if).
|
|
52
|
+
%>
|
|
53
|
+
<%
|
|
54
|
+
submit_url = local_assigns.fetch(:submit_url)
|
|
55
|
+
title = local_assigns.fetch(:title, "Complete activity")
|
|
56
|
+
description = local_assigns.fetch(:description, nil)
|
|
57
|
+
input = local_assigns.fetch(:input, false)
|
|
58
|
+
input_type = local_assigns.fetch(:input_type, "text")
|
|
59
|
+
initial_value = local_assigns.fetch(:initial_value, "")
|
|
60
|
+
placeholder = local_assigns.fetch(:placeholder, "")
|
|
61
|
+
min_length = local_assigns.fetch(:min_length, 0)
|
|
62
|
+
max_length = local_assigns.fetch(:max_length, nil)
|
|
63
|
+
pattern = local_assigns.fetch(:pattern, nil)
|
|
64
|
+
pattern_title = local_assigns.fetch(:pattern_title, nil)
|
|
65
|
+
cta_label = local_assigns.fetch(:cta_label, "Complete")
|
|
66
|
+
saved_label = local_assigns.fetch(:saved_label, "Done")
|
|
67
|
+
saving_label = local_assigns.fetch(:saving_label, "Saving…")
|
|
68
|
+
finalize_url = local_assigns.fetch(:finalize_url, "")
|
|
69
|
+
finalize_hook = local_assigns.fetch(:finalize_hook, "")
|
|
70
|
+
saved_event = local_assigns.fetch(:saved_event, "studio:activity-saved")
|
|
71
|
+
quest_label = local_assigns.fetch(:quest_label, nil)
|
|
72
|
+
icon_emoji = local_assigns.fetch(:icon_emoji, "🌱")
|
|
73
|
+
celebrate_title = local_assigns.fetch(:celebrate_title, "Activity complete")
|
|
74
|
+
celebrate_subtitle = local_assigns.fetch(:celebrate_subtitle, "Nice — you earned seeds. Keep completing quests to earn a Free Entry.")
|
|
75
|
+
free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
|
|
76
|
+
seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
|
|
77
|
+
leveling = local_assigns.fetch(:leveling, Studio.feature?(:leveling))
|
|
78
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
79
|
+
demo = local_assigns.fetch(:demo, false)
|
|
80
|
+
%>
|
|
81
|
+
<div x-data="levelingActionModal({
|
|
82
|
+
hasInput: <%= input ? 'true' : 'false' %>,
|
|
83
|
+
initialValue: '<%= j initial_value.to_s %>',
|
|
84
|
+
minLength: <%= min_length.to_i %>,
|
|
85
|
+
submitUrl: '<%= j submit_url %>',
|
|
86
|
+
finalizeUrl: '<%= j finalize_url %>',
|
|
87
|
+
finalizeHook: '<%= j finalize_hook %>',
|
|
88
|
+
savedEvent: '<%= j saved_event %>',
|
|
89
|
+
store: '<%= j modal_store %>',
|
|
90
|
+
leveling: <%= leveling ? 'true' : 'false' %>,
|
|
91
|
+
demo: <%= demo ? 'true' : 'false' %>,
|
|
92
|
+
seedsPerLevel: <%= seeds_per_level.to_i %>
|
|
93
|
+
})">
|
|
94
|
+
|
|
95
|
+
<%# FORM VIEW — the action. Single root inside the template. %>
|
|
96
|
+
<template x-if="!celebrate">
|
|
97
|
+
<div>
|
|
98
|
+
<%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
|
|
99
|
+
<% if leveling && quest_label.present? %>
|
|
100
|
+
<div class="mb-3">
|
|
101
|
+
<span class="inline-block text-[10px] font-semibold uppercase tracking-wide text-primary bg-primary/10 px-2 py-0.5 rounded-full whitespace-nowrap"><%= quest_label %></span>
|
|
102
|
+
</div>
|
|
103
|
+
<% end %>
|
|
104
|
+
|
|
105
|
+
<% if description.present? %>
|
|
106
|
+
<p class="text-sm text-muted mb-4"><%= description %></p>
|
|
107
|
+
<% end %>
|
|
108
|
+
|
|
109
|
+
<template x-if="error">
|
|
110
|
+
<p class="text-red-400 text-sm mb-3" x-text="error"></p>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<form @submit.prevent="save()" class="space-y-4">
|
|
114
|
+
<% if input %>
|
|
115
|
+
<input type="<%= input_type %>" x-model="value" class="input-field w-full"
|
|
116
|
+
placeholder="<%= placeholder %>"
|
|
117
|
+
<%= "minlength=\"#{min_length}\"".html_safe if min_length.to_i.positive? %>
|
|
118
|
+
<%= "maxlength=\"#{max_length}\"".html_safe if max_length.present? %>
|
|
119
|
+
<%= "pattern=\"#{pattern}\"".html_safe if pattern.present? %>
|
|
120
|
+
<%= "title=\"#{pattern_title}\"".html_safe if pattern_title.present? %>
|
|
121
|
+
autofocus>
|
|
122
|
+
<% end %>
|
|
123
|
+
<button type="submit" class="btn btn-primary btn-lg w-full" :disabled="saving || !changed">
|
|
124
|
+
<span x-show="!saving" x-text="changed ? '<%= j cta_label %>' : '<%= j saved_label %>'"><%= cta_label %></span>
|
|
125
|
+
<span x-show="saving" class="inline-flex items-center justify-center gap-2" style="display: none;">
|
|
126
|
+
<span class="cta-spinner" aria-hidden="true"></span>
|
|
127
|
+
<span x-text="progressLabel || '<%= j saving_label %>'"><%= saving_label %></span>
|
|
128
|
+
</span>
|
|
129
|
+
</button>
|
|
130
|
+
</form>
|
|
131
|
+
<% end %>
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
|
|
135
|
+
<%# CELEBRATION VIEW — leveling ONLY. Rendered at ERB time only when leveling is
|
|
136
|
+
on, so a leveling-off modal ships NO seeds chrome at all (not merely
|
|
137
|
+
unmounted). Mounts once celebrate flips true so the seeds bar animates from
|
|
138
|
+
the freshly-saved payload. Single root. %>
|
|
139
|
+
<% if leveling %>
|
|
140
|
+
<template x-if="celebrate">
|
|
141
|
+
<div>
|
|
142
|
+
<%= render "studio/modals/blocks/card_header",
|
|
143
|
+
size: :lg, icon_emoji: icon_emoji,
|
|
144
|
+
title: celebrate_title, subtitle: celebrate_subtitle %>
|
|
145
|
+
|
|
146
|
+
<div class="mb-5">
|
|
147
|
+
<%= render "studio/modals/blocks/seeds_bar",
|
|
148
|
+
seeds_earned_key: "seedsEarned", seeds_total_key: "seedsTotal",
|
|
149
|
+
seeds_per_level: seeds_per_level, free_entry_label: free_entry_label %>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
153
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
154
|
+
Close
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</template>
|
|
158
|
+
<% end %>
|
|
159
|
+
</div>
|
|
@@ -255,6 +255,11 @@
|
|
|
255
255
|
specimen opens live. A consumer app renders this in its layout the same way. %>
|
|
256
256
|
<%= render "studio/age_verify_assets" %>
|
|
257
257
|
|
|
258
|
+
<%# levelingActionModal factory — page level (never inside the overlay template,
|
|
259
|
+
where a cloned script would not run) so the change-username + quest-activity
|
|
260
|
+
specimens open live. A consumer app renders this in its layout the same way. %>
|
|
261
|
+
<%= render "studio/leveling_activity_assets" %>
|
|
262
|
+
|
|
258
263
|
<%# ---- The overlay: one backdrop + card; every specimen's real content is a
|
|
259
264
|
single-root <template x-if> gated on the current modal id. Faithful to the
|
|
260
265
|
shared host — backdrop fade, scroll lock, escape + click-self dismissal
|
|
@@ -376,6 +381,49 @@
|
|
|
376
381
|
subtitle: "You leveled up! A Free Entry lands in your account soon. Keep leveling for more." %></div>
|
|
377
382
|
</template>
|
|
378
383
|
|
|
384
|
+
<%# --- Leveling activities: change username + quest activity --- %>
|
|
385
|
+
<%# Each id renders the SAME engine primitive with an EXPLICIT leveling:
|
|
386
|
+
flag, so BOTH modes stay visible here regardless of this app's real
|
|
387
|
+
:leveling setting. All demo:true — the save resolves locally (no
|
|
388
|
+
backend), and the on-chain-accurate copy is passed as an app-authored
|
|
389
|
+
description (the engine default stays neutral). %>
|
|
390
|
+
<template x-if="$store.dsModals.current().id === 'change-username'">
|
|
391
|
+
<div><%= render "studio/modals/blocks/change_username",
|
|
392
|
+
modal_store: "dsModals", demo: true, leveling: true,
|
|
393
|
+
current_username: "turfmonster",
|
|
394
|
+
submit_url: "#demo-change-username",
|
|
395
|
+
description: "Your username is stored on-chain. Changes are saved via your managed wallet.",
|
|
396
|
+
quest_label: "Quest 1 of 4" %></div>
|
|
397
|
+
</template>
|
|
398
|
+
<template x-if="$store.dsModals.current().id === 'change-username-plain'">
|
|
399
|
+
<div><%= render "studio/modals/blocks/change_username",
|
|
400
|
+
modal_store: "dsModals", demo: true, leveling: false,
|
|
401
|
+
current_username: "mcritchie",
|
|
402
|
+
submit_url: "#demo-change-username",
|
|
403
|
+
description: "Choose the name shown across your account." %></div>
|
|
404
|
+
</template>
|
|
405
|
+
<template x-if="$store.dsModals.current().id === 'quest-activity'">
|
|
406
|
+
<div><%= render "studio/modals/blocks/leveling_activity",
|
|
407
|
+
modal_store: "dsModals", demo: true, leveling: true,
|
|
408
|
+
submit_url: "#demo-quest",
|
|
409
|
+
title: "Join the Newsletter",
|
|
410
|
+
description: "Sports news and contest updates — complete the quest for seeds.",
|
|
411
|
+
input: true, input_type: "email", placeholder: "you@example.com",
|
|
412
|
+
cta_label: "Subscribe", saved_label: "Subscribed", saving_label: "Subscribing…",
|
|
413
|
+
quest_label: "Quest 2 of 4",
|
|
414
|
+
icon_emoji: "📬", celebrate_title: "You're in",
|
|
415
|
+
celebrate_subtitle: "Nice — you earned seeds for joining. Keep completing quests to earn a Free Entry." %></div>
|
|
416
|
+
</template>
|
|
417
|
+
<template x-if="$store.dsModals.current().id === 'quest-activity-plain'">
|
|
418
|
+
<div><%= render "studio/modals/blocks/leveling_activity",
|
|
419
|
+
modal_store: "dsModals", demo: true, leveling: false,
|
|
420
|
+
submit_url: "#demo-quest",
|
|
421
|
+
title: "Join the Newsletter",
|
|
422
|
+
description: "Sports news and contest updates.",
|
|
423
|
+
input: true, input_type: "email", placeholder: "you@example.com",
|
|
424
|
+
cta_label: "Subscribe", saved_label: "Subscribed", saving_label: "Subscribing…" %></div>
|
|
425
|
+
</template>
|
|
426
|
+
|
|
379
427
|
</div>
|
|
380
428
|
</div>
|
|
381
429
|
</template>
|
|
@@ -859,4 +907,97 @@
|
|
|
859
907
|
<% end %>
|
|
860
908
|
</div>
|
|
861
909
|
</section>
|
|
910
|
+
|
|
911
|
+
<%# ===================================================================== %>
|
|
912
|
+
<%# 7. LEVELING ACTIVITIES — change username + quest activities %>
|
|
913
|
+
<%# ===================================================================== %>
|
|
914
|
+
<section class="space-y-5">
|
|
915
|
+
<div class="space-y-1">
|
|
916
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
917
|
+
<h3 class="text-xl font-bold text-heading">Leveling activities</h3>
|
|
918
|
+
<% if leveling_on %>
|
|
919
|
+
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">leveling on</span>
|
|
920
|
+
<% else %>
|
|
921
|
+
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">leveling off</span>
|
|
922
|
+
<% end %>
|
|
923
|
+
</div>
|
|
924
|
+
<p class="text-muted text-sm">
|
|
925
|
+
The quest/leveling action modals
|
|
926
|
+
(<code class="font-mono text-2xs">studio/modals/blocks/_change_username</code>,
|
|
927
|
+
<code class="font-mono text-2xs">studio/modals/blocks/_leveling_activity</code>),
|
|
928
|
+
migrated from Turf Monster. ONE primitive, <strong>two modes</strong>, decided
|
|
929
|
+
by <code class="font-mono text-2xs">Studio.feature?(:leveling)</code>:
|
|
930
|
+
<strong>leveling on</strong> adds the quest pill + the seeds level-up
|
|
931
|
+
celebration; <strong>leveling off</strong> is the plain action modal (just
|
|
932
|
+
the input + Save + a Saved state — the McRitchie Studio shape). Each modal is
|
|
933
|
+
shown <strong>both ways</strong> below (the specimens pass an explicit
|
|
934
|
+
<code class="font-mono text-2xs">leveling:</code> so both render regardless of
|
|
935
|
+
this app's flag). The actual save is an <strong>app-supplied callback</strong>:
|
|
936
|
+
the modal POSTs to <code class="font-mono text-2xs">submit_url</code> and, for
|
|
937
|
+
an app that needs a wallet step, delegates it opaquely to an app-supplied
|
|
938
|
+
<code class="font-mono text-2xs">finalize_hook</code> — <strong>no wallet,
|
|
939
|
+
signing, or on-chain write lives in the engine</strong>. (These demos resolve
|
|
940
|
+
the save locally.)
|
|
941
|
+
</p>
|
|
942
|
+
</div>
|
|
943
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
944
|
+
<%# Change username — leveling ON: quest pill + seeds celebration on save. %>
|
|
945
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
946
|
+
label: "Change username (leveling)",
|
|
947
|
+
reference: %(the "Change username" modal WITH leveling (studio-engine studio/modals/blocks/_change_username, leveling: true) — quest pill + seeds level-up celebration on save. The on-chain save is an app-supplied callback (submit_url + an opaque finalize_hook); the engine is UI only. Open with $store.dsModals.open('change-username')),
|
|
948
|
+
open_expr: "$store.dsModals.open('change-username')",
|
|
949
|
+
glow_when: ds_glow.call("change-username") } do %>
|
|
950
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
951
|
+
<div class="flex items-center justify-between gap-2">
|
|
952
|
+
<span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
953
|
+
<span class="text-[8px] font-semibold uppercase rounded-full px-1.5 py-0.5" style="color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent)">Quest 1/4</span>
|
|
954
|
+
</div>
|
|
955
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
956
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
957
|
+
</div>
|
|
958
|
+
<% end %>
|
|
959
|
+
|
|
960
|
+
<%# Change username — leveling OFF: the plain MS-style action + Save. %>
|
|
961
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
962
|
+
label: "Change username (plain)",
|
|
963
|
+
reference: %(the "Change username" modal WITHOUT leveling (studio-engine studio/modals/blocks/_change_username, leveling: false) — the plain McRitchie Studio shape: input + Save, then a Saved state. No quest pill, no seeds. Same primitive, flag off. Open with $store.dsModals.open('change-username-plain')),
|
|
964
|
+
open_expr: "$store.dsModals.open('change-username-plain')",
|
|
965
|
+
glow_when: ds_glow.call("change-username-plain") } do %>
|
|
966
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
967
|
+
<span class="block h-2 w-16 rounded" style="background: var(--color-text); opacity: .18"></span>
|
|
968
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
969
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
970
|
+
</div>
|
|
971
|
+
<% end %>
|
|
972
|
+
|
|
973
|
+
<%# Quest activity — leveling ON: generic quest (newsletter join) + seeds. %>
|
|
974
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
975
|
+
label: "Quest activity (leveling)",
|
|
976
|
+
reference: %(a generic quest activity WITH leveling (studio-engine studio/modals/blocks/_leveling_activity, leveling: true) — here a "Join the Newsletter" quest: quest pill + seeds celebration on complete. Covers join-newsletter / send-message and the rest as ONE parameterized primitive. Open with $store.dsModals.open('quest-activity')),
|
|
977
|
+
open_expr: "$store.dsModals.open('quest-activity')",
|
|
978
|
+
glow_when: ds_glow.call("quest-activity") } do %>
|
|
979
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
980
|
+
<div class="flex items-center justify-between gap-2">
|
|
981
|
+
<span class="text-lg leading-none">📬</span>
|
|
982
|
+
<span class="text-[8px] font-semibold uppercase rounded-full px-1.5 py-0.5" style="color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent)">Quest 2/4</span>
|
|
983
|
+
</div>
|
|
984
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
985
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
986
|
+
</div>
|
|
987
|
+
<% end %>
|
|
988
|
+
|
|
989
|
+
<%# Quest activity — leveling OFF: the plain action (no quest, no seeds). %>
|
|
990
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
991
|
+
label: "Quest activity (plain)",
|
|
992
|
+
reference: %(the SAME generic activity WITHOUT leveling (studio-engine studio/modals/blocks/_leveling_activity, leveling: false) — a plain "Join the Newsletter" action: input + Subscribe, then Subscribed. No quest pill, no seeds. Open with $store.dsModals.open('quest-activity-plain')),
|
|
993
|
+
open_expr: "$store.dsModals.open('quest-activity-plain')",
|
|
994
|
+
glow_when: ds_glow.call("quest-activity-plain") } do %>
|
|
995
|
+
<div class="pointer-events-none w-40 rounded-lg bg-surface border border-subtle shadow p-4 space-y-2">
|
|
996
|
+
<span class="text-lg leading-none">📬</span>
|
|
997
|
+
<span class="block h-6 w-full rounded border" style="border-color: var(--color-border-strong)"></span>
|
|
998
|
+
<span class="block h-5 w-full rounded" style="background: var(--color-cta)"></span>
|
|
999
|
+
</div>
|
|
1000
|
+
<% end %>
|
|
1001
|
+
</div>
|
|
1002
|
+
</section>
|
|
862
1003
|
</section>
|