studio-engine 0.70.0 → 0.72.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 +90 -0
- data/README.md +42 -0
- data/app/views/studio/modals/onboarding/_first_name.html.erb +121 -6
- data/app/views/style/_host.html.erb +111 -0
- data/app/views/style/index.html.erb +32 -3
- data/lib/studio/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a53d7cde61dcf497f1dee1542d64ad82714900227b0fb0c98c31df714ab67267
|
|
4
|
+
data.tar.gz: 16d9816dde34b609d14b57f0c526f60883a79641f485775362e396cf4ac040b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 682057c50bc19db2ab91d46d54ddb730d273617c1814b61e7bedea16120187af757ea44fe554cdbf66bc8b26f8356cb40050532b3cca146f7af6c73607632b21
|
|
7
|
+
data.tar.gz: 59c7b9a8331368526b1561482cd86d117866a4e61ce2e07a499fb48a8c2eb4ecf6a588388347a3bb4c2d6973c9d599bc819c2eb4b6348212f38aaee753c20774
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,96 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
6
6
|
|
|
7
7
|
### Added
|
|
8
8
|
|
|
9
|
+
- **The first-name card now says WHICH path finished it, and can type its
|
|
10
|
+
placeholder.** Two adoption hooks on
|
|
11
|
+
`studio/modals/onboarding/_first_name`, aimed at turf-monster's own 240-line
|
|
12
|
+
copy of it.
|
|
13
|
+
|
|
14
|
+
**WHAT THIS DOES NOT DO.** It unblocks that adoption; it does not complete it,
|
|
15
|
+
and the copy cannot simply be deleted on this release. `required` is still
|
|
16
|
+
resolved at RENDER time, while turf drives it as a runtime prop from ONE
|
|
17
|
+
registered modal id — the post-auth chain opens that id skippable, the entry
|
|
18
|
+
gate opens the SAME id with `required: true` — so adopting still costs two
|
|
19
|
+
registered ids (pass a distinct `id:` to keep the field's DOM id unique) or a
|
|
20
|
+
runtime mode here. And the host still writes the bridge: this card touches no
|
|
21
|
+
session store and emits no `first-name-saved`, so turf's resume needs a
|
|
22
|
+
listener that does both when `saved` is true.
|
|
23
|
+
|
|
24
|
+
**THE OUTCOME (the blocker).** `finish()` is called by the save AND the skip,
|
|
25
|
+
and it dispatched the same detail either way — so a host could hear that the
|
|
26
|
+
step was done and could not hear whether a name had landed. Its `done_event`
|
|
27
|
+
detail now carries `{ next: [...], saved: true|false }`. turf's entry gate
|
|
28
|
+
resumes a contest entry the gate interrupted when the name saves; fired after
|
|
29
|
+
a SKIP that same signal resumes an entry that still has no name.
|
|
30
|
+
|
|
31
|
+
It is an extra detail key rather than an opt-in second event because
|
|
32
|
+
McRitchie Studio's listener wants BOTH paths (it retires the "ask" marker
|
|
33
|
+
whether the user answered or skipped), so the event that always fires has to
|
|
34
|
+
be the one carrying the answer — and an opt-in event would leave this one
|
|
35
|
+
ambiguous for every host that did not pass it, including the next one to add
|
|
36
|
+
a gate. `next` keeps its key and the event keeps its name, so the existing
|
|
37
|
+
listener does not move. The flag is written `!!saved`, so an unreported path
|
|
38
|
+
reports FALSE: a card claiming `saved: true` for a skip would be worse than
|
|
39
|
+
the ambiguity it replaced.
|
|
40
|
+
|
|
41
|
+
**THE TYPED PLACEHOLDER (optional, OFF by default).** A new
|
|
42
|
+
`placeholder_names:` local takes an array of first names and types one of
|
|
43
|
+
them into the placeholder a character at a time, so the card demonstrates the
|
|
44
|
+
answer instead of describing it. Absent or empty, NONE of it is emitted and
|
|
45
|
+
the static `placeholder` is used exactly as before — the local exists so
|
|
46
|
+
turf's adoption does not silently delete a flourish it has today, and it
|
|
47
|
+
comes out in one piece if the operator would rather drop it.
|
|
48
|
+
|
|
49
|
+
It carries the three things that make it bearable rather than a nuisance:
|
|
50
|
+
typing stops the moment the user types and NEVER restarts (a focus counts
|
|
51
|
+
only when it follows a blur, because the field is focused on mount);
|
|
52
|
+
`prefers-reduced-motion` gets the whole example statically, with no timer;
|
|
53
|
+
and there is a 420ms pre-roll so a short name does not finish underneath the
|
|
54
|
+
modal's own mount spring. The pool travels as an escaped `data-` attribute
|
|
55
|
+
rather than interpolated into the `x-data`, which is what keeps a name with
|
|
56
|
+
an apostrophe — or the JSON's own double quotes — from killing the component.
|
|
57
|
+
|
|
58
|
+
The default card is asserted **byte-for-byte against the 0.70.0 render**,
|
|
59
|
+
with the four outcome lines rewound and every other byte required to match;
|
|
60
|
+
a fifth change has to be added to that list deliberately. Mutation-checked
|
|
61
|
+
6/6, and the two halves redden disjoint tests: reporting `true` on the skip,
|
|
62
|
+
dropping `saved` from the detail, and renaming `next` each hit an outcome
|
|
63
|
+
test, while forcing the typed mode on, un-guarding the refocus, and treating
|
|
64
|
+
an empty pool as present each hit a placeholder test.
|
|
65
|
+
|
|
66
|
+
- **The living style guide grows a HOST SECTION seam.** `/admin/style` rendered
|
|
67
|
+
four hard-coded engine sections (Theme, Modals, Tricks, Tasks) with no way for
|
|
68
|
+
a consuming app to contribute one of its own. An app now defines
|
|
69
|
+
**`app/views/style/host/_modals.html.erb`** and the guide grows a fifth
|
|
70
|
+
section for that app's own modals, between Modals and Tricks, plus its pill in
|
|
71
|
+
the sticky section nav. Found by the same three-term
|
|
72
|
+
`lookup_context.exists?("modals", ["style/host"], true)` the modal host uses
|
|
73
|
+
for `modals/_host_extras` — one convention in this codebase, not two — so
|
|
74
|
+
defining the file IS the registration: no initializer, no locals, no list to
|
|
75
|
+
append to.
|
|
76
|
+
|
|
77
|
+
**The gem keeps dictating the structure.** `style/_host.html.erb` renders the
|
|
78
|
+
section element, the `#host-modals` anchor, the heading (`Studio.app_name`),
|
|
79
|
+
the intro line and the Alpine scope; the app supplies specimens only. An app
|
|
80
|
+
that ships no such partial gets **nothing** — no pill, no heading, no
|
|
81
|
+
container — and that is asserted as a whole-document comparison rather than a
|
|
82
|
+
handful of refutes: the page an app WITH a section gets, minus that section
|
|
83
|
+
and its pill, must equal the page a base app gets. Five of the six apps
|
|
84
|
+
mounting this engine will ship no host section, and their page is unchanged.
|
|
85
|
+
|
|
86
|
+
**A host specimen drives `$store.modals`, not `dsModals`, and the distinction
|
|
87
|
+
is the whole point.** The guide's own Modals section needs a page-scoped store
|
|
88
|
+
because its specimens are the ENGINE's ids, which an app's layout host has no
|
|
89
|
+
registration for — pushing one there paints an empty card behind the specimen.
|
|
90
|
+
An app's own ids ARE registered in its layout host, so a host section opens
|
|
91
|
+
the **real** production card with no mirror, no second registration list and
|
|
92
|
+
no iframe. The doc comment on `style/_host.html.erb` is the consumer contract
|
|
93
|
+
and says so outright, because a reader who assumes `dsModals` builds that
|
|
94
|
+
mirror instead — a second copy of every card, free to drift from the one the
|
|
95
|
+
app ships, which is the opposite of what a living style guide is for.
|
|
96
|
+
|
|
97
|
+
**The seam ships no host section itself.** The first consumer is
|
|
98
|
+
turf-monster's own `style/host/_modals`, which lands as its own task.
|
|
9
99
|
- **A `required` mode on the shared first-name card.**
|
|
10
100
|
`studio/modals/onboarding/_first_name` grows one local. `required: true`
|
|
11
101
|
renders the card with BOTH skip affordances gone — the "Skip for now" button
|
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.
|
|
@@ -25,6 +25,18 @@
|
|
|
25
25
|
talking to a different audience — a host that passes its own
|
|
26
26
|
subtext still wins)
|
|
27
27
|
placeholder — (default "Alex")
|
|
28
|
+
placeholder_names — OPTIONAL, and OFF unless passed: an array of first names,
|
|
29
|
+
one of which is TYPED into the placeholder a character at a
|
|
30
|
+
time, so the card demonstrates the answer instead of
|
|
31
|
+
describing it. Absent (or empty) NONE of it is emitted and the
|
|
32
|
+
static `placeholder` above is used exactly as before. Passed,
|
|
33
|
+
`placeholder` goes unused: the field carries no static
|
|
34
|
+
placeholder, because a value Alpine blanks a moment after
|
|
35
|
+
paint reads as a flicker rather than a hint. Honours
|
|
36
|
+
prefers-reduced-motion by showing the whole example
|
|
37
|
+
statically, with no timer. The typing stops FOR GOOD the
|
|
38
|
+
moment the user types — it never restarts, not even when they
|
|
39
|
+
leave the field and come back.
|
|
28
40
|
max_length — (default Studio::FULL_NAME_MAX_LENGTH; the server stays the
|
|
29
41
|
real bound, and it REFUSES past that rather than truncating,
|
|
30
42
|
so a host raising this makes the endpoint answer 422 rather
|
|
@@ -32,10 +44,25 @@
|
|
|
32
44
|
progress — [current, total] to render the segmented pill, or nil for none
|
|
33
45
|
modal_store — Alpine store name (default "modals"; the living style guide
|
|
34
46
|
mounts its own page-scoped host and passes "dsModals")
|
|
35
|
-
done_event — window event dispatched
|
|
36
|
-
|
|
47
|
+
done_event — window event dispatched when this step is finished, saved or
|
|
48
|
+
skipped (default "onboarding-step-done"). Its detail carries
|
|
49
|
+
`{ next: [...], saved: true|false }` — see THE OUTCOME below.
|
|
37
50
|
The HOST decides what happens next; this partial never knows.
|
|
38
51
|
|
|
52
|
+
THE OUTCOME RIDES THE DETAIL, NOT A SECOND EVENT. `saved` says which path
|
|
53
|
+
finished the step, because to a host the two are not interchangeable:
|
|
54
|
+
turf-monster's entry gate resumes a contest entry the gate interrupted once
|
|
55
|
+
the name lands, and resuming after a SKIP resumes an entry that still has no
|
|
56
|
+
name. It is an extra detail key rather than an opt-in `saved_event` local for
|
|
57
|
+
two reasons. McRitchie Studio's listener wants BOTH paths — it retires the
|
|
58
|
+
"ask" marker whether the user answered or skipped — so the event that always
|
|
59
|
+
fires has to be the one carrying the answer, and a second event would leave
|
|
60
|
+
this one ambiguous for every host that did not opt in, including the next one
|
|
61
|
+
to add a gate. `next` keeps its key and its meaning, so an existing listener
|
|
62
|
+
does not move. And the flag is written `!!saved`: an unreported path reports
|
|
63
|
+
FALSE, because a card that claimed `saved: true` for a skip would be worse
|
|
64
|
+
than the ambiguity it replaced.
|
|
65
|
+
|
|
39
66
|
CONTRACT WITH THE SERVER. Both endpoints answer JSON `{ ok: true, next: [...] }`
|
|
40
67
|
— `next` being whatever steps the host says remain. A non-ok response must carry
|
|
41
68
|
`{ error: "…" }`, which is rendered inline. The step reports `next` upward and
|
|
@@ -59,6 +86,12 @@
|
|
|
59
86
|
end
|
|
60
87
|
subtext = local_assigns.fetch(:subtext, default_subtext)
|
|
61
88
|
placeholder = local_assigns.fetch(:placeholder, "Alex")
|
|
89
|
+
# OFF unless a host passes a non-empty array. Normalised to nil so that an
|
|
90
|
+
# empty list behaves exactly like an absent local rather than emitting the
|
|
91
|
+
# whole typing machine for a pool it can never sample from.
|
|
92
|
+
placeholder_names = local_assigns.fetch(:placeholder_names, nil)
|
|
93
|
+
placeholder_names = nil if placeholder_names.nil? || placeholder_names.empty?
|
|
94
|
+
typed_placeholder = !placeholder_names.nil?
|
|
62
95
|
max_length = local_assigns.fetch(:max_length, Studio::FULL_NAME_MAX_LENGTH)
|
|
63
96
|
progress = local_assigns.fetch(:progress, nil)
|
|
64
97
|
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
@@ -77,6 +110,68 @@
|
|
|
77
110
|
firstName: '',
|
|
78
111
|
submitting: false,
|
|
79
112
|
error: '',
|
|
113
|
+
<% if typed_placeholder %>
|
|
114
|
+
// --- the typed placeholder (opt-in: placeholder_names) ---------------
|
|
115
|
+
// Types ONE sampled name into the field's PLACEHOLDER, character by
|
|
116
|
+
// character. The field itself stays genuinely empty — nothing is ever
|
|
117
|
+
// submitted that the user did not type.
|
|
118
|
+
//
|
|
119
|
+
// NO fake caret. A blinking block after the text is the obvious way to
|
|
120
|
+
// sell a typewriter and it is wrong here: the field is focused on mount,
|
|
121
|
+
// so the browser is already drawing a real caret at position 0, and a
|
|
122
|
+
// second one two characters away reads as a rendering bug.
|
|
123
|
+
placeholderText: '',
|
|
124
|
+
_phPhrase: '',
|
|
125
|
+
_phTimer: null,
|
|
126
|
+
_phDelay: null,
|
|
127
|
+
_phBlurred: false,
|
|
128
|
+
startPlaceholder(names) {
|
|
129
|
+
if (!names || !names.length) return;
|
|
130
|
+
this._phPhrase = names[Math.floor(Math.random() * names.length)];
|
|
131
|
+
// Reduced motion: show the whole example, statically, no timer. The
|
|
132
|
+
// hint is the point; the animation is decoration.
|
|
133
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
134
|
+
this.placeholderText = this._phPhrase;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
var self = this, i = 0;
|
|
138
|
+
this.placeholderText = '';
|
|
139
|
+
// WAIT FOR THE CARD TO LAND. The modal mounts with a 320ms spring and
|
|
140
|
+
// x-init fires at the START of it, so typing that begins immediately
|
|
141
|
+
// runs entirely underneath the entrance. A SHORT name is the common
|
|
142
|
+
// case and the worst one: a two-letter name finishes before the card
|
|
143
|
+
// has finished appearing, so the field simply arrives with a name
|
|
144
|
+
// already in it and the effect is invisible.
|
|
145
|
+
this._phDelay = setTimeout(function () {
|
|
146
|
+
self._phDelay = null;
|
|
147
|
+
self._phTimer = setInterval(function () {
|
|
148
|
+
i += 1;
|
|
149
|
+
self.placeholderText = self._phPhrase.slice(0, i);
|
|
150
|
+
if (i >= self._phPhrase.length) self.stopPlaceholder();
|
|
151
|
+
}, 110);
|
|
152
|
+
}, 420);
|
|
153
|
+
},
|
|
154
|
+
// Stop the timer, leaving whatever is on screen. Also kills the pre-roll,
|
|
155
|
+
// or dismissing DURING the delay lets the animation start after the user
|
|
156
|
+
// has already begun typing.
|
|
157
|
+
stopPlaceholder() {
|
|
158
|
+
if (this._phTimer) { clearInterval(this._phTimer); this._phTimer = null; }
|
|
159
|
+
if (this._phDelay) { clearTimeout(this._phDelay); this._phDelay = null; }
|
|
160
|
+
},
|
|
161
|
+
// The user is writing their own name — get out of the way. Clearing
|
|
162
|
+
// rather than completing is deliberate: a half-typed name frozen under
|
|
163
|
+
// their cursor looks broken. Nothing restarts it afterwards.
|
|
164
|
+
dismissPlaceholder() {
|
|
165
|
+
this.stopPlaceholder();
|
|
166
|
+
this.placeholderText = '';
|
|
167
|
+
},
|
|
168
|
+
// The mount focus is NOT engagement. This card focuses the field the
|
|
169
|
+
// moment it mounts, so treating ANY focus as the user choosing the field
|
|
170
|
+
// would kill the animation before it drew a character. Only a focus that
|
|
171
|
+
// FOLLOWS a blur is the user deliberately coming back.
|
|
172
|
+
markPlaceholderBlurred() { this._phBlurred = true; },
|
|
173
|
+
refocusPlaceholder() { if (this._phBlurred) this.dismissPlaceholder(); },
|
|
174
|
+
<% end %>
|
|
80
175
|
async save() {
|
|
81
176
|
if (this.submitting) return;
|
|
82
177
|
var value = (this.firstName || '').trim();
|
|
@@ -88,14 +183,14 @@
|
|
|
88
183
|
this.error = (data && data.error) || 'Could not save that — try again.';
|
|
89
184
|
return;
|
|
90
185
|
}
|
|
91
|
-
this.finish(data.next || []);
|
|
186
|
+
this.finish(data.next || [], true);
|
|
92
187
|
},
|
|
93
188
|
async skip() {
|
|
94
189
|
if (this.submitting) return;
|
|
95
190
|
this.submitting = true; this.error = '';
|
|
96
191
|
var data = await this.post('<%= skip_path %>', {});
|
|
97
192
|
this.submitting = false;
|
|
98
|
-
this.finish((data && data.next) || []);
|
|
193
|
+
this.finish((data && data.next) || [], false);
|
|
99
194
|
},
|
|
100
195
|
async post(url, body) {
|
|
101
196
|
var meta = document.querySelector('meta[name=csrf-token]');
|
|
@@ -112,11 +207,18 @@
|
|
|
112
207
|
return await res.json();
|
|
113
208
|
} catch (e) { return null; }
|
|
114
209
|
},
|
|
115
|
-
finish(next) {
|
|
116
|
-
|
|
210
|
+
finish(next, saved) {
|
|
211
|
+
<% if typed_placeholder %>
|
|
212
|
+
this.stopPlaceholder();
|
|
213
|
+
<% end %>
|
|
214
|
+
window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next, saved: !!saved } }));
|
|
117
215
|
$store.<%= modal_store %>.close();
|
|
118
216
|
}
|
|
119
217
|
}"
|
|
218
|
+
<% if typed_placeholder %>
|
|
219
|
+
data-placeholder-names="<%= placeholder_names.to_json %>"
|
|
220
|
+
x-init="startPlaceholder(JSON.parse($el.dataset.placeholderNames || '[]'))"
|
|
221
|
+
<% end %>
|
|
120
222
|
class="relative">
|
|
121
223
|
|
|
122
224
|
<div class="relative mb-3 -mt-2">
|
|
@@ -144,9 +246,22 @@
|
|
|
144
246
|
<%# Focused on open. The HTML autofocus attribute is not enough for a modal
|
|
145
247
|
mounted from a <template x-if> after the document parsed, so Alpine does
|
|
146
248
|
it; preventScroll keeps the mount animation from being yanked. %>
|
|
249
|
+
<%# The placeholder is STATIC unless the host passed placeholder_names, in
|
|
250
|
+
which case it is bound and typed. The three extra handlers are the
|
|
251
|
+
difference between a hint and a nuisance: real typing dismisses it, a
|
|
252
|
+
blur is recorded, and a focus AFTER that blur — the user deliberately
|
|
253
|
+
returning — dismisses it too. A bare focus must not, because the
|
|
254
|
+
x-init on this input fires one immediately. %>
|
|
147
255
|
<input id="<%= field_id %>" type="text" x-model="firstName"
|
|
148
256
|
maxlength="<%= max_length %>" autocomplete="given-name" enterkeyhint="done"
|
|
257
|
+
<% if typed_placeholder %>
|
|
258
|
+
:placeholder="placeholderText"
|
|
259
|
+
@input="dismissPlaceholder()"
|
|
260
|
+
@blur="markPlaceholderBlurred()"
|
|
261
|
+
@focus="refocusPlaceholder()"
|
|
262
|
+
<% else %>
|
|
149
263
|
placeholder="<%= placeholder %>"
|
|
264
|
+
<% end %>
|
|
150
265
|
:disabled="submitting"
|
|
151
266
|
x-init="$nextTick(() => $el.focus({ preventScroll: true }))"
|
|
152
267
|
class="input-field">
|
|
@@ -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>
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.72.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -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
|