studio-engine 0.113.0 → 0.114.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.
@@ -0,0 +1,252 @@
1
+ // studio/newsletter_modal: the newsletter cards' JSON mode
2
+ // (studio/profiles/_newsletter_modals with submit_mode: :json).
3
+ //
4
+ // x-data="studioNewsletterModal({ action: 'subscribe', url, store, needsEmail, requiresConsent, nextId, event })"
5
+ //
6
+ // One scope for every card, so each binds the same names: email, consent,
7
+ // saving, error, props, ready and submit(). A card that only shows (the success
8
+ // and goodbye beats) binds props and nothing else.
9
+ //
10
+ // WHAT submit() DOES, in order:
11
+ // 1. Refuses quietly while a request is in flight, or while a consent box the
12
+ // card carries is unticked (the button is disabled then too).
13
+ // 2. When the card asks for an address, refuses an address with no "@" past
14
+ // its first character, on the card's own error line.
15
+ // 3. HANDS OFF when the opener passed props.onSubmit: closes the card and
16
+ // calls it with the address (or null). The opener owns the request then;
17
+ // this is how a page that already knows how to subscribe asks only for the
18
+ // address.
19
+ // 4. Otherwise POSTs JSON to the host's url (DELETE or another verb by
20
+ // opts.method) with the page's CSRF token, and reads the answer: a 2xx, not by redirect, whose
21
+ // body does not say success: false is a success; anything else shows
22
+ // body.error, or a plain line, on the card. The request goes through
23
+ // plain fetch, or through the host's own fetcher when opts.fetcher names
24
+ // one (THE HOST'S FETCHER, below).
25
+ // 5. On success: dispatches opts.event on window with
26
+ // { subscribed, email, response }, calls props.onSuccess(response) when the
27
+ // opener passed one, then swaps to opts.nextId (the success or goodbye card)
28
+ // with the response as its props, or closes when there is none.
29
+ //
30
+ // No page reload anywhere: the host's listener updates whatever the page shows.
31
+ //
32
+ // THE HOST'S FETCHER. A host whose requests go through its own wrapper (Turf
33
+ // Monster's authedFetch: a 401 opens the sign-in card, a 429 the rate-limit
34
+ // card) names it with opts.fetcher, a JS identifier. The card resolves it as a
35
+ // named export of the importmap module "studio/host_fetchers": the engine pins
36
+ // that name to an empty module, and a host that pins the same name to its own
37
+ // file wins (FRONT_END_STANDARD.md, "The engine's modules"). Never a window
38
+ // global: the standard forbids a window.* API between files. The host's module
39
+ // is imported on the first press that needs it, not at boot.
40
+ //
41
+ // # the host's config/importmap.rb
42
+ // pin "studio/host_fetchers", to: "newsletter_fetchers.js"
43
+ // // app/javascript/newsletter_fetchers.js
44
+ // export { authedFetch } from "solana_utils"
45
+ //
46
+ // The fetcher takes fetch's arguments and returns a Response, or null when it
47
+ // has answered the request itself (authedFetch opens its own card and returns
48
+ // null): the card then stops its spinner and says nothing more. A name the
49
+ // module does not export as a function, or a module that does not load, fails
50
+ // the press with the card's plain line and one console error; it never falls
51
+ // back to plain fetch, which would quietly drop the host's 401 handling.
52
+ //
53
+ // studio/alpine_scopes publishes the factory on window, statically: Alpine
54
+ // evaluates the x-data as the modal host mounts the card. It imports nothing
55
+ // statically (the host's fetcher module is a dynamic import);
56
+ // test/javascript/newsletter_modal.test.mjs loads it as a data: module.
57
+
58
+ export const INVALID_EMAIL = "Enter a valid email address."
59
+ export const SUBSCRIBE_FAILED = "Could not join the newsletter."
60
+ export const UNSUBSCRIBE_FAILED = "Could not unsubscribe."
61
+ export const CHANGED_EVENT = "studio:newsletter-changed"
62
+ export const HOST_FETCHERS = "studio/host_fetchers"
63
+ export const FETCHER_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/
64
+
65
+ // The host's fetcher named +name+, as a function, or null when the module does
66
+ // not load or does not export a function by that name. +importer+ loads
67
+ // studio/host_fetchers (the tests pass their own).
68
+ export async function resolveFetcher(name, importer) {
69
+ if (!FETCHER_NAME.test(String(name || ""))) return null
70
+ try {
71
+ const mod = await importer()
72
+ const fn = mod && mod[name]
73
+ return typeof fn === "function" ? fn : null
74
+ } catch (_) {
75
+ return null
76
+ }
77
+ }
78
+
79
+ // The address trimmed, or "" when it cannot be one: an "@" with nothing before
80
+ // it, or none at all. The server is the real check; this only saves a round trip
81
+ // for the address nobody meant.
82
+ export function cleanEmail(value) {
83
+ const s = String(value == null ? "" : value).trim()
84
+ return s.indexOf("@") > 0 ? s : ""
85
+ }
86
+
87
+ // What the host's answer means. ok: a 2xx whose body does not say
88
+ // success: false, and that fetch did not reach by a redirect (an expired session
89
+ // redirected to an HTML sign-in page answers 200, and joined nobody). error:
90
+ // the body's own words, else the fallback.
91
+ export function outcomeOf(response, body, fallback) {
92
+ body = body && typeof body === "object" ? body : {}
93
+ const ok = !!(response && response.ok && !response.redirected) && body.success !== false
94
+ return ok ? { ok: true, body: body } : { ok: false, body: body, error: body.error || fallback }
95
+ }
96
+
97
+ // The page's CSRF token, or "" on a page without one.
98
+ export function csrfToken(doc) {
99
+ try {
100
+ const meta = doc && doc.querySelector('meta[name="csrf-token"]')
101
+ return (meta && meta.content) || ""
102
+ } catch (_) {
103
+ return ""
104
+ }
105
+ }
106
+
107
+ // x-data for a newsletter card.
108
+ //
109
+ // opts:
110
+ // action "subscribe" (default) or "unsubscribe".
111
+ // url the host's JSON endpoint for the action.
112
+ // method the verb (default "POST").
113
+ // store the Alpine store the card is mounted in (default "modals").
114
+ // needsEmail the card asks for an address.
115
+ // requiresConsent the card carries a consent box (ticked to start).
116
+ // nextId the card a success swaps to; blank closes instead.
117
+ // event the window event a success dispatches (CHANGED_EVENT).
118
+ // fetcher the host's fetcher, by its export name in
119
+ // studio/host_fetchers; blank sends through plain fetch.
120
+ //
121
+ // env: { win, importer } for the tests.
122
+ export function studioNewsletterModal(opts, env) {
123
+ opts = opts || {}
124
+ const win = (env && env.win) || (typeof window !== "undefined" ? window : {})
125
+ const action = opts.action === "unsubscribe" ? "unsubscribe" : "subscribe"
126
+ const importer = (env && env.importer) || (() => import("studio/host_fetchers"))
127
+
128
+ return {
129
+ email: "",
130
+ consent: true,
131
+ saving: false,
132
+ error: "",
133
+ action: action,
134
+ url: opts.url || "",
135
+ method: String(opts.method || "POST").toUpperCase(),
136
+ store: opts.store || "modals",
137
+ needsEmail: !!opts.needsEmail,
138
+ requiresConsent: !!opts.requiresConsent,
139
+ nextId: opts.nextId || "",
140
+ event: opts.event || CHANGED_EVENT,
141
+ fetcher: opts.fetcher || "",
142
+
143
+ _store() {
144
+ try { return win.Alpine.store(this.store) } catch (_) { return null }
145
+ },
146
+
147
+ // The open card's props, or {} with nothing open.
148
+ get props() {
149
+ const store = this._store()
150
+ const entry = store && store.current && store.current()
151
+ return (entry && entry.props) || {}
152
+ },
153
+
154
+ get ready() {
155
+ return !this.saving && (!this.requiresConsent || !!this.consent)
156
+ },
157
+
158
+ async submit() {
159
+ if (!this.ready) return
160
+ let address = null
161
+ if (this.needsEmail) {
162
+ address = cleanEmail(this.email)
163
+ if (!address) {
164
+ this.error = INVALID_EMAIL
165
+ return
166
+ }
167
+ }
168
+ this.error = ""
169
+
170
+ const props = this.props
171
+ if (action === "subscribe" && typeof props.onSubmit === "function") {
172
+ const handOff = props.onSubmit
173
+ this._close()
174
+ handOff(address)
175
+ return
176
+ }
177
+
178
+ this.saving = true
179
+ const fallback = action === "unsubscribe" ? UNSUBSCRIBE_FAILED : SUBSCRIBE_FAILED
180
+ let outcome
181
+ try {
182
+ const init = {
183
+ method: this.method,
184
+ credentials: "same-origin",
185
+ headers: {
186
+ "Content-Type": "application/json",
187
+ "Accept": "application/json",
188
+ "X-CSRF-Token": csrfToken(win.document)
189
+ }
190
+ }
191
+ if (action === "subscribe") init.body = JSON.stringify({ email: address })
192
+ const send = await this._sender()
193
+ if (!send) {
194
+ outcome = { ok: false, body: {}, error: fallback }
195
+ } else {
196
+ const response = await send(this.url, init)
197
+ if (response == null && this.fetcher) {
198
+ // The host's fetcher answered it (its own sign-in or wait card).
199
+ this.saving = false
200
+ return
201
+ }
202
+ let body = {}
203
+ try { body = await response.json() } catch (_) {}
204
+ outcome = outcomeOf(response, body, fallback)
205
+ }
206
+ } catch (_) {
207
+ outcome = { ok: false, body: {}, error: fallback }
208
+ }
209
+
210
+ this.saving = false
211
+ if (!outcome.ok) {
212
+ this.error = outcome.error
213
+ return
214
+ }
215
+ this._succeed(outcome.body, address, props)
216
+ },
217
+
218
+ // What sends the request: plain fetch, or the host's named fetcher; null
219
+ // when a named one cannot be found.
220
+ async _sender() {
221
+ if (!this.fetcher) return (url, init) => win.fetch(url, init)
222
+ const fn = await resolveFetcher(this.fetcher, importer)
223
+ if (!fn) {
224
+ try {
225
+ win.console.error(`studio/newsletter_modal: ${HOST_FETCHERS} exports no function named ${this.fetcher}`)
226
+ } catch (_) {}
227
+ }
228
+ return fn
229
+ },
230
+
231
+ _succeed(body, address, props) {
232
+ const subscribed = action === "subscribe"
233
+ try {
234
+ win.dispatchEvent(new win.CustomEvent(this.event, {
235
+ detail: { subscribed: subscribed, email: address, response: body }
236
+ }))
237
+ } catch (_) {}
238
+ if (typeof props.onSuccess === "function") {
239
+ try { props.onSuccess(body) } catch (_) {}
240
+ }
241
+ const store = this._store()
242
+ if (!store) return
243
+ if (this.nextId) store.swap(this.nextId, body)
244
+ else store.close()
245
+ },
246
+
247
+ _close() {
248
+ const store = this._store()
249
+ if (store) store.close()
250
+ }
251
+ }
252
+ }
@@ -17,9 +17,23 @@
17
17
  data-pin-edge: on a header on the scroll timeline the stack publishes the
