studio-engine 0.85.0 → 0.86.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 940823449254339d163e66fe93eabdab83cc36e25f4b80600ab02c6552e3d624
4
- data.tar.gz: 1ce338c5ba90749bbd3a5217c5dba0bd3813b5be96996c15c734cdacdb1b0f0d
3
+ metadata.gz: 0b6007443c9eeb21b16c521e76c956e5c05755ff0c264bbbf0f387f04ee79bd8
4
+ data.tar.gz: 1c6dd57eaf48aa3900de531b440766c8cb69281172f6c8a1aeb0daae45b7ca1b
5
5
  SHA512:
6
- metadata.gz: c322848f2708206583cff895faf563e34b85853494172dfd6f859568a771555a5f5b7859c0ac517a361679a282dbe5af1e5aa91b4bdce9a5e5df57c38a4cbf59
7
- data.tar.gz: 70c41c33f57623eee5a8fe63aab6ff5c804a3570f055cca12bde70c8ae0919b20c3026de2100518e01ccab75c79f77afcc21674dede4c76ea7d6b1be4b6d5e0f
6
+ metadata.gz: 9fe2eb18d609180eeaaf03be1cf497bfc1b57407266565b8328757de5dd7f7c0633d5c5a81ab244cb39e4be9a211080a903c2d0490e181cfa07e89aca4838f97
7
+ data.tar.gz: 997135fe0ac9f3aea6d0da57c0d8af31b95e49320464fda777f441479250edf81af4bdbd4e93043a971d406e1779f819a7490707e733621e156ad95432c1c204
data/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.86.0 — 2026-10-03
8
+
9
+ ### Added
10
+
11
+ - **Footer-only consumers: an app with no ActiveRecord can take the site
12
+ footer alone, with no workarounds.** When `ActiveRecord::Railtie` is not
13
+ loaded (`Studio.active_record?` reads false) the engine requires the
14
+ ActiveSupport core extensions it calls at load time, skips the user-contract
15
+ check, keeps every `app/` root but `app/helpers` (the two `concerns` roots
16
+ included) out of eager loading, and answers
17
+ `studio_engine:install:migrations` with a no-op that exits 0 and writes
18
+ nothing. A host with ActiveRecord is unchanged. docs/SITE_FOOTER.md, *An app
19
+ with no database*, has the recipe; rantly can drop its four workarounds
20
+ (`require "active_support/core_ext/integer/time"`,
21
+ `Studio.validate_user_contract = false`, its `do_not_eager_load` loop, and its
22
+ replacement rake task).
23
+ - **docs/SITE_FOOTER.md and docs/BOOKING.md ship in the gem**, so a consumer's
24
+ citation of them resolves in the installed copy.
25
+
26
+ ### Changed
27
+
28
+ - **Footer links are painted at full opacity in `--ftr-link-ink`**, which
29
+ defaults to `--color-text-secondary`, instead of the inherited body colour at
30
+ 78% opacity. On the default theme the links clear WCAG AA on the band in both
31
+ themes; an app that maps the tokens onto its own palette sets
32
+ `--color-text-secondary` (or `--ftr-link-ink`) to an ink that clears 4.5:1 on
33
+ its `--color-surface-alt` (docs/SITE_FOOTER.md, *Colours*). A refused href's
34
+ label (`.ftr-link-plain`) takes the same ink.
35
+ - **A footer with no address names no Leaflet.** The map's styles and mount
36
+ script moved from `studio/site_footer/_assets` to a new
37
+ `studio/site_footer/_map_assets`, rendered only with a map
38
+ (`studio_footer_map_assets`).
39
+
40
+ ### Fixed
41
+
42
+ - **`/signup` honours `Studio.auth_methods`, as `/login` already did.** The
43
+ engine's `registrations/new` rendered Password, Confirm Password and "Sign up
44
+ with Google" whatever the app configured, while the passwordless
45
+ `RegistrationsController#create` reads the email alone. It now renders the
46
+ password form only when `Studio.password_login_available?`, otherwise the
47
+ email-only form (button "Send sign-in link", wording from
48
+ `Studio.sign_in_label`) when `:magic_link` is enabled, and the Google button and
49
+ its divider only with `:google`. The Name field renders only on the password
50
+ path, the one path that stores it; a passwordless signup discarded the name, so
51
+ it no longer asks. `#create` now branches on the same predicate the view does
52
+ (`password_login_available?` rather than `auth_method?(:password)`); for every
53
+ app that boots, the two agree. A password app renders exactly as before.
54
+ Consumers that wrote a host `app/views/registrations/new.html.erb` to hide the
55
+ password fields (McRitchie Industries) may delete it after the bump; the hub
56
+ and Turf Monster override it for their unified auth card and keep theirs.
57
+
7
58
  ## 0.85.0 — 2026-10-02
