studio-engine 0.69.5 → 0.71.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 +121 -0
- data/README.md +42 -0
- data/app/views/studio/modals/onboarding/_first_name.html.erb +42 -10
- data/app/views/style/_host.html.erb +111 -0
- data/app/views/style/_modals.html.erb +372 -4
- data/app/views/style/index.html.erb +32 -3
- data/lib/studio/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b84ed97df94f71d6135899621e982649f6c80b41d5821b12842ab5c0e6d47ce7
|
|
4
|
+
data.tar.gz: 2b1af46db99092b30d9049d2afbbefa9cc810878bcbc376d9e04f780cc6e7a05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 294aa380598143d9f79592e2b0e62bab038dbb886b374ce765ff9fdf0da349e460049ae284e397f234647f3b9dfdea24263cd8ed31bf74bbe00d1e14e1bbf49f
|
|
7
|
+
data.tar.gz: d119392c02adb20753485b0b6efcaf01e3c3f3ea0aa26f9b5e36fe1f3de3fb4df77ede836f0a79ec937bd556ad14a9317078fa25f028d890e27f531e74ad8f37
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,76 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
+
- **The living style guide grows a HOST SECTION seam.** `/admin/style` rendered
|
|
10
|
+
four hard-coded engine sections (Theme, Modals, Tricks, Tasks) with no way for
|
|
11
|
+
a consuming app to contribute one of its own. An app now defines
|
|
12
|
+
**`app/views/style/host/_modals.html.erb`** and the guide grows a fifth
|
|
13
|
+
section for that app's own modals, between Modals and Tricks, plus its pill in
|
|
14
|
+
the sticky section nav. Found by the same three-term
|
|
15
|
+
`lookup_context.exists?("modals", ["style/host"], true)` the modal host uses
|
|
16
|
+
for `modals/_host_extras` — one convention in this codebase, not two — so
|
|
17
|
+
defining the file IS the registration: no initializer, no locals, no list to
|
|
18
|
+
append to.
|
|
19
|
+
|
|
20
|
+
**The gem keeps dictating the structure.** `style/_host.html.erb` renders the
|
|
21
|
+
section element, the `#host-modals` anchor, the heading (`Studio.app_name`),
|
|
22
|
+
the intro line and the Alpine scope; the app supplies specimens only. An app
|
|
23
|
+
that ships no such partial gets **nothing** — no pill, no heading, no
|
|
24
|
+
container — and that is asserted as a whole-document comparison rather than a
|
|
25
|
+
handful of refutes: the page an app WITH a section gets, minus that section
|
|
26
|
+
and its pill, must equal the page a base app gets. Five of the six apps
|
|
27
|
+
mounting this engine will ship no host section, and their page is unchanged.
|
|
28
|
+
|
|
29
|
+
**A host specimen drives `$store.modals`, not `dsModals`, and the distinction
|
|
30
|
+
is the whole point.** The guide's own Modals section needs a page-scoped store
|
|
31
|
+
because its specimens are the ENGINE's ids, which an app's layout host has no
|
|
32
|
+
registration for — pushing one there paints an empty card behind the specimen.
|
|
33
|
+
An app's own ids ARE registered in its layout host, so a host section opens
|
|
34
|
+
the **real** production card with no mirror, no second registration list and
|
|
35
|
+
no iframe. The doc comment on `style/_host.html.erb` is the consumer contract
|
|
36
|
+
and says so outright, because a reader who assumes `dsModals` builds that
|
|
37
|
+
mirror instead — a second copy of every card, free to drift from the one the
|
|
38
|
+
app ships, which is the opposite of what a living style guide is for.
|
|
39
|
+
|
|
40
|
+
**The seam ships no host section itself.** The first consumer is
|
|
41
|
+
turf-monster's own `style/host/_modals`, which lands as its own task.
|
|
42
|
+
- **A `required` mode on the shared first-name card.**
|
|
43
|
+
`studio/modals/onboarding/_first_name` grows one local. `required: true`
|
|
44
|
+
renders the card with BOTH skip affordances gone — the "Skip for now" button
|
|
45
|
+
is not emitted at all, and the × merely CLOSES and is labelled `Close` — and
|
|
46
|
+
swaps the sub-copy to the audience being gated. Default `false`, so the
|
|
47
|
+
post-auth chain card McRitchie Studio renders today does not move.
|
|
48
|
+
|
|
49
|
+
**IT IS FOR A HOST THAT GATES SOMETHING ON THE NAME.** turf-monster's entry
|
|
50
|
+
gate opens this card as the first validation of hold-to-confirm, and that gate
|
|
51
|
+
reads the stored COLUMN — so a recorded skip buys the user nothing and the
|
|
52
|
+
Skip link is a door painted on a wall, promising a way past a wall that does
|
|
53
|
+
not move.
|
|
54
|
+
|
|
55
|
+
**IT IS DELIBERATELY NOT A TRAP.** Closing stays reachable, because abandoning
|
|
56
|
+
the flow and slipping past the gate are two different things: the × still
|
|
57
|
+
dismisses the card, it just stops calling `skip()` and stops claiming it did.
|
|
58
|
+
A `required` that also removed the × would satisfy every "hides the skip"
|
|
59
|
+
reading and be a worse card, so the two are asserted as a PAIR.
|
|
60
|
+
|
|
61
|
+
**RESOLVED SERVER-SIDE, not through an Alpine `x-show`** — the source this was
|
|
62
|
+
ported from hides its skip button reactively, which leaves a button that must
|
|
63
|
+
not exist sitting in the DOM and clickable for as long as Alpine has not
|
|
64
|
+
mounted, and there for good if it never does. `required` is known at render
|
|
65
|
+
time, so the button is simply never emitted.
|
|
66
|
+
|
|
67
|
+
The default render is asserted **byte-for-byte identical** to `required:
|
|
68
|
+
false`, which is what pins the existing consumer. Mutation-checked 5/5:
|
|
69
|
+
flipping the default, un-hiding the skip button, making the × always skip,
|
|
70
|
+
freezing the label, and freezing the sub-copy each turn the suite red on a
|
|
71
|
+
different test.
|
|
72
|
+
|
|
73
|
+
This was the LAST mode turf-monster's own 240-line `modals/_onboarding` had
|
|
74
|
+
that the gem did not, so that copy can be deleted next release. A host wanting
|
|
75
|
+
both modes mounts two `<template x-if>` ids rather than branching one at
|
|
76
|
+
runtime — which is how the two cards already differ in copy, not just in
|
|
77
|
+
affordances.
|
|
78
|
+
|
|
9
79
|
- **Knowledge coverage view** — `/admin/knowledge/coverage` +
|
|
10
80
|
`Studio::KnowledgeExpectation`: the "what SHOULD exist" half of the
|
|
11
81
|
knowledge layer. Expectations carry an entity, folder, provenance
|
|
@@ -21,6 +91,41 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
21
91
|
`Studio.draw_knowledge_routes` opt-in. Built for the Commercial Welding
|
|
22
92
|
65-item diligence tracker; app-agnostic like the rest of the layer.
|
|
23
93
|
|
|
94
|
+
- **The style guide grows the two modal simulators that review ENGINE
|
|
95
|
+
behaviour** — `#modals-motion-registry` ("Enter & leave animations") and
|
|
96
|
+
`#modals-stack-mechanics` ("Stack behaviour") in `style/_modals`. Both are
|
|
97
|
+
ported from turf-monster's `/admin/modals` gallery, which is being deleted:
|
|
98
|
+
the rest of that page was consumer showroom, but these two exercised engine
|
|
99
|
+
mechanics — the `window.ModalAnimations` registry, and the modal stack's
|
|
100
|
+
dismissibility / `holdAtLeast` / LIFO rules — and would have gone with it.
|
|
101
|
+
|
|
102
|
+
The motion section BUILDS ITS CONTROLS FROM THE LIVE REGISTRY at load
|
|
103
|
+
(a button and a dropdown option per registered key, per channel), so
|
|
104
|
+
registering a new animation surfaces it on the guide with no edit to the
|
|
105
|
+
page. That is the property the section exists for, and
|
|
106
|
+
`test/views/style_guide_modal_simulator_test.rb` pins it from both sides:
|
|
107
|
+
the containers must ship EMPTY, and the build must enumerate
|
|
108
|
+
`window.ModalAnimations` rather than a local literal.
|
|
109
|
+
|
|
110
|
+
The stack demos drive `$store.dsModals` and the guide's own `onchain-tx`
|
|
111
|
+
specimen directly. **One demo did not come across: "error with recovery."**
|
|
112
|
+
turf's `setRecovery(label, fn, { phantom })` is a method on that app's
|
|
113
|
+
legacy compatibility proxy and renders a recovery button on ITS `_onchain_tx`
|
|
114
|
+
card; the engine specimen's error state is `blocks/_card_header` plus a plain
|
|
115
|
+
Close, with no recovery affordance. Porting it would have meant designing a
|
|
116
|
+
new block into a shipped partial, so it was dropped rather than shimmed —
|
|
117
|
+
roughly half the ported demos drove that proxy, and none of them reference it
|
|
118
|
+
now (asserted).
|
|
119
|
+
|
|
120
|
+
**Both sections are graded in a browser**, because neither is observable from
|
|
121
|
+
the response bytes: `e2e/style_modal_simulators.spec.js` (7 specs) drives a new
|
|
122
|
+
`/lab/style_modals` lab page, which mounts the shared modal host exactly as a
|
|
123
|
+
consumer layout does — without it `window.ModalAnimations` is undefined, the
|
|
124
|
+
build produces no controls, and "the controls match the registry" would be
|
|
125
|
+
vacuously true at `0 == 0`. Each spec was verified RED against its own defect
|
|
126
|
+
reintroduced, with the lab server restarted between runs. The lane contract
|
|
127
|
+
moves 116 → 123 (`config/e2e_lane.yml`), re-derived with the lister.
|
|
128
|
+
|
|
24
129
|
### Changed
|
|
25
130
|
|
|
26
131
|
- **`Gemfile.lock` resolves solana-studio 0.5.7, and a gate now keeps it there.** The lock had sat on **0.5.3 for four patch releases** while BOTH consumers shipped 0.5.7 (turf-monster `~> 0.5.3`, mcritchie-studio `~> 0.5`). Nothing was red and nothing could have been: engine CI installs with `bundler-cache: true`, so it resolves from the lock and never fresh — the drift does not self-correct and never surfaces as flakiness. It matters because `test/views/style_web3_specimens_test.rb` exists to prove "the style guide renders the REAL gem cards" and reads them off whatever the LOCK resolved; four versions behind, that guard certifies a card no consumer receives. It still passes — only its MEANING changes. MEASURED on this span, the gem's whole `app/` tree was byte-identical 0.5.3 → 0.5.7 (only `CHANGELOG.md`, `README.md` and `version.rb` differ), so this instance cost nothing, which is exactly why it went four releases unnoticed.
|
|
@@ -55,6 +160,22 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
55
160
|
|
|
56
161
|
### Fixed
|
|
57
162
|
|
|
163
|
+
- **The style guide's page-scoped modal store now resolves animations through
|
|
164
|
+
the LIVE registry, so the guide can no longer disagree with itself.**
|
|
165
|
+
`style/_modals`' `dsModals` carried a hard-coded COPY of the animation table
|
|
166
|
+
while the new simulator builds its controls from `window.ModalAnimations`.
|
|
167
|
+
The two agreed today, which is exactly why this was invisible — but a
|
|
168
|
+
consumer registering a key would have grown a control from the registry that
|
|
169
|
+
the store then resolved back to `pop`: the button said "shake", the card
|
|
170
|
+
popped, and nothing reported the gap. Its `modalAnim` now reads
|
|
171
|
+
`window.ModalAnimations` at CALL time with the same late-binding guard the
|
|
172
|
+
shared host uses (unknown keys and a gutted registry still fall back to
|
|
173
|
+
`pop`, so a miss can never throw on `.ms` and strand a modal open); the local
|
|
174
|
+
table remains only as the fallback for a guide rendered without the host.
|
|
175
|
+
MEASURED in a browser both ways: registering a new key at runtime surfaces a
|
|
176
|
+
control AND plays it, and with this fix reverted the same key surfaces a
|
|
177
|
+
control that plays `modal-card-mount`.
|
|
178
|
+
|
|
58
179
|
- **Onboarding no longer truncates a surname to fit a first name's cap.**
|
|
59
180
|
`Studio::OnboardingController#first_name` measured the WHOLE typed answer with
|
|
60
181
|
`Studio::FIRST_NAME_MAX_LENGTH` — the PER-FIELD cap, 40. The field asks for a
|
data/README.md
CHANGED
|
@@ -449,6 +449,48 @@ than assuming drop-in equivalence:
|
|
|
449
449
|
the replacement lands after the 220ms slide-out (previously an immediate
|
|
450
450
|
top-of-stack assignment).
|
|
451
451
|
|
|
452
|
+
### Style guide — your app's own section
|
|
453
|
+
|
|
454
|
+
`/admin/style` is the engine's living style guide, and every section on it is
|
|
455
|
+
the engine's: Theme, Modals, Tricks, Tasks. An app grows a **fifth section, its
|
|
456
|
+
own**, by defining one partial:
|
|
457
|
+
|
|
458
|
+
```erb
|
|
459
|
+
<%# app/views/style/host/_modals.html.erb %>
|
|
460
|
+
<div class="card p-4 space-y-3">
|
|
461
|
+
<p class="label-upper">Wallet setup</p>
|
|
462
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
463
|
+
@click="$store.modals.open('wallet-setup', { returnUrl: window.location.href })">
|
|
464
|
+
Open
|
|
465
|
+
</button>
|
|
466
|
+
</div>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
That is the whole registration — the same optional-partial convention as
|
|
470
|
+
`modals/_host_extras`, resolved by the same three-term `lookup_context.exists?`.
|
|
471
|
+
An app that ships no such file gets no section, no heading, and no nav pill; its
|
|
472
|
+
page is byte-for-byte what it was.
|
|
473
|
+
|
|
474
|
+
**The gem keeps dictating the structure.** It renders the section element, the
|
|
475
|
+
`#host-modals` anchor, the heading (your `Studio.app_name`), the intro line, and
|
|
476
|
+
the sticky nav pill, and it places the section between Modals and Tricks. Your
|
|
477
|
+
partial supplies specimens only — no `<section>`, no `<h2>`, nothing to register
|
|
478
|
+
in a nav.
|
|
479
|
+
|
|
480
|
+
**Drive `$store.modals`, not `dsModals`.** The guide's own Modals section stands
|
|
481
|
+
up a page-scoped store because its specimens are the *engine's* ids, and pushing
|
|
482
|
+
an id your layout host has never registered opens that host onto an empty card.
|
|
483
|
+
Your own ids are already registered there, so
|
|
484
|
+
`$store.modals.open(id, props)` renders the **real** card, in production chrome,
|
|
485
|
+
with production behavior. There is nothing to mirror and no second registration
|
|
486
|
+
list to keep in sync — building one is the mistake this note exists to prevent.
|
|
487
|
+
This assumes your layout mounts the shared host. Not every consuming app does
|
|
488
|
+
(see "Page-scoped hosts" above); where none is mounted `$store.modals` is
|
|
489
|
+
undefined, so mount `studio/modals/host` before writing a trigger.
|
|
490
|
+
|
|
491
|
+
Full contract, including what the partial may assume: the doc comment at the top
|
|
492
|
+
of `app/views/style/_host.html.erb`.
|
|
493
|
+
|
|
452
494
|
### User nav slots
|
|
453
495
|
|
|
454
496
|
`components/_user_nav.html.erb` renders the right-side navbar user section.
|
|
@@ -12,8 +12,18 @@
|
|
|
12
12
|
Locals (all optional, defaults via local_assigns.fetch):
|
|
13
13
|
submit_path — POST target for the name (default "/onboarding/first_name")
|
|
14
14
|
skip_path — POST target for the skip (default "/onboarding/skip_first_name")
|
|
15
|
+
required — hide the SKIP affordances (default false, which keeps today's
|
|
16
|
+
skippable card). The × then merely CLOSES and is labelled
|
|
17
|
+
Close, and the "Skip for now" button is not rendered. For a
|
|
18
|
+
host that GATES something on the name: offering to skip would
|
|
19
|
+
promise a way past a wall that does not move. Closing stays
|
|
20
|
+
available — a required step abandons the flow, it never traps
|
|
21
|
+
the user in it.
|
|
15
22
|
heading — (default "What should we call you?")
|
|
16
|
-
subtext — the one-line why (default speaks about emails
|
|
23
|
+
subtext — the one-line why (default speaks about emails; the DEFAULT
|
|
24
|
+
follows `required`, because a card that cannot be skipped is
|
|
25
|
+
talking to a different audience — a host that passes its own
|
|
26
|
+
subtext still wins)
|
|
17
27
|
placeholder — (default "Alex")
|
|
18
28
|
max_length — (default Studio::FULL_NAME_MAX_LENGTH; the server stays the
|
|
19
29
|
real bound, and it REFUSES past that rather than truncating,
|
|
@@ -40,15 +50,27 @@
|
|
|
40
50
|
<%
|
|
41
51
|
submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
|
|
42
52
|
skip_path = local_assigns.fetch(:skip_path, "/onboarding/skip_first_name")
|
|
53
|
+
required = local_assigns.fetch(:required, false)
|
|
43
54
|
heading = local_assigns.fetch(:heading, "What should we call you?")
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
default_subtext = if required
|
|
56
|
+
"One last thing — just your first name, so we can address you in emails."
|
|
57
|
+
else
|
|
58
|
+
"Just your first name — we use it to address you in emails."
|
|
59
|
+
end
|
|
60
|
+
subtext = local_assigns.fetch(:subtext, default_subtext)
|
|
46
61
|
placeholder = local_assigns.fetch(:placeholder, "Alex")
|
|
47
62
|
max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
|
|
48
63
|
progress = local_assigns.fetch(:progress, nil)
|
|
49
64
|
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
50
65
|
done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
|
|
51
66
|
field_id = local_assigns.fetch(:id, "onboarding-first-name")
|
|
67
|
+
# The × mirrors the skip affordance it sits beside: it SKIPS while the step is
|
|
68
|
+
# skippable, and merely CLOSES once it is required. Resolved here rather than in
|
|
69
|
+
# an Alpine expression because `required` is known at render time — and a skip
|
|
70
|
+
# button hidden with x-show is still in the DOM, and still clickable, for as
|
|
71
|
+
# long as Alpine has not mounted.
|
|
72
|
+
dismiss_action = required ? "$store.#{modal_store}.close()" : "skip()"
|
|
73
|
+
dismiss_label = required ? "Close" : "Skip"
|
|
52
74
|
%>
|
|
53
75
|
<div x-data="{
|
|
54
76
|
get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
|
|
@@ -101,10 +123,15 @@
|
|
|
101
123
|
<h3 class="text-heading font-bold text-lg leading-tight text-center pt-1"><%= heading %></h3>
|
|
102
124
|
<%# The × SKIPS rather than merely closing: this lands seconds after signup,
|
|
103
125
|
and a close that silently abandons the chain is how a host ends up with a
|
|
104
|
-
step nobody can answer again.
|
|
105
|
-
|
|
126
|
+
step nobody can answer again.
|
|
127
|
+
|
|
128
|
+
When the host passes required: true it merely CLOSES, and says so. That
|
|
129
|
+
step is gating something the host will not let through without a name, so
|
|
130
|
+
recording a skip would promise a way past a wall that does not move.
|
|
131
|
+
Closing still works — it abandons the flow rather than slipping past it. %>
|
|
132
|
+
<button @click="<%= dismiss_action %>"
|
|
106
133
|
class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
|
|
107
|
-
aria-label="
|
|
134
|
+
aria-label="<%= dismiss_label %>">×</button>
|
|
108
135
|
</div>
|
|
109
136
|
<% if progress %>
|
|
110
137
|
<%= render "studio/modals/blocks/progress_pill", current: progress.first, total: progress.last %>
|
|
@@ -134,8 +161,13 @@
|
|
|
134
161
|
</button>
|
|
135
162
|
</form>
|
|
136
163
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
164
|
+
<%# Gone entirely when required — see the × above for why offering it there
|
|
165
|
+
would promise a way past a wall that does not move. Omitted rather than
|
|
166
|
+
hidden, for the same reason the × is resolved server-side. %>
|
|
167
|
+
<% unless required %>
|
|
168
|
+
<button type="button" @click="skip()" :disabled="submitting"
|
|
169
|
+
class="mt-3 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
170
|
+
Skip for now
|
|
171
|
+
</button>
|
|
172
|
+
<% end %>
|
|
141
173
|
</div>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<%# The HOST section of the living style guide (admin/style).
|
|
2
|
+
|
|
3
|
+
THE ONE SEAM a consuming app has for growing its own specimens onto this
|
|
4
|
+
page. Every other section on this page is the gem's. This one is the app's,
|
|
5
|
+
and the gem still dictates its structure: the section element, the anchor,
|
|
6
|
+
the heading, the intro line, and the nav pill above are all rendered HERE,
|
|
7
|
+
from the engine. The app supplies content only.
|
|
8
|
+
|
|
9
|
+
"Host" means the CONSUMING APP, the same sense it carries in the modal
|
|
10
|
+
host's own seam (modals/_host_extras). It does NOT mean the modal-host
|
|
11
|
+
shell, which is studio/modals/_host - a different file and a different
|
|
12
|
+
meaning of the word.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
THE CONTRACT - what a consuming app writes
|
|
16
|
+
------------------------------------------
|
|
17
|
+
Define ONE partial in the app:
|
|
18
|
+
|
|
19
|
+
app/views/style/host/_modals.html.erb
|
|
20
|
+
|
|
21
|
+
That is the whole registration. There is no initializer, no locals, no
|
|
22
|
+
list to append to, and nothing to render from the app's own layout. The
|
|
23
|
+
guide finds the partial by CONVENTION, exactly the way the modal host
|
|
24
|
+
finds modals/_host_extras:
|
|
25
|
+
|
|
26
|
+
lookup_context.exists?("modals", ["style/host"], true)
|
|
27
|
+
|
|
28
|
+
- the same three-term form: name, prefixes, partial. That call lives in
|
|
29
|
+
style/index.html.erb, which owns it because the sticky section nav needs
|
|
30
|
+
the same answer the render does. An app that ships no such partial gets no
|
|
31
|
+
pill, no heading, no container, and no bytes: the guide is unchanged.
|
|
32
|
+
|
|
33
|
+
WHY THIS PATH. A directory (style/host/) rather than a flat name in the
|
|
34
|
+
gem's own style/ prefix, because the gem already ships style/_modals,
|
|
35
|
+
style/_theme, style/_tricks and style/_tasks - an app file dropped beside
|
|
36
|
+
them shadows a section by name, and the failure is silent. Nothing of the
|
|
37
|
+
gem's lives under style/host/, so an app cannot collide with the engine
|
|
38
|
+
there. Naming the partial for the SECTION it contributes (modals) rather
|
|
39
|
+
than for the seam leaves room for a later style/host/_tricks without
|
|
40
|
+
renaming what is already shipped.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
WHAT THE PARTIAL MAY ASSUME
|
|
44
|
+
---------------------------
|
|
45
|
+
- It renders INSIDE this section element, already anchored, spaced and
|
|
46
|
+
headed. So it supplies cards and prose only: no section tag of its own,
|
|
47
|
+
no h2, no scroll-margin, no nav entry.
|
|
48
|
+
- The engine component layer (engine.css) and motion layer
|
|
49
|
+
(engine-motion.css) are loaded, so card, btn, badge, label-upper, the
|
|
50
|
+
role tokens and the motion/effect primitives all style for real.
|
|
51
|
+
- Alpine is running and this section carries an x-data scope, so a
|
|
52
|
+
specimen can use x-on / at-click bindings directly. A plain onclick
|
|
53
|
+
calling Alpine.store works too.
|
|
54
|
+
- NOT that a shared modal host exists. Only an app whose layout renders
|
|
55
|
+
studio/modals/host has Alpine.store("modals") at all: mcritchie-studio
|
|
56
|
+
and turf-monster mount it; acquisition-studio, mcritchie-industries and
|
|
57
|
+
moms-app render none (the same split README's "Page-scoped hosts" and
|
|
58
|
+
studio/emails/index.html.erb already name). On an app with no shared
|
|
59
|
+
host $store.modals is UNDEFINED and a trigger throws on click, so mount
|
|
60
|
+
studio/modals/host in the layout first - or give the page its own host
|
|
61
|
+
with studio/modals/scoped_host and drive that instead.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
WHICH STORE IT DRIVES - read this before writing a trigger
|
|
65
|
+
----------------------------------------------------------
|
|
66
|
+
Drive the app's REAL store:
|
|
67
|
+
|
|
68
|
+
$store.modals.open(id, props)
|
|
69
|
+
|
|
70
|
+
NOT dsModals. dsModals is the page-scoped host that the gem's own Modals
|
|
71
|
+
section above stands up, and copying it here is the one wrong turn this
|
|
72
|
+
seam invites.
|
|
73
|
+
|
|
74
|
+
The gem's section needs dsModals for a reason that does NOT apply to an
|
|
75
|
+
app's own modals. Its specimens are the ENGINE's ids, and an app's layout
|
|
76
|
+
host registers only that app's ids under an unconditional template x-if on
|
|
77
|
+
the current modal. Pushing a NOVEL id onto the app's store would open that
|
|
78
|
+
host onto a card it has no registration for, painting an EMPTY panel behind
|
|
79
|
+
the specimen. So the gem's section owns a private store and registers all
|
|
80
|
+
of its specimen content itself.
|
|
81
|
+
|
|
82
|
+
An app's own modal ids are already registered in that app's layout host.
|
|
83
|
+
Push one onto $store.modals and the real host renders the real card - the
|
|
84
|
+
production card, in production chrome, with production behaviour. There is
|
|
85
|
+
nothing to mirror, no second registration list to keep in sync, and no
|
|
86
|
+
iframe. Turf Monster's /admin/modals gallery has driven the real store from
|
|
87
|
+
a plain button this way since it was built; this seam is that same move,
|
|
88
|
+
given a home on the guide.
|
|
89
|
+
|
|
90
|
+
A reader who assumes dsModals will build a mirror instead: a second copy of
|
|
91
|
+
every card, free to drift from the one the app actually ships, which is the
|
|
92
|
+
opposite of what a living style guide is for. %>
|
|
93
|
+
<section id="host-modals" class="space-y-8" x-data="{}"
|
|
94
|
+
style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
|
|
95
|
+
<div class="space-y-1">
|
|
96
|
+
<h2 class="text-2xl font-bold text-heading"><%= Studio.app_name %></h2>
|
|
97
|
+
<p class="text-muted text-sm max-w-2xl">
|
|
98
|
+
This app’s own modals, contributed by
|
|
99
|
+
<code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">app/views/style/host/_modals.html.erb</code>.
|
|
100
|
+
Every section above belongs to the engine and is inherited; everything
|
|
101
|
+
here belongs to <%= Studio.app_name %> and ships with it. These specimens
|
|
102
|
+
drive the app’s <strong>real</strong>
|
|
103
|
+
<code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded">$store.modals</code>
|
|
104
|
+
host, so the card that opens here is the card that opens in production.
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<%# The app's content. Reached only when it resolves - style/index.html.erb
|
|
109
|
+
owns that guard, because the section nav needs the same answer. %>
|
|
110
|
+
<%= render "style/host/modals" %>
|
|
111
|
+
</section>
|
|
@@ -211,7 +211,20 @@
|
|
|
211
211
|
// slide uses the modal-card-swap-* classes regardless of these keys.
|
|
212
212
|
var CLOSE_ANIM_MS = 220;
|
|
213
213
|
var SWAP_IN_MS = 220;
|
|
214
|
-
|
|
214
|
+
|
|
215
|
+
// THE LIVE REGISTRY IS window.ModalAnimations, NOT THIS TABLE. This is a
|
|
216
|
+
// fallback only, for the case where the guide renders without the shared
|
|
217
|
+
// host (studio/modals/_host) that publishes the registry — both consumer
|
|
218
|
+
// layouts mount it, so in practice the lookup below always finds it.
|
|
219
|
+
//
|
|
220
|
+
// It used to be a hard-coded local copy, and that made the Enter/Leave
|
|
221
|
+
// simulator below a LIAR: the simulator builds its controls from the live
|
|
222
|
+
// registry, so a newly registered animation grew a button here while the
|
|
223
|
+
// store silently resolved the unknown key back to 'pop'. The control said
|
|
224
|
+
// "shake", the card popped, and nothing reported the gap. Reading the
|
|
225
|
+
// registry at CALL time is what makes a registered animation actually
|
|
226
|
+
// playable on this page.
|
|
227
|
+
var ANIM_FALLBACK = {
|
|
215
228
|
enter: {
|
|
216
229
|
pop: { cls: 'modal-card-mount', ms: 320 },
|
|
217
230
|
shake: { cls: 'modal-card-shake-in', ms: 600 },
|
|
@@ -222,9 +235,13 @@
|
|
|
222
235
|
slide: { cls: 'modal-card-swap-out', ms: 220 }
|
|
223
236
|
}
|
|
224
237
|
};
|
|
238
|
+
// Same late-binding guard, and for the same reason, as the host's own
|
|
239
|
+
// modalAnim: never return undefined. A miss would throw on .ms in close()
|
|
240
|
+
// and strand the modal open, so an unknown key falls back to 'pop'.
|
|
225
241
|
function modalAnim(channel, key) {
|
|
226
|
-
var table =
|
|
227
|
-
|
|
242
|
+
var table = (window.ModalAnimations && window.ModalAnimations[channel]) ||
|
|
243
|
+
ANIM_FALLBACK[channel];
|
|
244
|
+
return table[key] || table.pop || ANIM_FALLBACK[channel].pop;
|
|
228
245
|
}
|
|
229
246
|
|
|
230
247
|
Alpine.store('dsModals', {
|
|
@@ -766,7 +783,7 @@
|
|
|
766
783
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
767
784
|
<%= render layout: "style/modal_specimen", locals: {
|
|
768
785
|
label: "First name",
|
|
769
|
-
reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain),
|
|
786
|
+
reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain. A host that GATES on the name passes required: true, which drops BOTH skip affordances and leaves the × merely closing — this specimen shows the default, skippable card),
|
|
770
787
|
open_expr: "$store.dsModals.open('onboarding-first-name')",
|
|
771
788
|
glow_when: ds_glow.call("onboarding-first-name")
|
|
772
789
|
} do %>
|
|
@@ -1914,4 +1931,355 @@
|
|
|
1914
1931
|
</div>
|
|
1915
1932
|
</section>
|
|
1916
1933
|
|
|
1934
|
+
<%# ===================================================================== %>
|
|
1935
|
+
<%# 8. MOTION REGISTRY (the enter / leave animation simulator) %>
|
|
1936
|
+
<%# ===================================================================== %>
|
|
1937
|
+
<section id="modals-motion-registry" class="space-y-5">
|
|
1938
|
+
<div class="space-y-1">
|
|
1939
|
+
<h3 class="text-xl font-bold text-heading">Enter & leave animations</h3>
|
|
1940
|
+
<p class="text-muted text-sm">
|
|
1941
|
+
The per-modal entrance/exit registry —
|
|
1942
|
+
<code class="font-mono text-2xs">window.ModalAnimations</code>, published by
|
|
1943
|
+
<code class="font-mono text-2xs">studio/modals/_host</code>. Any modal opens with
|
|
1944
|
+
<code class="font-mono text-2xs">open(id, { enterAnim, exitAnim })</code>; omit either
|
|
1945
|
+
and it uses <code class="font-mono text-2xs">pop</code>. Every control below is
|
|
1946
|
+
<strong class="text-heading font-semibold">generated from the live registry</strong>,
|
|
1947
|
+
so registering a new animation surfaces it here with no edit to this page. Demo card:
|
|
1948
|
+
<code class="font-mono text-2xs">email-change-pending</code>.
|
|
1949
|
+
</p>
|
|
1950
|
+
</div>
|
|
1951
|
+
|
|
1952
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
1953
|
+
<%# One control per registered ENTER key, built by the script below. %>
|
|
1954
|
+
<div class="card p-5 space-y-3">
|
|
1955
|
+
<h4 class="text-heading font-semibold text-sm">
|
|
1956
|
+
Enter <span class="text-muted text-xs font-normal">— watch it arrive</span>
|
|
1957
|
+
</h4>
|
|
1958
|
+
<p class="text-secondary text-xs">
|
|
1959
|
+
Opens the demo card with that entrance. Dismiss with Escape, click-outside, or Close.
|
|
1960
|
+
</p>
|
|
1961
|
+
<div id="modal-anim-enter-buttons" class="flex flex-wrap gap-2"></div>
|
|
1962
|
+
</div>
|
|
1963
|
+
|
|
1964
|
+
<%# One control per registered EXIT key: opens with a plain entrance, then
|
|
1965
|
+
auto-closes a beat later so the exit keyframe is what you watch. %>
|
|
1966
|
+
<div class="card p-5 space-y-3">
|
|
1967
|
+
<h4 class="text-heading font-semibold text-sm">
|
|
1968
|
+
Leave <span class="text-muted text-xs font-normal">— watch it dismiss</span>
|
|
1969
|
+
</h4>
|
|
1970
|
+
<p class="text-secondary text-xs">
|
|
1971
|
+
Opens with a plain entrance, then closes itself so the exit keyframe is the star.
|
|
1972
|
+
</p>
|
|
1973
|
+
<div id="modal-anim-exit-buttons" class="flex flex-wrap gap-2"></div>
|
|
1974
|
+
</div>
|
|
1975
|
+
|
|
1976
|
+
<%# The full matrix: any entrance crossed with any exit. %>
|
|
1977
|
+
<div class="card p-5 space-y-3 sm:col-span-2">
|
|
1978
|
+
<h4 class="text-heading font-semibold text-sm">Combine</h4>
|
|
1979
|
+
<p class="text-secondary text-xs">Pick any entrance and exit, then run the round trip.</p>
|
|
1980
|
+
<div class="flex flex-wrap items-end gap-3">
|
|
1981
|
+
<label class="text-xs text-secondary">Enter
|
|
1982
|
+
<select id="modal-anim-enter-select"
|
|
1983
|
+
class="block mt-1 text-sm bg-inset border border-subtle rounded px-2 py-1 text-heading"></select>
|
|
1984
|
+
</label>
|
|
1985
|
+
<label class="text-xs text-secondary">Leave
|
|
1986
|
+
<select id="modal-anim-exit-select"
|
|
1987
|
+
class="block mt-1 text-sm bg-inset border border-subtle rounded px-2 py-1 text-heading"></select>
|
|
1988
|
+
</label>
|
|
1989
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
1990
|
+
onclick="dsModalDemos.animOpenSelected()">Open</button>
|
|
1991
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
1992
|
+
onclick="dsModalDemos.animRoundTripSelected()">Open, then auto-close</button>
|
|
1993
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
1994
|
+
@click="$store.dsModals.close()">Close</button>
|
|
1995
|
+
</div>
|
|
1996
|
+
</div>
|
|
1997
|
+
</div>
|
|
1998
|
+
</section>
|
|
1999
|
+
|
|
2000
|
+
<%# ===================================================================== %>
|
|
2001
|
+
<%# 9. STACK MECHANICS (dismissibility, hold-at-least, LIFO stacking) %>
|
|
2002
|
+
<%# ===================================================================== %>
|
|
2003
|
+
<section id="modals-stack-mechanics" class="space-y-5">
|
|
2004
|
+
<div class="space-y-1">
|
|
2005
|
+
<h3 class="text-xl font-bold text-heading">Stack behaviour</h3>
|
|
2006
|
+
<p class="text-muted text-sm">
|
|
2007
|
+
The engine modal-stack mechanics, poked at directly on
|
|
2008
|
+
<code class="font-mono text-2xs">$store.dsModals</code>: the on-chain TX card states,
|
|
2009
|
+
dismissibility, the minimum-visible-duration floor, and LIFO stacking.
|
|
2010
|
+
</p>
|
|
2011
|
+
</div>
|
|
2012
|
+
|
|
2013
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
2014
|
+
<%# The three states of style/modals/_onchain_tx. The two static buttons open
|
|
2015
|
+
the card AT a state; the two timed buttons drive the transition through
|
|
2016
|
+
the store's advance(), which is the in-flow step primitive. %>
|
|
2017
|
+
<div class="card p-5 space-y-3">
|
|
2018
|
+
<h4 class="text-heading font-semibold text-sm">On-chain TX states</h4>
|
|
2019
|
+
<p class="text-secondary text-xs">
|
|
2020
|
+
Engine <code class="font-mono text-2xs">blocks/_card_header</code> and
|
|
2021
|
+
<code class="font-mono text-2xs">blocks/_onchain_success</code>. The timed pair
|
|
2022
|
+
transitions a live card with
|
|
2023
|
+
<code class="font-mono text-2xs">advance()</code>.
|
|
2024
|
+
</p>
|
|
2025
|
+
<div class="flex flex-wrap gap-2">
|
|
2026
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2027
|
+
onclick="dsModalDemos.processing()">Processing</button>
|
|
2028
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2029
|
+
onclick="dsModalDemos.success()">Success</button>
|
|
2030
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2031
|
+
onclick="dsModalDemos.error()">Error</button>
|
|
2032
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
2033
|
+
onclick="dsModalDemos.processThenSuccess()">Processing to success (3s)</button>
|
|
2034
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
2035
|
+
onclick="dsModalDemos.processThenError()">Processing to error (3s)</button>
|
|
2036
|
+
</div>
|
|
2037
|
+
</div>
|
|
2038
|
+
|
|
2039
|
+
<%# dismissible: false is what a pending transaction sets so a stray Escape
|
|
2040
|
+
cannot orphan an in-flight signature. %>
|
|
2041
|
+
<div class="card p-5 space-y-3">
|
|
2042
|
+
<h4 class="text-heading font-semibold text-sm">Dismissibility</h4>
|
|
2043
|
+
<p class="text-secondary text-xs">
|
|
2044
|
+
A processing card passes <code class="font-mono text-2xs">dismissible: false</code>, so
|
|
2045
|
+
Escape and click-outside are both suppressed until it resolves.
|
|
2046
|
+
</p>
|
|
2047
|
+
<div class="flex flex-wrap gap-2">
|
|
2048
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2049
|
+
onclick="dsModalDemos.processing()">Try Escape (locked)</button>
|
|
2050
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2051
|
+
onclick="dsModalDemos.dismissible()">Dismissible processing</button>
|
|
2052
|
+
</div>
|
|
2053
|
+
</div>
|
|
2054
|
+
|
|
2055
|
+
<%# The anti-flicker floor from studio/modals/_load_convention. %>
|
|
2056
|
+
<div class="card p-5 space-y-3">
|
|
2057
|
+
<h4 class="text-heading font-semibold text-sm">Hold at least</h4>
|
|
2058
|
+
<p class="text-secondary text-xs">
|
|
2059
|
+
<code class="font-mono text-2xs">StudioModals.holdAtLeast(ms)</code> keeps a fast
|
|
2060
|
+
operation from flashing the spinner. Run both and watch the difference.
|
|
2061
|
+
</p>
|
|
2062
|
+
<div class="flex flex-wrap gap-2">
|
|
2063
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
2064
|
+
onclick="dsModalDemos.fastWithHold()">Fast op, hold 1500ms</button>
|
|
2065
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2066
|
+
onclick="dsModalDemos.fastNoHold()">Fast op, no hold (flashes)</button>
|
|
2067
|
+
</div>
|
|
2068
|
+
</div>
|
|
2069
|
+
|
|
2070
|
+
<%# LIFO: open pushes, close pops back to what was underneath. %>
|
|
2071
|
+
<div class="card p-5 space-y-3">
|
|
2072
|
+
<h4 class="text-heading font-semibold text-sm">Stacking</h4>
|
|
2073
|
+
<p class="text-secondary text-xs">
|
|
2074
|
+
LIFO — a second <code class="font-mono text-2xs">open()</code> pushes the first
|
|
2075
|
+
card behind it, and <code class="font-mono text-2xs">close()</code> pops back to it.
|
|
2076
|
+
</p>
|
|
2077
|
+
<div class="flex flex-wrap gap-2">
|
|
2078
|
+
<button type="button" class="btn btn-primary btn-sm"
|
|
2079
|
+
onclick="dsModalDemos.stackTwo()">Open two</button>
|
|
2080
|
+
<button type="button" class="btn btn-outline btn-sm"
|
|
2081
|
+
@click="$store.dsModals.closeAll()">closeAll()</button>
|
|
2082
|
+
</div>
|
|
2083
|
+
</div>
|
|
2084
|
+
</div>
|
|
2085
|
+
</section>
|
|
2086
|
+
|
|
2087
|
+
<%# ---- Demo drivers for the two sections above -------------------------
|
|
2088
|
+
Deliberately NOT an Alpine component: these are multi-step sequences with
|
|
2089
|
+
timers, and an x-data scope would be cloned per specimen card. One global
|
|
2090
|
+
namespace, defined after the markup it drives so the control containers
|
|
2091
|
+
exist when the registry build runs.
|
|
2092
|
+
|
|
2093
|
+
THE STORE IS dsModals. turf-monster's version of this page drove roughly
|
|
2094
|
+
half of these through its legacy solana-modal compatibility proxy, a store
|
|
2095
|
+
this engine does not ship; every demo here goes through the page-scoped
|
|
2096
|
+
store and the onchain-tx specimen directly instead. The exact spelling of
|
|
2097
|
+
that legacy store name is deliberately absent from this file, because
|
|
2098
|
+
style_guide_modal_simulator_test scans the source for it. %>
|
|
2099
|
+
<script>
|
|
2100
|
+
window.dsModalDemos = (function () {
|
|
2101
|
+
var SAMPLE_TX = '5xTrDemoSignature1234567890abcXYZ';
|
|
2102
|
+
|
|
2103
|
+
function store() { return Alpine.store('dsModals'); }
|
|
2104
|
+
|
|
2105
|
+
// Open the onchain-tx specimen. The defaults mirror a real pending
|
|
2106
|
+
// transaction, INCLUDING dismissible: false — the demos that want an
|
|
2107
|
+
// escapable card override it explicitly.
|
|
2108
|
+
function openTx(props) {
|
|
2109
|
+
store().open('onchain-tx', Object.assign({
|
|
2110
|
+
state: 'processing',
|
|
2111
|
+
title: 'Submitting entry',
|
|
2112
|
+
message: 'Awaiting wallet signature',
|
|
2113
|
+
dismissible: false
|
|
2114
|
+
}, props || {}));
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// In-flow state change on the card that is already open. advance() patches
|
|
2118
|
+
// props WITHOUT replacing the stack entry, so the specimen's x-data scope
|
|
2119
|
+
// survives the transition. Guarded to a still-open onchain-tx card: a timer
|
|
2120
|
+
// that fires after the user closed it must do nothing rather than patch
|
|
2121
|
+
// whatever card happens to be on top now.
|
|
2122
|
+
function advanceTx(patch) {
|
|
2123
|
+
var cur = store().current();
|
|
2124
|
+
if (!cur || cur.id !== 'onchain-tx' || cur._closing) return;
|
|
2125
|
+
store().advance(patch);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
var SUCCESS = {
|
|
2129
|
+
state: 'success',
|
|
2130
|
+
txSignature: SAMPLE_TX,
|
|
2131
|
+
successTitle: 'Entry confirmed',
|
|
2132
|
+
successSubtitle: 'Your entry is confirmed on-chain.',
|
|
2133
|
+
ctaLabel: 'View wallet',
|
|
2134
|
+
ctaHref: null,
|
|
2135
|
+
dismissible: true
|
|
2136
|
+
};
|
|
2137
|
+
var FAILURE = {
|
|
2138
|
+
state: 'error',
|
|
2139
|
+
title: 'Submission failed',
|
|
2140
|
+
errorMessage: 'Transaction simulation failed: insufficient SOL for rent.',
|
|
2141
|
+
dismissible: true
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
// === Enter / leave simulator =====================================
|
|
2145
|
+
// The demo vehicle is the always-registered email-change-pending card, so
|
|
2146
|
+
// only the motion varies between runs.
|
|
2147
|
+
var ANIM_DEMO_ID = 'email-change-pending';
|
|
2148
|
+
var ANIM_DEMO_PROPS = { currentEmail: 'you@example.com', newEmail: 'new@example.com' };
|
|
2149
|
+
|
|
2150
|
+
function animOpen(enterAnim, exitAnim) {
|
|
2151
|
+
store().open(ANIM_DEMO_ID, Object.assign({}, ANIM_DEMO_PROPS, {
|
|
2152
|
+
enterAnim: enterAnim, exitAnim: exitAnim
|
|
2153
|
+
}));
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Open, let the entrance settle, then close so the chosen EXIT keyframe is
|
|
2157
|
+
// what you watch. The hold is the entrance's own registry duration plus a
|
|
2158
|
+
// beat, read from the live registry rather than assumed.
|
|
2159
|
+
function animRoundTrip(enterAnim, exitAnim) {
|
|
2160
|
+
animOpen(enterAnim, exitAnim);
|
|
2161
|
+
var reg = window.ModalAnimations || { enter: {}, exit: {} };
|
|
2162
|
+
var enterMs = ((reg.enter[enterAnim] || {}).ms) || 320;
|
|
2163
|
+
setTimeout(function () { store().close(); }, enterMs + 700);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function el(id) { return document.getElementById(id); }
|
|
2167
|
+
|
|
2168
|
+
// Build the quick buttons and the dropdown options FROM THE LIVE REGISTRY.
|
|
2169
|
+
// This is the property the section exists to demonstrate: a newly
|
|
2170
|
+
// registered animation key grows a control here with no edit to this file.
|
|
2171
|
+
// Idempotent — it clears each container first, so re-running on a Turbo
|
|
2172
|
+
// visit cannot double the controls.
|
|
2173
|
+
function buildAnimControls() {
|
|
2174
|
+
var reg = window.ModalAnimations || { enter: {}, exit: {} };
|
|
2175
|
+
|
|
2176
|
+
var label = function (key) {
|
|
2177
|
+
return key.charAt(0).toUpperCase() + key.slice(1) + (key === 'pop' ? ' (default)' : '');
|
|
2178
|
+
};
|
|
2179
|
+
var addOption = function (sel, key) {
|
|
2180
|
+
if (!sel) return;
|
|
2181
|
+
var o = document.createElement('option');
|
|
2182
|
+
o.value = key;
|
|
2183
|
+
o.textContent = label(key);
|
|
2184
|
+
if (key === 'pop') o.selected = true;
|
|
2185
|
+
sel.appendChild(o);
|
|
2186
|
+
};
|
|
2187
|
+
var addButton = function (container, key, onClick, suffix) {
|
|
2188
|
+
if (!container) return;
|
|
2189
|
+
var b = document.createElement('button');
|
|
2190
|
+
b.type = 'button';
|
|
2191
|
+
b.className = 'btn btn-outline btn-sm';
|
|
2192
|
+
b.textContent = label(key) + (suffix || '');
|
|
2193
|
+
b.onclick = onClick;
|
|
2194
|
+
container.appendChild(b);
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
var enterBtns = el('modal-anim-enter-buttons');
|
|
2198
|
+
var exitBtns = el('modal-anim-exit-buttons');
|
|
2199
|
+
var enterSel = el('modal-anim-enter-select');
|
|
2200
|
+
var exitSel = el('modal-anim-exit-select');
|
|
2201
|
+
|
|
2202
|
+
[enterBtns, exitBtns, enterSel, exitSel].forEach(function (node) {
|
|
2203
|
+
if (node) node.innerHTML = '';
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
Object.keys(reg.enter || {}).forEach(function (key) {
|
|
2207
|
+
addButton(enterBtns, key, function () { animOpen(key, 'pop'); }, ' ↗');
|
|
2208
|
+
addOption(enterSel, key);
|
|
2209
|
+
});
|
|
2210
|
+
Object.keys(reg.exit || {}).forEach(function (key) {
|
|
2211
|
+
addButton(exitBtns, key, function () { animRoundTrip('pop', key); });
|
|
2212
|
+
addOption(exitSel, key);
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// WHEN TO BUILD. window.ModalAnimations is published by the shared host's
|
|
2217
|
+
// inline script, which a host layout may render AFTER this one — so the
|
|
2218
|
+
// build waits for the rest of the body either way:
|
|
2219
|
+
// full load — DOMContentLoaded, the host script has run by then;
|
|
2220
|
+
// Turbo visit — DOMContentLoaded already fired and never fires again, so
|
|
2221
|
+
// fall through to a macrotask, which runs after the body
|
|
2222
|
+
// swap finishes inserting its scripts.
|
|
2223
|
+
// turbo:load covers the remaining visits; the build is idempotent, so the
|
|
2224
|
+
// overlap between these paths is redundant work and nothing worse.
|
|
2225
|
+
if (document.readyState === 'loading') {
|
|
2226
|
+
document.addEventListener('DOMContentLoaded', buildAnimControls);
|
|
2227
|
+
} else {
|
|
2228
|
+
setTimeout(buildAnimControls, 0);
|
|
2229
|
+
}
|
|
2230
|
+
if (!window.__dsAnimControlsBound) {
|
|
2231
|
+
window.__dsAnimControlsBound = true;
|
|
2232
|
+
document.addEventListener('turbo:load', buildAnimControls);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
return {
|
|
2236
|
+
// Stack behaviour
|
|
2237
|
+
processing: function () { openTx({}); },
|
|
2238
|
+
success: function () { openTx(SUCCESS); },
|
|
2239
|
+
error: function () { openTx(FAILURE); },
|
|
2240
|
+
processThenSuccess: function () {
|
|
2241
|
+
openTx({ message: 'Awaiting RPC confirmation' });
|
|
2242
|
+
setTimeout(function () { advanceTx(SUCCESS); }, 3000);
|
|
2243
|
+
},
|
|
2244
|
+
processThenError: function () {
|
|
2245
|
+
openTx({ message: 'Awaiting RPC confirmation' });
|
|
2246
|
+
setTimeout(function () { advanceTx(FAILURE); }, 3000);
|
|
2247
|
+
},
|
|
2248
|
+
dismissible: function () {
|
|
2249
|
+
openTx({
|
|
2250
|
+
dismissible: true,
|
|
2251
|
+
title: 'Dismissible processing',
|
|
2252
|
+
message: 'Escape and click-outside both close this one.'
|
|
2253
|
+
});
|
|
2254
|
+
},
|
|
2255
|
+
fastWithHold: function () {
|
|
2256
|
+
openTx({ title: 'Fast operation', message: 'Holding the spinner for at least 1500ms' });
|
|
2257
|
+
window.StudioModals.holdAtLeast(1500).then(function () { advanceTx(SUCCESS); });
|
|
2258
|
+
},
|
|
2259
|
+
fastNoHold: function () {
|
|
2260
|
+
openTx({ title: 'Fast operation', message: 'No hold, so this flashes past' });
|
|
2261
|
+
setTimeout(function () { advanceTx(SUCCESS); }, 50);
|
|
2262
|
+
},
|
|
2263
|
+
stackTwo: function () {
|
|
2264
|
+
openTx({
|
|
2265
|
+
dismissible: true,
|
|
2266
|
+
title: 'Bottom of the stack',
|
|
2267
|
+
message: 'A second card is about to push in front of this one.'
|
|
2268
|
+
});
|
|
2269
|
+
setTimeout(function () {
|
|
2270
|
+
store().open(ANIM_DEMO_ID, ANIM_DEMO_PROPS);
|
|
2271
|
+
}, 700);
|
|
2272
|
+
},
|
|
2273
|
+
|
|
2274
|
+
// Enter / leave simulator
|
|
2275
|
+
animOpenSelected: function () {
|
|
2276
|
+
animOpen(el('modal-anim-enter-select').value, el('modal-anim-exit-select').value);
|
|
2277
|
+
},
|
|
2278
|
+
animRoundTripSelected: function () {
|
|
2279
|
+
animRoundTrip(el('modal-anim-enter-select').value, el('modal-anim-exit-select').value);
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
})();
|
|
2283
|
+
</script>
|
|
2284
|
+
|
|
1917
2285
|
</section>
|
|
@@ -3,22 +3,48 @@
|
|
|
3
3
|
application.html.erb, inheriting that app's navbar and theme, so every
|
|
4
4
|
specimen restyles per app and in dark or light automatically.
|
|
5
5
|
|
|
6
|
-
Four sections, reached by the sticky section nav below and rendered
|
|
7
|
-
sibling partials: Theme is the landing section (the color foundation - it
|
|
6
|
+
Four ENGINE sections, reached by the sticky section nav below and rendered
|
|
7
|
+
as sibling partials: Theme is the landing section (the color foundation - it
|
|
8
8
|
owns the role tokens/swatches, the folded-in /admin/theme editor, and the
|
|
9
9
|
live preview), then Modals (the shared host + engine card blocks), Tricks
|
|
10
10
|
(the button/surface/motion/effect/leveling primitives, framed as a board of
|
|
11
11
|
copy-paste-for-an-agent snippets), and Tasks (the shared board primitive).
|
|
12
12
|
|
|
13
|
+
A FIFTH section is the host app's own, and appears only on an app that asks
|
|
14
|
+
for it: define app/views/style/host/_modals.html.erb in the consuming app
|
|
15
|
+
and the guide grows a section (and a nav pill) for that app's own modals,
|
|
16
|
+
between Modals and Tricks. The gem still renders the section chrome; the app
|
|
17
|
+
supplies the specimens. Full contract, including which modal store a host
|
|
18
|
+
specimen drives and why it is not the one the Modals section uses:
|
|
19
|
+
app/views/style/_host.html.erb.
|
|
20
|
+
|
|
13
21
|
Specimens use ONLY the engine's own classes (engine.css component layer plus
|
|
14
22
|
engine-motion.css motion/effect/leveling layer), so a consumer that bundles
|
|
15
23
|
engine.css and imports engine-motion.css styles them for real. The primitive
|
|
16
24
|
CSS is NOT inlined here - the consumer's Tailwind build provides it. Page
|
|
17
25
|
chrome uses the engine's own .card / .label-upper / theme tokens. %>
|
|
18
26
|
<%
|
|
27
|
+
# THE HOST SECTION SEAM. A consuming app grows its OWN modal specimens onto
|
|
28
|
+
# this page by defining app/views/style/host/_modals.html.erb. Found by the
|
|
29
|
+
# same three-term lookup_context.exists?(name, prefixes, partial) the modal
|
|
30
|
+
# host uses for modals/_host_extras (studio/modals/_host.html.erb) - one
|
|
31
|
+
# convention in this codebase, not two.
|
|
32
|
+
#
|
|
33
|
+
# Resolved ONCE, here, because two places need the same answer: the sticky
|
|
34
|
+
# section nav below and the render further down. An app that ships no such
|
|
35
|
+
# partial gets no pill, no heading and no container - the page is what it was.
|
|
36
|
+
# The section chrome and the consumer contract live in style/_host.html.erb.
|
|
37
|
+
host_modals = lookup_context.exists?("modals", ["style/host"], true)
|
|
38
|
+
|
|
19
39
|
sections = [
|
|
20
40
|
["theme", "Theme"],
|
|
21
|
-
["modals", "Modals"]
|
|
41
|
+
["modals", "Modals"]
|
|
42
|
+
]
|
|
43
|
+
# Adjacent to Modals, not appended: this page groups by SUBJECT, and a host's
|
|
44
|
+
# own modals are the same subject as the engine's. Labelled with the app's own
|
|
45
|
+
# name, so the pill says whose section it is.
|
|
46
|
+
sections << ["host-modals", Studio.app_name] if host_modals
|
|
47
|
+
sections += [
|
|
22
48
|
["tricks", "Tricks"],
|
|
23
49
|
["tasks", "Tasks"]
|
|
24
50
|
]
|
|
@@ -54,6 +80,9 @@
|
|
|
54
80
|
<div class="space-y-16 pt-10">
|
|
55
81
|
<%= render "style/theme" %>
|
|
56
82
|
<%= render "style/modals" %>
|
|
83
|
+
<% if host_modals %>
|
|
84
|
+
<%= render "style/host" %>
|
|
85
|
+
<% end %>
|
|
57
86
|
<%= render "style/tricks" %>
|
|
58
87
|
<%= render "style/tasks" %>
|
|
59
88
|
</div>
|
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.71.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -509,6 +509,7 @@ files:
|
|
|
509
509
|
- app/views/studio/profiles/_save_controls.html.erb
|
|
510
510
|
- app/views/studio/profiles/edit.html.erb
|
|
511
511
|
- app/views/studio/profiles/show.html.erb
|
|
512
|
+
- app/views/style/_host.html.erb
|
|
512
513
|
- app/views/style/_modal_specimen.html.erb
|
|
513
514
|
- app/views/style/_modals.html.erb
|
|
514
515
|
- app/views/style/_specimen.html.erb
|