18
18
  PINNED bar and nothing per frame, so at the top of the page this fixed panel
19
19
  would cover the expanded header. engine.css (THE PINNED EDGE ON THE SCROLL
20
- TIMELINE) moves it with the header on the scroll timeline instead. %>
20
+ TIMELINE) moves it with the header on the scroll timeline instead.
21
+
22
+ Two opt-in locals (unset, the panel renders as it did in 0.113.0):
23
+ title_html the header's LEAD SLOT: HTML in the title's place, for a panel
24
+ whose lead is a live status line rather than a label. It is
25
+ content, NOT a name: it never feeds aria-labelledby or
26
+ aria-label, so a caller that passes it instead of `title` must
27
+ pass aria_label, or the panel has no accessible name. `title`
28
+ wins when both are passed. Escaped unless html_safe.
29
+ close_hook true stamps the close button data-sidebar-panel-close="<id>"
30
+ too, so a host's own document handler can match the button
31
+ to its panel by id. Needs `id`; without one it adds nothing.
32
+ data-link-sidebar-close stays, and studio/link_sidebar counts
33
+ it only inside its own panels. %>
21
34
  <% id = local_assigns.fetch(:id, nil) %>
22
35
  <% title = local_assigns.fetch(:title, nil) %>
36
+ <% title_html = local_assigns.fetch(:title_html, nil) %>
23
37
  <% open = local_assigns.fetch(:open) %>