8
59
 
9
60
  ### Changed
data/README.md CHANGED
@@ -73,6 +73,14 @@ Studio.configure do |config|
73
73
  end
74
74
  ```
75
75
 
76
+ `auth_methods` draws both auth pages. `/login` and `/signup` render the password
77
+ form only when `:password` is enabled and the User has `authenticate`
78
+ (`Studio.password_login_available?`); otherwise they render the email-only
79
+ sign-in-link form when `:magic_link` is enabled, and the Google button only with
80
+ `:google`. A passwordless signup stores nothing from the form: it mails a link,
81
+ and using the link creates the account. So `registration_params`' `:name` shows a
82
+ Name field only on a password app.
83
+
76
84
  ### Local log rotation (automatic — nothing to configure)
77
85
 
78
86
  The engine caps the host app's **development** log at 16 MB and its **test** log
@@ -12,7 +12,9 @@ class RegistrationsController < ApplicationController
12
12
  # straight from a signup POST would skip proof of email ownership, so we
13
13
  # treat "sign up" as a magic-link request — the create-or-login link (which
14
14
  # only fires after the recipient clicks it) does the account creation.
15
- unless Studio.auth_method?(:password)
15
+ # Gated on the same predicate registrations/new renders the password form on,
16
+ # so the form a visitor sees is always the form this action reads.
17
+ unless Studio.password_login_available?
16
18
  email = (params.dig(:user, :email) || params[:email]).to_s.strip.downcase
17
19
  if email.match?(URI::MailTo::EMAIL_REGEXP)
18
20
  token = issue_magic_link(email, nil)
@@ -155,6 +155,15 @@ module Studio
155
155
  render "studio/site_footer/assets"
156
156
  end
157
157
 
158
+ # The map's styles and mount script, once per page. Only the map partial
159
+ # asks, so a page with no map names no Leaflet.
160
+ def studio_footer_map_assets
161
+ return if @_studio_footer_map_assets_rendered
162
+
163
+ @_studio_footer_map_assets_rendered = true
164
+ render "studio/site_footer/map_assets"
165
+ end
166
+
158
167
  def studio_booking_assets
159
168
  return if @_studio_booking_assets_rendered
160
169
 
@@ -3,6 +3,15 @@
3
3
  words = Studio.app_name.split
4
4
  last_word = words.pop
5
5
  prefix = words.join(" ")
6
+ # /signup renders from the app's configured auth_methods, exactly as /login does
7
+ # (sessions/new.html.erb): the PASSWORD form only when passwords are available
8
+ # (Studio.password_login_available?), otherwise the passwordless email form when
9
+ # :magic_link is enabled, and the Google button only when :google is. The passwordless
10
+ # POST (RegistrationsController#create) reads the email alone and mails a sign-in link,
11
+ # so that form asks for no password and no name: neither would be stored.
12
+ password_signup = Studio.password_login_available?
13
+ email_signup = password_signup || Studio.auth_method?(:magic_link)
14
+ ask_name = password_signup && Studio.registration_params.include?(:name)
6
15
  %>
7
16
 
8
17
  <div class="min-h-[70vh] flex items-center justify-center">
@@ -24,51 +33,65 @@
24
33
  </div>
25
34
  <% end %>
26
35
 
27
- <%= form_with model: @user, url: signup_path, method: :post do |f| %>
28
- <% if Studio.registration_params.include?(:name) %>
36
+ <% if email_signup %>
37
+ <%= form_with model: @user, url: signup_path, method: :post do |f| %>
38
+ <% if ask_name %>
39
+ <div class="mb-5">
40
+ <label for="user_name" class="block text-sm text-secondary mb-2 font-medium">Name</label>
41
+ <%= f.text_field :name, id: "user_name", autofocus: true,
42
+ class: "input-field",
43
+ placeholder: "Your name" %>
44
+ </div>
45
+ <% end %>
46
+
29
47
  <div class="mb-5">
30
- <label for="user_name" class="block text-sm text-secondary mb-2 font-medium">Name</label>
31
- <%= f.text_field :name, id: "user_name", autofocus: true,
48
+ <label for="user_email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
49
+ <%= f.email_field :email, id: "user_email", autofocus: !ask_name,
32
50
  class: "input-field",
33
- placeholder: "Your name" %>
51
+ placeholder: "you@example.com" %>
34
52
  </div>
35
- <% end %>
36
53
 
37
- <div class="mb-5">
38
- <label for="user_email" class="block text-sm text-secondary mb-2 font-medium">Email</label>
39
- <%= f.email_field :email, id: "user_email", autofocus: !Studio.registration_params.include?(:name),
40
- class: "input-field",
41
- placeholder: "you@example.com" %>
42
- </div>
54
+ <% if password_signup %>
55
+ <div class="mb-5">
56
+ <label for="user_password" class="block text-sm text-secondary mb-2 font-medium">Password</label>
57
+ <%= f.password_field :password, id: "user_password",
58
+ class: "input-field",
59
+ placeholder: "Choose a password" %>
60
+ </div>
43
61
 
44
- <div class="mb-5">
45
- <label for="user_password" class="block text-sm text-secondary mb-2 font-medium">Password</label>
46
- <%= f.password_field :password, id: "user_password",
47
- class: "input-field",
48
- placeholder: "Choose a password" %>
49
- </div>
62
+ <div class="mb-6">
63
+ <label for="user_password_confirmation" class="block text-sm text-secondary mb-2 font-medium">Confirm Password</label>
64
+ <%= f.password_field :password_confirmation, id: "user_password_confirmation",
65
+ class: "input-field",
66
+ placeholder: "Confirm your password" %>
67
+ </div>
50
68
 
51
- <div class="mb-6">
52
- <label for="user_password_confirmation" class="block text-sm text-secondary mb-2 font-medium">Confirm Password</label>
53
- <%= f.password_field :password_confirmation, id: "user_password_confirmation",
54
- class: "input-field",
55
- placeholder: "Confirm your password" %>
56
- </div>
69
+ <button type="submit" class="btn btn-primary btn-lg w-full">
70
+ Sign Up
71
+ </button>
72
+ <% else %>
73
+ <button type="submit" class="btn btn-primary btn-lg w-full">
74
+ Send <%= Studio::AuthLabels.link_noun(Studio.sign_in_label) %>
75
+ </button>
57
76
 
58
- <button type="submit" class="btn btn-primary btn-lg w-full">
59
- Sign Up
60
- </button>
77
+ <p class="text-center text-secondary text-sm mt-4">We will email you a link that signs you in and creates your account.</p>
78
+ <% end %>
79
+ <% end %>
61
80
  <% end %>
62
81
 
63
- <div class="relative my-6">
64
- <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
65
- <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
66
- </div>
82
+ <% if Studio.auth_method?(:google) %>
83
+ <% if email_signup %>
84
+ <div class="relative my-6">
85
+ <div class="absolute inset-0 flex items-center"><div class="w-full border-t border-subtle"></div></div>
86
+ <div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
87
+ </div>
88
+ <% end %>
67
89
 
68
- <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
69
- class: "btn btn-google btn-lg w-full gap-3" do %>
70
- <%= render "components/google_logo" %>
71
- Sign up with Google
90
+ <%= button_to "/auth/google_oauth2", method: :post, data: { turbo: false },
91
+ class: "btn btn-google btn-lg w-full gap-3" do %>
92
+ <%= render "components/google_logo" %>
93
+ Sign up with Google
94
+ <% end %>
72
95
  <% end %>
73
96
 
74
97
  <p class="text-center text-secondary text-sm mt-6">
@@ -1,26 +1,44 @@
1
- <%# The site footer's styles and its map mount. Rendered once per page, by
2
- `studio_site_footer_assets` (the footer and the map partial both ask).
1
+ <%# The site footer's styles. Rendered once per page, by
2
+ `studio_site_footer_assets`.
3
3
 
4
4
  SCOPED CSS, NOT UTILITIES. Everything the footer needs to lay out is here,
5
5
  reading the theme's custom properties with a fallback beside each, so it
6
- renders the same in an app whose Tailwind build has never seen these views. %>
6
+ renders the same in an app whose Tailwind build has never seen these views.
7
+
8
+ NOTHING OF THE MAP. Its styles and its mount script are
9
+ studio/site_footer/_map_assets, which only studio/site_footer/_map renders,
10
+ so a footer with no address names no Leaflet anywhere on the page. %>
7
11
  <style>
8
12
  .ftr { --ftr-primary: var(--color-primary, #8b5cf6); --ftr-primary-rgb: var(--color-primary-500-rgb, 139 92 246);
9
13
  --ftr-line: var(--color-border, rgb(127 127 127 / .25)); --ftr-ink: var(--color-text, inherit);
14
+ --ftr-link-ink: var(--color-text-secondary, currentColor);
10
15
  background: var(--color-surface-alt, transparent); color: var(--color-text-body, inherit);
11
16
  border-top: 1px solid var(--ftr-line); }
12
- .ftr a { transition: color .15s ease, opacity .15s ease; }
17
+ .ftr a { transition: color .15s ease; }
13
18
  .ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
14
- .ftr-link { color: inherit; opacity: .78; text-decoration: none; }
19
+ /* LINK INK. A link is painted in --ftr-link-ink at FULL opacity, never the
20
+ inherited body colour faded: that read 4.5:1 or better only where the body
21
+ ink happened to be dark enough, and failed in light mode for a consumer
22
+ whose body ink is light. The default is the engine's --color-text-secondary,
23
+ which ThemeResolver derives to clear WCAG AA 4.5:1 on every surface in both
24
+ themes (--color-surface-alt, this band's background, among them). An app
25
+ that maps the theme tokens onto its own palette sets --color-text-secondary
26
+ to an ink that clears 4.5:1 on its --color-surface-alt, or sets
27
+ --ftr-link-ink on .ftr directly (docs/SITE_FOOTER.md, "Colours").
28
+ test/lib/site_footer_link_contrast_test.rb measures it. */
29
+ .ftr-link { color: var(--ftr-link-ink); text-decoration: none; }
15
30
  /* A label that is an address (an email address, a URL) stays on one line: a browser
16
31
  would otherwise wrap it after a hyphen or a slash. Its track is at least as
17
32
  wide as it is (the grid below), and the tracks beside it give way. It is
18
33
  broken only on a screen narrower than any phone, where no layout holds it. */
19
34
  .ftr-link-solid { white-space: nowrap; }
20
35
  @media (max-width: 299px) { .ftr-link-solid { white-space: normal; overflow-wrap: anywhere; } }
21
- .ftr-link-disabled { opacity: .38; cursor: not-allowed; }
22
- .ftr-link-plain { opacity: .78; }
23
- .ftr-link:hover, .ftr-link:focus-visible { opacity: 1; color: var(--ftr-primary); }
36
+ /* A refused href renders its label as text, in the link ink. */
37
+ .ftr-link-plain { color: var(--ftr-link-ink); }
38
+ /* A disabled label ("Coming soon") is faded on purpose: WCAG exempts an
39
+ inactive control from the contrast minimum. */
40
+ .ftr-link-disabled { color: var(--ftr-link-ink); opacity: .38; cursor: not-allowed; }
41
+ .ftr-link:hover, .ftr-link:focus-visible { color: var(--ftr-primary); }
24
42
 
25
43
  /* Brand, then the link columns (.ftr-col), on a grid with explicit tracks.
26
44
  below 768px: the brand across the top, the link columns two to a row
@@ -78,155 +96,5 @@
78
96
  /* With no map between them, a rule separates the legal line from what is above. */
79
97
  .ftr-legal-ruled { border-top: 1px solid var(--ftr-line); }
80
98
 
81
- /* The map. isolation keeps Leaflet's internal z-indexes (up to 1000) under
82
- the pinned navbar and every modal. It reads the same properties outside
83
- the footer, where .ftr has not set them. */
84
- .ftr-map { position: relative; isolation: isolate; overflow: hidden;
85
- background: rgb(var(--ftr-primary-rgb, var(--color-primary-500-rgb, 139 92 246)) / .08); }
86
- /* One keyless basemap for both themes: in dark mode the light tiles are
87
- inverted and pulled toward the site's violet rather than swapped. */
88
- .dark .ftr-map .leaflet-tile-pane { filter: invert(1) hue-rotate(205deg) saturate(.55) brightness(.9) contrast(.92); }
89
- .ftr-map-wide { width: 100%; height: clamp(16rem, 26vw, 26rem);
90
- border-block: 1px solid var(--ftr-line, var(--color-border, rgb(127 127 127 / .25))); }
91
- .ftr-map-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
92
- padding: 1rem; text-align: center; font-size: .85rem; color: inherit; }
93
- /* Leaflet puts .leaflet-container on the [data-footer-map] element ITSELF, so
94
- this is a compound selector, not a descendant one. It keeps the tint behind
95
- the tiles (Leaflet's own is #ddd) and the page's font in the controls. */
96
- .ftr-map.leaflet-container { background: rgb(var(--ftr-primary-rgb, var(--color-primary-500-rgb, 139 92 246)) / .08); font: inherit; }
97
- .ftr-map .leaflet-control-attribution { font-size: 9px; background: rgb(0 0 0 / .45); color: #ddd; }
98
- .ftr-map .leaflet-control-attribution a { color: #fff; }
99
- .ftr-map .leaflet-bar a { background: var(--color-surface, #fff); color: var(--color-text, #111); border-color: rgb(127 127 127 / .3); }
100
- .ftr-pin { position: relative; width: 18px; height: 18px; }
101
- .ftr-pin::before, .ftr-pin::after { content: ""; position: absolute; inset: 0; border-radius: 9999px;
102
- background: var(--ftr-primary, var(--color-primary, #8b5cf6)); }
103
- .ftr-pin::before { animation: ftr-ping 2.2s cubic-bezier(0, 0, .2, 1) infinite; opacity: .6; }
104
- .ftr-pin::after { border: 3px solid #fff; box-shadow: 0 2px 8px rgb(0 0 0 / .45); }
105
- @keyframes ftr-ping { 75%, 100% { transform: scale(3.2); opacity: 0; } }
106
- @media (prefers-reduced-motion: reduce) { .ftr-pin::before { animation: none; } .ftr a, .ftr-social { transition: none; } }
99
+ @media (prefers-reduced-motion: reduce) { .ftr a, .ftr-social { transition: none; } }
107
100
  </style>
108
-
109
- <script>
110
- // Mounts a Leaflet map on every [data-footer-map]. Leaflet (script and
111
- // stylesheet) is fetched once, on demand, from the URLs the element carries,
112
- // so a page with no map pays nothing for it. The basemap follows the site
113
- // theme in CSS (see .dark .leaflet-tile-pane above). Tiles are OpenStreetMap's
114
- // keyless ones; a basemap that needs an API key does not belong in a gem.
115
- //
116
- // NOTHING STARTS BEFORE THE WINDOW'S `load`, AND NOTHING UNTIL THE MAP IS NEAR
117
- // THE VIEWPORT. The tiles are a third party's, and a request that starts
118
- // before `load` holds that event open for as long as the third party takes to
119
- // answer. The map is also at the bottom of every public page, where most
120
- // visits never reach: those pay for neither Leaflet nor a single tile.
121
- //
122
- // THE GUARD IS THE ENGINE'S OWN NAME, AND THE ELEMENTS ARE THE ENGINE'S OWN. An
123
- // app that still renders a local footer script (mcritchie-studio, until it
124
- // adopts this one) guards it with the unprefixed name and marks its map
125
- // [data-footer-map] too. Sharing either would let whichever script ran first
126
- // in a Turbo session stand the other down and then fail on its elements: that
127
- // app's map carries no data-leaflet-js, so there is nothing here to fetch.
128
- (function () {
129
- if (window.__studioFooterMapsArmed) return;
130
- window.__studioFooterMapsArmed = true;
131
-
132
- var TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
133
- var ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
134
- var maps = [];
135
- var loading = null;
136
-
137
- // Checked on every mount, not once: a page visit that rebuilds the head
138
- // must not leave a mounted map without its stylesheet.
139
- function ensureStylesheet(href) {
140
- if (!href || document.querySelector('link[data-studio-leaflet]')) return;
141
- var link = document.createElement('link');
142
- link.rel = 'stylesheet';
143
- link.href = href;
144
- link.setAttribute('data-studio-leaflet', '');
145
- document.head.appendChild(link);
146
- }
147
-
148
- function loadLeaflet(src) {
149
- if (window.L) return Promise.resolve();
150
- if (loading) return loading;
151
- loading = new Promise(function (resolve, reject) {
152
- var s = document.createElement('script');
153
- s.src = src;
154
- s.onload = resolve;
155
- // A failed fetch must not be remembered: the next visit tries again.
156
- s.onerror = function () { loading = null; s.remove(); reject(new Error('leaflet failed to load')); };
157
- document.head.appendChild(s);
158
- });
159
- return loading;
160
- }
161
-
162
- function mount(el) {
163
- if (el.__footerMap) return;
164
- var at = [parseFloat(el.dataset.lat), parseFloat(el.dataset.lng)];
165
- if (isNaN(at[0]) || isNaN(at[1])) return;
166
- // NO ONE-FINGER DRAG ON A TOUCH DEVICE. The map runs edge to edge, so on a
167
- // phone a swipe that starts on it would pan the map instead of scrolling
168
- // the page, and there is no gutter to scroll by. With dragging off Leaflet
169
- // leaves touch-action at pan-x pan-y: one finger scrolls the page, and a
170
- // pinch still zooms the map (touchZoom stays on, and moves it under the
171
- // fingers). The zoom buttons and the directions link do the rest.
172
- var map = L.map(el, { center: at, zoom: parseInt(el.dataset.zoom, 10) || 15,
173
- scrollWheelZoom: false, attributionControl: true,
174
- dragging: !L.Browser.mobile, touchZoom: true,
175
- zoomControl: el.dataset.controls !== 'false' });
176
- map.attributionControl.setPrefix(false);
177
- L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map);
178
- L.marker(at, { icon: L.divIcon({ className: '', html: '<div class="ftr-pin"></div>', iconSize: [18, 18], iconAnchor: [9, 9] }),
179
- keyboard: false }).addTo(map);
180
- // Page scroll stays page scroll until the visitor commits to the map.
181
- map.on('click', function () { map.scrollWheelZoom.enable(); });
182
- map.on('mouseout', function () { map.scrollWheelZoom.disable(); });
183
- var fallback = el.querySelector('.ftr-map-fallback');
184
- if (fallback) { el.__footerFallback = fallback; fallback.remove(); }
185
- el.__footerMap = map;
186
- maps.push(el);
187
- }
188
-
189
- function fetchAndMount(el) {
190
- ensureStylesheet(el.dataset.leafletCss);
191
- loadLeaflet(el.dataset.leafletJs)
192
- .then(function () { if (el.isConnected) mount(el); })
193
- .catch(function () { /* the fallback link stays */ });
194
- }
195
-
196
- // One observer per map: it fires once, when the map comes within 400px of
197
- // the viewport, and is then dropped.
198
- function watch(el) {
199
- if (el.__footerMap || el.__footerWatch) return;
200
- if (typeof IntersectionObserver !== 'function') { fetchAndMount(el); return; }
201
- var watcher = new IntersectionObserver(function (entries) {
202
- if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
203
- watcher.disconnect();
204
- el.__footerWatch = null;
205
- fetchAndMount(el);
206
- }, { rootMargin: '400px' });
207
- el.__footerWatch = watcher;
208
- watcher.observe(el);
209
- }
210
-
211
- function armAll() { document.querySelectorAll('[data-footer-map][data-leaflet-js]').forEach(watch); }
212
-
213
- function armAfterLoad() {
214
- if (document.readyState === 'complete') armAll();
215
- else window.addEventListener('load', armAll, { once: true });
216
- }
217
-
218
- // A cached Turbo snapshot would restore Leaflet's DOM without its state, so
219
- // the map is taken down and its fallback link put back before the snapshot.
220
- document.addEventListener('turbo:before-cache', function () {
221
- maps.forEach(function (el) {
222
- var fallback = el.__footerFallback;
223
- el.__footerMap.remove();
224
- el.__footerMap = null;
225
- if (fallback && !el.querySelector('.ftr-map-fallback')) el.appendChild(fallback);
226
- });
227
- maps = [];
228
- });
229
- document.addEventListener('turbo:load', armAfterLoad);
230
- armAfterLoad();
231
- })();
232
- </script>
@@ -1,12 +1,12 @@
1
1
  <%# One live map of an address. Leaflet mounts on this element (see
2
- studio/site_footer/_assets, which this partial pulls in); until it does, and
3
- if it never does, the link inside is the whole map: an address that opens
4
- directions. `classes` and `style` size and shape it.
2
+ studio/site_footer/_map_assets, which this partial and nothing else pulls
3
+ in); until it does, and if it never does, the link inside is the whole map:
4
+ an address that opens directions. `classes` and `style` size and shape it.
5
5
 
6
6
  Leaflet's two files ride the engine's asset pipeline (studio/leaflet.js,
7
7
  studio/leaflet.css). Their URLs travel on the element, so the script fetches
8
8
  them only on a page that has a map. %>
9
- <%= studio_site_footer_assets %>
9
+ <%= studio_footer_map_assets %>
10
10
  <div class="ftr-map <%= local_assigns[:classes] %>"
11
11
  <% if local_assigns[:style].present? %>style="<%= style %>"<% end %>
12
12
  data-footer-map
@@ -0,0 +1,157 @@
1
+ <%# The map's styles and its mount script. Rendered once per page, by
2
+ `studio_footer_map_assets`, which only studio/site_footer/_map calls: a page
3
+ with no map (a footer with no address) carries none of this and names no
4
+ Leaflet. The footer's own styles are studio/site_footer/_assets. %>
5
+ <style>
6
+ /* The map. isolation keeps Leaflet's internal z-indexes (up to 1000) under
7
+ the pinned navbar and every modal. It reads the same properties outside
8
+ the footer, where .ftr has not set them. */
9
+ .ftr-map { position: relative; isolation: isolate; overflow: hidden;
10
+ background: rgb(var(--ftr-primary-rgb, var(--color-primary-500-rgb, 139 92 246)) / .08); }
11
+ /* One keyless basemap for both themes: in dark mode the light tiles are
12
+ inverted and pulled toward the site's violet rather than swapped. */
13
+ .dark .ftr-map .leaflet-tile-pane { filter: invert(1) hue-rotate(205deg) saturate(.55) brightness(.9) contrast(.92); }
14
+ .ftr-map-wide { width: 100%; height: clamp(16rem, 26vw, 26rem);
15
+ border-block: 1px solid var(--ftr-line, var(--color-border, rgb(127 127 127 / .25))); }
16
+ .ftr-map-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
17
+ padding: 1rem; text-align: center; font-size: .85rem; color: inherit; }
18
+ /* Leaflet puts .leaflet-container on the [data-footer-map] element ITSELF, so
19
+ this is a compound selector, not a descendant one. It keeps the tint behind
20
+ the tiles (Leaflet's own is #ddd) and the page's font in the controls. */
21
+ .ftr-map.leaflet-container { background: rgb(var(--ftr-primary-rgb, var(--color-primary-500-rgb, 139 92 246)) / .08); font: inherit; }
22
+ .ftr-map .leaflet-control-attribution { font-size: 9px; background: rgb(0 0 0 / .45); color: #ddd; }
23
+ .ftr-map .leaflet-control-attribution a { color: #fff; }
24
+ .ftr-map .leaflet-bar a { background: var(--color-surface, #fff); color: var(--color-text, #111); border-color: rgb(127 127 127 / .3); }
25
+ .ftr-pin { position: relative; width: 18px; height: 18px; }
26
+ .ftr-pin::before, .ftr-pin::after { content: ""; position: absolute; inset: 0; border-radius: 9999px;
27
+ background: var(--ftr-primary, var(--color-primary, #8b5cf6)); }
28
+ .ftr-pin::before { animation: ftr-ping 2.2s cubic-bezier(0, 0, .2, 1) infinite; opacity: .6; }
29
+ .ftr-pin::after { border: 3px solid #fff; box-shadow: 0 2px 8px rgb(0 0 0 / .45); }
30
+ @keyframes ftr-ping { 75%, 100% { transform: scale(3.2); opacity: 0; } }
31
+ @media (prefers-reduced-motion: reduce) { .ftr-pin::before { animation: none; } }
32
+ </style>
33
+
34
+ <script>
35
+ // Mounts a Leaflet map on every [data-footer-map]. Leaflet (script and
36
+ // stylesheet) is fetched once, on demand, from the URLs the element carries,
37
+ // so a page with no map pays nothing for it. The basemap follows the site
38
+ // theme in CSS (see .dark .leaflet-tile-pane above). Tiles are OpenStreetMap's
39
+ // keyless ones; a basemap that needs an API key does not belong in a gem.
40
+ //
41
+ // NOTHING STARTS BEFORE THE WINDOW'S `load`, AND NOTHING UNTIL THE MAP IS NEAR
42
+ // THE VIEWPORT. The tiles are a third party's, and a request that starts
43
+ // before `load` holds that event open for as long as the third party takes to
44
+ // answer. The map is also at the bottom of every public page, where most
45
+ // visits never reach: those pay for neither Leaflet nor a single tile.
46
+ //
47
+ // THE GUARD IS THE ENGINE'S OWN NAME, AND THE ELEMENTS ARE THE ENGINE'S OWN. An
48
+ // app that still renders a local footer script (mcritchie-studio, until it
49
+ // adopts this one) guards it with the unprefixed name and marks its map
50
+ // [data-footer-map] too. Sharing either would let whichever script ran first
51
+ // in a Turbo session stand the other down and then fail on its elements: that
52
+ // app's map carries no data-leaflet-js, so there is nothing here to fetch.
53
+ (function () {
54
+ if (window.__studioFooterMapsArmed) return;
55
+ window.__studioFooterMapsArmed = true;
56
+
57
+ var TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
58
+ var ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
59
+ var maps = [];
60
+ var loading = null;
61
+
62
+ // Checked on every mount, not once: a page visit that rebuilds the head
63
+ // must not leave a mounted map without its stylesheet.
64
+ function ensureStylesheet(href) {
65
+ if (!href || document.querySelector('link[data-studio-leaflet]')) return;
66
+ var link = document.createElement('link');
67
+ link.rel = 'stylesheet';
68
+ link.href = href;
69
+ link.setAttribute('data-studio-leaflet', '');
70
+ document.head.appendChild(link);
71
+ }
72
+
73
+ function loadLeaflet(src) {
74
+ if (window.L) return Promise.resolve();
75
+ if (loading) return loading;
76
+ loading = new Promise(function (resolve, reject) {
77
+ var s = document.createElement('script');
78
+ s.src = src;
79
+ s.onload = resolve;
80
+ // A failed fetch must not be remembered: the next visit tries again.
81
+ s.onerror = function () { loading = null; s.remove(); reject(new Error('leaflet failed to load')); };
82
+ document.head.appendChild(s);
83
+ });
84
+ return loading;
85
+ }
86
+
87
+ function mount(el) {
88
+ if (el.__footerMap) return;
89
+ var at = [parseFloat(el.dataset.lat), parseFloat(el.dataset.lng)];
90
+ if (isNaN(at[0]) || isNaN(at[1])) return;
91
+ // NO ONE-FINGER DRAG ON A TOUCH DEVICE. The map runs edge to edge, so on a
92
+ // phone a swipe that starts on it would pan the map instead of scrolling
93
+ // the page, and there is no gutter to scroll by. With dragging off Leaflet
94
+ // leaves touch-action at pan-x pan-y: one finger scrolls the page, and a
95
+ // pinch still zooms the map (touchZoom stays on, and moves it under the
96
+ // fingers). The zoom buttons and the directions link do the rest.
97
+ var map = L.map(el, { center: at, zoom: parseInt(el.dataset.zoom, 10) || 15,
98
+ scrollWheelZoom: false, attributionControl: true,
99
+ dragging: !L.Browser.mobile, touchZoom: true,
100
+ zoomControl: el.dataset.controls !== 'false' });
101
+ map.attributionControl.setPrefix(false);
102
+ L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map);
103
+ L.marker(at, { icon: L.divIcon({ className: '', html: '<div class="ftr-pin"></div>', iconSize: [18, 18], iconAnchor: [9, 9] }),
104
+ keyboard: false }).addTo(map);
105
+ // Page scroll stays page scroll until the visitor commits to the map.
106
+ map.on('click', function () { map.scrollWheelZoom.enable(); });
107
+ map.on('mouseout', function () { map.scrollWheelZoom.disable(); });
108
+ var fallback = el.querySelector('.ftr-map-fallback');
109
+ if (fallback) { el.__footerFallback = fallback; fallback.remove(); }
110
+ el.__footerMap = map;
111
+ maps.push(el);
112
+ }
113
+
114
+ function fetchAndMount(el) {
115
+ ensureStylesheet(el.dataset.leafletCss);
116
+ loadLeaflet(el.dataset.leafletJs)
117
+ .then(function () { if (el.isConnected) mount(el); })
118
+ .catch(function () { /* the fallback link stays */ });
119
+ }
120
+
121
+ // One observer per map: it fires once, when the map comes within 400px of
122
+ // the viewport, and is then dropped.
123
+ function watch(el) {
124
+ if (el.__footerMap || el.__footerWatch) return;
125
+ if (typeof IntersectionObserver !== 'function') { fetchAndMount(el); return; }
126
+ var watcher = new IntersectionObserver(function (entries) {
127
+ if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
128
+ watcher.disconnect();
129
+ el.__footerWatch = null;
130
+ fetchAndMount(el);
131
+ }, { rootMargin: '400px' });
132
+ el.__footerWatch = watcher;
133
+ watcher.observe(el);
134
+ }
135
+
136
+ function armAll() { document.querySelectorAll('[data-footer-map][data-leaflet-js]').forEach(watch); }
137
+
138
+ function armAfterLoad() {
139
+ if (document.readyState === 'complete') armAll();
140
+ else window.addEventListener('load', armAll, { once: true });
141
+ }
142
+
143
+ // A cached Turbo snapshot would restore Leaflet's DOM without its state, so
144
+ // the map is taken down and its fallback link put back before the snapshot.
145
+ document.addEventListener('turbo:before-cache', function () {
146
+ maps.forEach(function (el) {
147
+ var fallback = el.__footerFallback;
148
+ el.__footerMap.remove();
149
+ el.__footerMap = null;
150
+ if (fallback && !el.querySelector('.ftr-map-fallback')) el.appendChild(fallback);
151
+ });
152
+ maps = [];
153
+ });
154
+ document.addEventListener('turbo:load', armAfterLoad);
155
+ armAfterLoad();
156
+ })();
157
+ </script>