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 +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +8 -0
- data/app/controllers/registrations_controller.rb +3 -1
- data/app/helpers/studio/site_footer_helper.rb +9 -0
- data/app/views/registrations/new.html.erb +58 -35
- data/app/views/studio/site_footer/_assets.html.erb +27 -159
- data/app/views/studio/site_footer/_map.html.erb +4 -4
- data/app/views/studio/site_footer/_map_assets.html.erb +157 -0
- data/docs/BOOKING.md +377 -0
- data/docs/SITE_FOOTER.md +375 -0
- data/lib/studio/engine.rb +81 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +32 -0
- data/studio-engine.gemspec +8 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b6007443c9eeb21b16c521e76c956e5c05755ff0c264bbbf0f387f04ee79bd8
|
|
4
|
+
data.tar.gz: 1c6dd57eaf48aa3900de531b440766c8cb69281172f6c8a1aeb0daae45b7ca1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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="
|
|
31
|
-
<%= f.
|
|
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: "
|
|
51
|
+
placeholder: "you@example.com" %>
|
|
34
52
|
</div>
|
|
35
|
-
<% end %>
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
2
|
-
`studio_site_footer_assets
|
|
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
|
|
17
|
+
.ftr a { transition: color .15s ease; }
|
|
13
18
|
.ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
|
|
14
|
-
.ftr-link
|
|
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
|
-
|
|
22
|
-
.ftr-link-plain {
|
|
23
|
-
|
|
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
|
-
|
|
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 = '© <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/
|
|
3
|
-
if it never does, the link inside is the whole map:
|
|
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
|
-
<%=
|
|
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 = '© <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>
|