24
38
  <% actions_html = local_assigns.fetch(:actions_html, nil) %>
25
39
  <% body_html = local_assigns.fetch(:body_html, nil) %>
@@ -34,6 +48,7 @@
34
48
  <% role = local_assigns.fetch(:role, "complementary") %>
35
49
  <% aria_label = local_assigns.fetch(:aria_label, title) %>
36
50
  <% labelledby = id.present? && title.present? ? "#{id}-title" : nil %>
51
+ <% close_hook = local_assigns.fetch(:close_hook, false) && id.present? %>
37
52
 
38
53
  <aside
39
54
  <% if id.present? %>id="<%= id %>"<% end %>
@@ -64,10 +79,14 @@
64
79
  role="<%= role %>"
65
80
  <% if labelledby %>aria-labelledby="<%= labelledby %>"<% elsif aria_label.present? %>aria-label="<%= aria_label %>"<% end %>>
66
81
  <div class="flex flex-col h-full">
67
- <% if title.present? || actions_html.present? || close_action.present? %>
82
+ <% if title.present? || title_html.present? || actions_html.present? || close_action.present? %>
68
83
  <div class="flex items-center justify-between p-4 border-b border-subtle">
69
84
  <% if title.present? %>
70
85
  <h3 <% if labelledby %>id="<%= labelledby %>"<% end %> class="text-lg font-bold text-heading"><%= title %></h3>
86
+ <% elsif title_html.present? %>
87
+ <%# min-w-0 so a long status line truncates in its own box instead of
88
+ pushing the close button off the panel's edge on a phone. %>
89
+ <div class="min-w-0 flex-1" data-sidebar-panel-lead><%= title_html %></div>
71
90
  <% end %>
72
91
  <div class="flex items-center gap-2 ml-auto">
73
92
  <%= actions_html if actions_html.present? %>
@@ -76,7 +95,7 @@
76
95
  @click="<%= close_action %>"
77
96
  class="flex items-center justify-center w-8 h-8 rounded-full text-secondary hover:text-heading hover:bg-surface-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 transition"
78
97
  aria-label="Close sidebar"
79
- data-link-sidebar-close="true">
98
+ data-link-sidebar-close="true"<% if close_hook %> data-sidebar-panel-close="<%= id %>"<% end %>>
80
99
  <span class="text-xl leading-none">&times;</span>
81
100
  </button>
82
101
  <% end %>
@@ -15,7 +15,10 @@
15
15
  (default display_name) and the signed-out button reads
16
16
  Studio.sign_in_label (default "Log in"); lib/studio/navbar_identity.rb.
17
17
  Studio.navbar_avatar_badge (default nil) draws one partial over the
18
- avatar; lib/studio/navbar_slots.rb.
18
+ avatar, and Studio.navbar_menu_trigger (default nil) replaces the admin
19
+ dropdown in the icon rail; lib/studio/navbar_slots.rb.
20
+ Studio.navbar_account_sidebar (default nil) makes the account control
21
+ open a host sidebar; lib/studio/navbar_account_sidebar.rb.
19
22
 
20
23
  Legacy string locals (deprecated): balance_html, extra_icons_html,
21
24
  div2_html — pre-rendered HTML injected via raw. Still honored when
@@ -63,6 +66,9 @@
63
66
  of it. A link that goes nowhere looks identical to one that works until it is
64
67
  clicked, which is why nobody reported it.
65
68
  %>
69
+ <%# Studio.navbar_menu_trigger: the host's menu control in the admin
70
+ dropdown's seat (the icon rail from md, and the signed-out rail). %>
71
+ <% menu_trigger = respond_to?(:studio_navbar_menu_trigger) ? studio_navbar_menu_trigger : nil %>
66
72
  <% studio_nav_destination =
67
73
  if defined?(account_path)
68
74
  account_path
@@ -102,7 +108,11 @@
102
108
  glyphs side by side read as a double gear. Desktop only, like
103
109
  the theme toggle: below md the navbar's phone row draws the cog,
104
110
  so a second one here put two on an admin's phone. %>
111
+ <% if menu_trigger %>
112
+ <span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
113
+ <% else %>
105
114
  <%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
115
+ <% end %>
106
116
  </div>
107
117
  </div>
108
118
  <% if div2_content.present? %>
@@ -219,15 +229,34 @@
219
229
  dialog" rather than a link that goes nowhere it says. studio/link_sidebar
220
230
  claims its click from the document, before Alpine binds, as it does the
221
231
  gear's. An app with no sidebar keeps the link to the account page. %>
222
- <% account_opens_sidebar = respond_to?(:studio_account_opens_sidebar?) && studio_account_opens_sidebar? %>
232
+ <%# Studio.navbar_account_sidebar: a HOST sidebar the control opens
233
+ instead (a button toggling the host's store flag, built by the engine
234
+ from validated names). It wins over the link sidebar and the account
235
+ link. It carries data-profile-image-toggle, so a host handler that
236
+ claims its triggers in the capture phase, before Alpine binds, claims
237
+ this one; the guarded @click is the path when nothing claims it. Its
238
+ controls may not name the engine's own link sidebar ids, refused at
239
+ assignment (lib/studio/navbar_account_sidebar.rb). %>
240
+ <% account_sidebar = respond_to?(:studio_navbar_account_sidebar) ? studio_navbar_account_sidebar : nil %>
241
+ <% account_opens_sidebar = !account_sidebar && respond_to?(:studio_account_opens_sidebar?) && studio_account_opens_sidebar? %>
223
242
  <% account_classes = "group flex items-center gap-2 flex-shrink-0" %>
243
+ <%# Studio.navbar_phone_name: the name shows below md too, capped, and
244
+ fades at its edge when it overflows (engine.css, THE PHONE NAME). %>
245
+ <% phone_name = Studio.navbar_phone_name %>
224
246
  <% account_inner = capture do %>
225
- <span class="sr-only md:not-sr-only md:max-w-40"><span class="block truncate text-right text-heading font-semibold text-base leading-none transition<%= " group-hover:text-primary" if account_opens_sidebar || studio_nav_destination %>" data-nav-name><%= Studio.navbar_user_name_for(current_user, self) %></span></span>
247
+ <% if phone_name %><span class="min-w-0 max-w-[4.5rem] min-[400px]:max-w-[6rem] md:max-w-40" data-nav-name-phone><% else %><span class="sr-only md:not-sr-only md:max-w-40"><% end %><span class="block truncate text-right text-heading font-semibold text-base leading-none transition<%= " group-hover:text-primary" if account_sidebar || account_opens_sidebar || studio_nav_destination %>" data-nav-name><%= Studio.navbar_user_name_for(current_user, self) %></span></span>
226
248
  <span class="flex-shrink-0 flex items-center" aria-hidden="true">
227
249
  <%= render "components/avatar", user: current_user, size: "nav" %>
228
250
  </span>
229
251
  <% end %>
230
- <% if account_opens_sidebar %>
252
+ <% if account_sidebar %>
253
+ <% account_js = studio_navbar_account_sidebar_expressions(account_sidebar) %>
254
+ <%= tag.button type: "button", class: "#{account_classes} cursor-pointer rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
255
+ data: { nav_account: "", profile_image_toggle: "" },
256
+ aria: { controls: account_sidebar[:controls], haspopup: "dialog" },
257
+ ":aria-expanded": account_js[:expanded], "@click.stop": account_js[:toggle],
258
+ "x-bind:style": "$store.devMode && 'background: lightgreen'" do %><%= account_inner %><% end %>
259
+ <% elsif account_opens_sidebar %>
231
260
  <button type="button" class="<%= account_classes %> cursor-pointer rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" data-nav-account data-profile-image-toggle
232
261
  aria-controls="studio-link-sidebar studio-link-sidebar-mobile" aria-haspopup="dialog"
233
262
  :aria-expanded="$store.sidebars && $store.sidebars.linkTreeOpen ? 'true' : 'false'"
@@ -251,7 +280,11 @@
251
280
  <div data-nav-avatar-badge><%= studio_navbar_slot_tag(avatar_badge) %></div>
252
281
  <% end %>
253
282
  <% else %>
283
+ <% if menu_trigger %>
284
+ <span class="hidden md:block" data-nav-menu-trigger><%= studio_navbar_slot_tag(menu_trigger) %></span>
285
+ <% else %>
254
286
  <%= render "components/admin_dropdown", display_class: "hidden md:block" unless respond_to?(:studio_sidebar_replaces_admin_menu?) && studio_sidebar_replaces_admin_menu? %>
287
+ <% end %>
255
288
  <%# Desktop only, as above: the phone row carries the toggle below md. %>
256
289
  <span class="hidden md:flex" data-nav-theme-toggle="desktop"><%= render "components/theme_toggle_morph" %></span>
257
290
  <div class="flex items-center gap-3 ml-auto">
@@ -4,6 +4,13 @@
4
4
  preview — if true, disables scroll handler and sticky positioning
5
5
  balance_html — raw HTML for balance display (passed through to _user_nav)
6
6
  extra_icons_html — raw HTML for extra icon buttons (passed through to _user_nav)
7
+ balance_slot, extra_icons_slot, div2_slot, div2_html
8
+ — components/_user_nav's slot locals (a partial path, or
9
+ { partial:, locals: }), forwarded to it as given. A
10
+ balance_slot reserves the balance column as balance_html
11
+ does. extra_icons_slot is rendered here, so the link
12
+ sidebar's trigger still follows it; like the user nav,
13
+ a slot wins over its legacy _html string.
7
14
  brand_heading — boolean, draw the brand title as the page's h1 (default: true).
8
15
  Pass false on a page that carries its own h1: the brand is
9
16
  drawn as a div with the same classes and contents.
@@ -20,6 +27,13 @@
20
27
  <% pin_header = Studio.smooth_load && !is_preview %>
21
28
  <% balance_html = local_assigns.fetch(:balance_html, nil) %>
22
29
  <% extra_icons_html = local_assigns.fetch(:extra_icons_html, nil) %>
30
+ <%# The user nav's slot locals, forwarded only when passed, so a navbar that
31
+ passes none renders the user nav exactly as before. %>
32
+ <% user_nav_slots = local_assigns.slice(:balance_slot, :div2_slot, :div2_html).compact %>
33
+ <% extra_icons_slot = local_assigns[:extra_icons_slot] %>
34
+ <% if extra_icons_slot.present? %>
35
+ <% extra_icons_html = extra_icons_slot.is_a?(Hash) ? render(extra_icons_slot[:partial], **extra_icons_slot.fetch(:locals, {})) : render(extra_icons_slot.to_s) %>
36
+ <% end %>
23
37
  <% brand_tag = local_assigns.fetch(:brand_heading, true) ? "h1" : "div" %>
24
38
  <%# Out-of-the-box navigation (engine 0.30): when the host declares
25
39
  Studio.sidebar_sections, the navbar mounts the link-sidebar trigger in its
@@ -50,6 +64,13 @@
50
64
  prefix = words.join(" ")
51
65
  logo_path = Studio.logo_for("Navbar Logo")
52
66
  wordmark = Studio.navbar_wordmark
67
+ # Studio.navbar_brand: the host's logo, its size and alt, and the title as
68
+ # words with the host's classes. nil (the default) draws as before.
69
+ brand = Studio.navbar_brand
70
+ logo_path = brand[:logo] if brand && brand[:logo]
71
+ logo_alt = (brand && brand[:alt]) || Studio.app_name
72
+ logo_size = brand && brand[:logo_size]
73
+ brand_words = brand && brand[:words]
53
74
  %>
54
75
 
55
76
  <%# nav-shell is the --nav-p scope: the engine's nav-collapse controller (studio/nav_collapse) writes
@@ -76,7 +97,10 @@
76
97
  or a scroll_timeline: local for one render; engine.css, THE SCROLL-DRIVEN
77
98
  COLLAPSE). Never on a preview: the /navbar page drives --nav-p itself. %>
78
99
  <% scroll_timeline = local_assigns.fetch(:scroll_timeline, Studio.navbar_scroll_timeline) %>
79
- <header data-pin="nav"<% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<%= " data-nav-timeline" if scroll_timeline %><% end %>
100
+ <%# Studio.navbar_root_hook puts data-navbar-root on the header, live and
101
+ preview; Studio.navbar_z_tier layers the live header at --z-nav on the
102
+ shared scale (engine.css) instead of z-50. Both off renders as before. %>
103
+ <header data-pin="nav"<%= " data-navbar-root".html_safe if Studio.navbar_root_hook %><% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<%= " data-nav-timeline" if scroll_timeline %><% end %>
80
104
  <%# top-0, a STATIC value, and never a custom property. Any bars render as
81
105
  this header's sibling in normal flow (studio/banners/_stack), so they
82
106
  already occupy their own height above it and there is nothing to
@@ -85,7 +109,7 @@
85
109
  measurement landed, moved again on a webfont swap, and drew twice
86
110
  during a view transition composited at two different tops. Only CSS
87
111
  can set this top now, so none of that is reachable. %>
88
- class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
112
+ class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 #{Studio.navbar_z_tier ? 'z-[var(--z-nav)]' : 'z-50'} bg-page transition-shadow duration-300" %>"
89
113
  <%= %(:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'").html_safe if is_preview %>>
90
114
  <style>
91
115
  /* THE PHONE STEP IS A SHARE OF THE VIEWPORT, NOT A CONSTANT, and that is the
@@ -144,6 +168,18 @@
144
168
  title swap matters most: it is DISCRETE, which is the 1px reverse lurch
145
169
  the primitive exists to delete. Full accounting in engine.css. */
146
170
  </style>
171
+ <%# Studio.navbar_header_bars: bars INSIDE the pinned header, above its row,
172
+ so they never scroll away: a control an admin must not lose (an
173
+ impersonation bar's JS-free "Return to <admin>" button). An ambient
174
+ label belongs in the bar stack above the header, which scrolls. Being in
175
+ the header costs one thing, stated: the header is a stacking context, so
176
+ these never rise over an open modal. On the scroll timeline engine.css
177
+ (THE IN-HEADER BARS) carries them down by the band so they hold at the
178
+ window's top. Never on a preview, and [] (the default) renders nothing. %>
179
+ <% header_bars = !is_preview && respond_to?(:studio_navbar_header_bars) ? studio_navbar_header_bars : [] %>
180
+ <% if header_bars.any? %>
181
+ <div data-nav-header-bars><% header_bars.each do |bar| %><%= studio_navbar_slot_tag(bar) %><% end %></div>
182
+ <% end %>
147
183
  <div class="nav-row flex items-center">
148
184
  <%# Left side: logo + nav %>
149
185
  <%# min-w-0 THREE TIMES, on every flex item between the row and the title.
@@ -174,14 +210,16 @@
174
210
  <%# No w-*/h-* utility here: .nav-shell .nav-logo sizes it off --nav-p.
175
211
  transition-transform, NOT transition-all — the hover lift still
176
212
  eases, while width/height follow the finger with no clock. %>
177
- <img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="nav-logo rounded-full shadow-lg <%= "group-hover:scale-105 transition-transform duration-300" unless is_preview %>">
213
+ <img src="<%= logo_path %>" alt="<%= logo_alt %>"<% if logo_size %> width="<%= logo_size %>" height="<%= logo_size %>"<% end %> class="nav-logo rounded-full shadow-lg <%= "group-hover:scale-105 transition-transform duration-300" unless is_preview %>">
178
214
  <% end %>
179
215
  <%# truncate on the SPANS, not the h1. .nav-title is itself a flex
180
216
  container (a column below 768px, a baseline row above), so the
181
217
  overflow that has to be clipped belongs to each word's own box —
182
218
  `truncate` on the h1 would clip the flex CONTAINER and leave the
183
219
  spans drawing outside it, which is the bug rather than the fix. %>
184
- <<%= brand_tag %> class="nav-title font-extrabold text-heading tracking-tight min-w-0"><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span></<%= brand_tag %>>
220
+ <%# Studio.navbar_brand's words, when set, replace the app_name split
221
+ inside the same h1: one span per word, the host's class plus truncate. %>
222
+ <<%= brand_tag %> class="nav-title font-extrabold text-heading tracking-tight min-w-0"><% if brand_words %><% brand_words.each do |word| %><span class="<%= [word[:class], "truncate"].compact.join(" ") %>"><%= word[:text] %></span><% end %><% else %><span class="truncate"><%= prefix %></span><span class="text-primary truncate"><%= last_word %></span><% end %></<%= brand_tag %>>
185
223
  <% end %>
186
224
  <% end %>
187
225
  <%# Desktop nav — Studio.navbar_links, then Studio.navbar_items. data-nav-links, only where the
@@ -201,16 +239,21 @@
201
239
  <%# Right side: user nav. The fixed column (matches sidebar width) exists
202
240
  to seat a balance in the top row; with no balance content it shrinks
203
241
  to fit. %>
204
- <div class="<%= balance_html.present? ? 'user-nav-col' : 'user-nav-fit' %> flex-shrink-0 pl-0 pr-4 md:px-4">
242
+ <div class="<%= balance_html.present? || user_nav_slots[:balance_slot].present? ? 'user-nav-col' : 'user-nav-fit' %> flex-shrink-0 pl-0 pr-4 md:px-4">
205
243
  <% if show_user %>
206
- <%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html %>
244
+ <%= render "components/user_nav", balance_html: balance_html, extra_icons_html: extra_icons_html, **user_nav_slots %>
207
245
  <% else %>
208
246
  <div class="flex items-center justify-end gap-3">
209
247
  <%= sidebar_trigger_desktop if sidebar_active %>
210
248
  <span class="hidden md:flex"><%= render "components/theme_toggle_morph" %></span>
211
249
  <% if signed_out_cta %>
212
- <%= link_to Studio.sign_in_label, login_path, class: "hidden md:inline text-sm font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
213
- <%= link_to signed_out_cta[:label], signed_out_cta[:path], class: "btn btn-primary whitespace-nowrap", data: { nav_signed_out_cta: "" } %>
250
+ <%# sign_in_link: false drops the quiet link (the CTA is the sign-in);
251
+ sign_in_path points it elsewhere; modal: opens a modal from the
252
+ button, its path the fallback (lib/studio/navbar_signed_out_cta.rb). %>
253
+ <% unless signed_out_cta[:sign_in_link] == false %>
254
+ <%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "hidden md:inline text-sm font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
255
+ <% end %>
256
+ <%= link_to signed_out_cta[:label], signed_out_cta[:path], class: "btn btn-primary whitespace-nowrap", data: { nav_signed_out_cta: "" }, **studio_navbar_cta_modal_options(signed_out_cta) %>
214
257
  <% else %>
215
258
  <%= link_to Studio.sign_in_label, login_path, class: "btn btn-primary" %>
216
259
  <% end %>
@@ -231,11 +274,18 @@
231
274
  <%= studio_navbar_slot_tag(item) %>
232
275
  <% end %>
233
276
  <span class="ml-auto flex items-center gap-3">
234
- <% if signed_out_cta %>
235
- <%= link_to Studio.sign_in_label, login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
277
+ <% if signed_out_cta && signed_out_cta[:sign_in_link] != false %>
278
+ <%= link_to Studio.sign_in_label, signed_out_cta[:sign_in_path] || login_path, class: "text-xs font-semibold text-secondary hover:text-primary whitespace-nowrap transition", data: { nav_sign_in: "" } %>
236
279
  <% end %>
237
280
  <%= render "components/link_sidebar_trigger" if sidebar_active %>
281
+ <%# Studio.navbar_menu_trigger takes the admin dropdown's seat here too: its
282
+ second render (the rail is the first), so the partial carries no ids. %>
283
+ <% menu_trigger = respond_to?(:studio_navbar_menu_trigger) ? studio_navbar_menu_trigger : nil %>
284
+ <% if menu_trigger %>
285
+ <%= studio_navbar_slot_tag(menu_trigger) %>
286
+ <% else %>
238
287
  <%= render "components/admin_dropdown" unless sidebar_active && studio_sidebar_replaces_admin_menu? %>
288
+ <% end %>
239
289
  <%= render "components/theme_toggle_morph" %>
240
290
  </span>
241
291
  </div>
@@ -1,13 +1,15 @@
1
1
  <%#
2
2
  THE X-DATA FACTORIES' GUARD: says so when a page's factories did not load.
3
3
 
4
- The photo crop, the upload hosts, the birthday card and row and the profile
5
- form bind an x-data factory by name (cropPhotoModal, imageUploadHost,
6
- avatarCropperHost, birthdayModal, studioBirthdayFields, studioProfileForm),
7
- and all six are published by studio/alpine_scopes from four modules
8
- (studio/cropper, studio/image_upload, studio/birthday, studio/profile_form).
4
+ The photo crop, the upload hosts, the birthday card and row, the profile
5
+ form, the error card's countdown and the newsletter cards bind an x-data
6
+ factory by name (cropPhotoModal, imageUploadHost, avatarCropperHost,
7
+ birthdayModal, studioBirthdayFields, studioProfileForm, studioModalCountdown,
8
+ studioNewsletterModal), and all eight are published by studio/alpine_scopes
9
+ from six modules (studio/cropper, studio/image_upload, studio/birthday,
10
+ studio/profile_form, studio/modal_countdown, studio/newsletter_modal).
9
11
  A browser keeps a failed module request for the life of the document, so one
10
- failure among the five at page load leaves every name undefined until a
12
+ failure among the seven at page load leaves every name undefined until a
11
13
  reload: the profile page with no Save, the birthday card with nothing to
12
14
  press. This script is what speaks then, so it is INLINE and imports nothing:
13
15
  it shares no request with the modules whose failure it reports.
@@ -46,7 +48,7 @@
46
48
  (function () {
47
49
  if (window.studioScopesGuard) return;
48
50
 
49
- var NAMES = ["cropPhotoModal", "imageUploadHost", "avatarCropperHost", "birthdayModal", "studioBirthdayFields", "studioProfileForm"];
51
+ var NAMES = ["cropPhotoModal", "imageUploadHost", "avatarCropperHost", "birthdayModal", "studioBirthdayFields", "studioProfileForm", "studioModalCountdown", "studioNewsletterModal"];
50
52
  var BOUND = "[x-data]";
51
53
  var FAILED = "data-studio-scope-failed";
52
54
  var FALLBACK = "data-studio-scope-fallback";
@@ -7,6 +7,18 @@
7
7
  page through studio/alpine_scopes; this partial loads the library alone.
8
8
 
9
9
  Pair with: the crop-photo and saving modal registrations in your modal-host
10
- block (see the README and CHANGELOG), and x-data="imageUploadHost({...})". %>
11
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css">
12
- <script defer src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"></script>
10
+ block (see the README and CHANGELOG), and x-data="imageUploadHost({...})".
11
+
12
+ Both tags are pinned with Subresource Integrity, so a changed file on the CDN
13
+ fails to load rather than running. The hashes are sha384 over the exact
14
+ 1.6.2 files; a version bump recomputes both (openssl dgst -sha384 -binary
15
+ FILE | openssl base64 -A), replaces the byte-exact copies in
16
+ test/fixtures/files/cropperjs-<version> that the browser lane serves, and
17
+ runs bin/cropper-sri-live. Keep the URLs exact: consumer CSPs name the
18
+ script by file. %>
19
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"
20
+ integrity="sha384-6LFfkTKLRlzFtgx8xsWyBdKGpcMMQTkv+dB7rAbugeJAu1Ym2q1Aji1cjHBG12Xh"
21
+ crossorigin="anonymous">
22
+ <script defer src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"
23
+ integrity="sha384-jrOgQzBlDeUNdmQn3rUt/PZD+pdcRBdWd/HWRqRo+n2OR2QtGyjSaJC0GiCeH+ir"
24
+ crossorigin="anonymous"></script>
@@ -58,6 +58,12 @@
58
58
  demo_underage (Boolean, optional) — style-guide preview ONLY: make the local
59
59
  resolve take the REFUSED branch, so the guide can show the
60
60
  handoff without a backend. Ignored unless demo.
61
+ progress_pill (Hash, optional) — { current: Integer, total: Integer } for a
62
+ card that is one step of a chain (turf-monster's onboarding runs
63
+ it at 2 of 3). Renders blocks/_progress_pill FIRST inside the
64
+ card's root, above the title row, where the chain's other steps
65
+ put theirs. Absent (the default), the card renders exactly as it
66
+ did before the local existed. Any other key raises.
61
67
 
62
68
  The scope, birthdayModal(), is studio/birthday, published on every page by
63
69
  studio/alpine_scopes. Single root element (modal-host template mount rule).
@@ -90,8 +96,20 @@
90
96
  demo = local_assigns.fetch(:demo, false)
91
97
  demo_underage = local_assigns.fetch(:demo_underage, false)
92
98
  age_gate_modal_id = local_assigns.fetch(:age_gate_modal_id, "age-gate")
99
+ # A Hash or nothing, and only the two keys blocks/_progress_pill reads: a typo
100
+ # (`totl:`) would otherwise fall back to the pill's default total and draw a
101
+ # chain of the wrong length with no error anywhere.
102
+ progress_pill = local_assigns[:progress_pill]
103
+ unless progress_pill.nil?
104
+ unless progress_pill.is_a?(Hash) && progress_pill.key?(:current) && (progress_pill.keys - %i[current total]).empty?
105
+ raise ArgumentError, "blocks/_birthday progress_pill: takes { current:, total: }, got #{progress_pill.inspect}"
106
+ end
107
+ end
93
108
  %>
94
109
  <div x-data="birthdayModal({ minAge: <%= min_age.to_i %>, state: '<%= j state %>', url: '<%= j submit_url %>', store: '<%= j modal_store %>', demo: <%= demo ? 'true' : 'false' %>, demoUnderage: <%= demo_underage ? 'true' : 'false' %>, gateId: '<%= j age_gate_modal_id.to_s %>' })">
110
+ <% if progress_pill %>
111
+ <%= render "studio/modals/blocks/progress_pill", **progress_pill %>
112
+ <% end %>
95
113
  <%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
96
114
  <%# The age line only exists when the app HAS a bar. A card that says
97
115
  "You must be 0+" is what an unconditional line produces for a host that