studio-engine 0.85.0 → 0.87.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: e3bc2b4fbaacbbcb3895cdcc95f4caa6421df1cc1cf16c9e53d8bdd5aa19f3a7
4
+ data.tar.gz: b453a06ef41d2bd56b23a56e2f80bc29f19bd793702dbf23e6b1f7236f0cfb37
5
5
  SHA512:
6
- metadata.gz: c322848f2708206583cff895faf563e34b85853494172dfd6f859568a771555a5f5b7859c0ac517a361679a282dbe5af1e5aa91b4bdce9a5e5df57c38a4cbf59
7
- data.tar.gz: 70c41c33f57623eee5a8fe63aab6ff5c804a3570f055cca12bde70c8ae0919b20c3026de2100518e01ccab75c79f77afcc21674dede4c76ea7d6b1be4b6d5e0f
6
+ metadata.gz: f88b17d347d21f137b61a91d0e1d0e70c67e3b464e36f1f0de7bb198798c914fd9a09af6a567639cae488ae67aabebdbac811c6a2a55f1ba02f9bdada74bb8bc
7
+ data.tar.gz: 975c4a433ea0dac45d70a683afc799984ee027c27a33e6cf823467cc545f01dda4575ef6623efd830b4bdf486cb4a29b53cee7e0886b441681a30af3b7f4ff38
data/CHANGELOG.md CHANGED
@@ -4,6 +4,94 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.87.0 — 2026-10-04
8
+
9
+ ### Changed
10
+
11
+ - **The site footer is on by default. An unset `config.site_footer` now renders
12
+ a footer wherever `studio_site_footer` is called**; before, it rendered
13
+ nothing. The default footer is built from what the engine already knows: the
14
+ app's name (the site identity's title, else `config.app_name`), its footer or
15
+ navbar logo, the © line, and a Privacy Policy and a Terms of Service link for
16
+ each of the routes named `privacy` and `terms` that the host has (a GET route
17
+ that needs no arguments; anything else is not linked). It has no address, no
18
+ map, no phone, no email, no tagline, no social row and no link columns, and
19
+ names no Leaflet. The rule for where it shows is unchanged. **An app that
20
+ sets `config.site_footer` to a Hash or a callable renders exactly what it did**,
21
+ and an app that does not call the helper is unaffected. A callable that
22
+ answers `nil` still declines the footer for that request.
23
+
24
+ ### Added
25
+
26
+ - **`config.site_footer = false` turns the footer off.** It is the one opt-out,
27
+ and it is distinct from unset (`nil`), which is the default footer.
28
+ `Studio.site_footer_enabled?` and `Studio.site_footer_declared?` read the
29
+ three states.
30
+ - **`config.site_footer_address`: the location, as one setting.** A Hash of the
31
+ facts' `address:` keys (`street:`, `city_line:`, `lat:`, `lng:`, and the
32
+ optional `zoom:` and `directions_url:`) gives the footer its Location band,
33
+ and its map when both coordinates are present, without writing the facts
34
+ callable. It applies to the default footer and to an app's own facts that
35
+ carry no address. An address in the app's own facts wins, whole, and
36
+ `address: false` there shows none.
37
+ - **docs/NEW_APP_SETUP.md has a Site Footer step (§ 10)**, and its layout ends
38
+ with `<%= studio_site_footer %>`. The sections after it moved down by one.
39
+
40
+ ### Fixed
41
+
42
+ - `address: false` in the footer's facts shows no address instead of raising.
43
+
44
+ ## 0.86.0 — 2026-10-03
45
+
46
+ ### Added
47
+
48
+ - **Footer-only consumers: an app with no ActiveRecord can take the site
49
+ footer alone, with no workarounds.** When `ActiveRecord::Railtie` is not
50
+ loaded (`Studio.active_record?` reads false) the engine requires the
51
+ ActiveSupport core extensions it calls at load time, skips the user-contract
52
+ check, keeps every `app/` root but `app/helpers` (the two `concerns` roots
53
+ included) out of eager loading, and answers
54
+ `studio_engine:install:migrations` with a no-op that exits 0 and writes
55
+ nothing. A host with ActiveRecord is unchanged. docs/SITE_FOOTER.md, *An app
56
+ with no database*, has the recipe; rantly can drop its four workarounds
57
+ (`require "active_support/core_ext/integer/time"`,
58
+ `Studio.validate_user_contract = false`, its `do_not_eager_load` loop, and its
59
+ replacement rake task).
60
+ - **docs/SITE_FOOTER.md and docs/BOOKING.md ship in the gem**, so a consumer's
61
+ citation of them resolves in the installed copy.
62
+
63
+ ### Changed
64
+
65
+ - **Footer links are painted at full opacity in `--ftr-link-ink`**, which
66
+ defaults to `--color-text-secondary`, instead of the inherited body colour at
67
+ 78% opacity. On the default theme the links clear WCAG AA on the band in both
68
+ themes; an app that maps the tokens onto its own palette sets
69
+ `--color-text-secondary` (or `--ftr-link-ink`) to an ink that clears 4.5:1 on
70
+ its `--color-surface-alt` (docs/SITE_FOOTER.md, *Colours*). A refused href's
71
+ label (`.ftr-link-plain`) takes the same ink.
72
+ - **A footer with no address names no Leaflet.** The map's styles and mount
73
+ script moved from `studio/site_footer/_assets` to a new
74
+ `studio/site_footer/_map_assets`, rendered only with a map
75
+ (`studio_footer_map_assets`).
76
+
77
+ ### Fixed
78
+
79
+ - **`/signup` honours `Studio.auth_methods`, as `/login` already did.** The
80
+ engine's `registrations/new` rendered Password, Confirm Password and "Sign up
81
+ with Google" whatever the app configured, while the passwordless
82
+ `RegistrationsController#create` reads the email alone. It now renders the
83
+ password form only when `Studio.password_login_available?`, otherwise the
84
+ email-only form (button "Send sign-in link", wording from
85
+ `Studio.sign_in_label`) when `:magic_link` is enabled, and the Google button and
86
+ its divider only with `:google`. The Name field renders only on the password
87
+ path, the one path that stores it; a passwordless signup discarded the name, so
88
+ it no longer asks. `#create` now branches on the same predicate the view does
89
+ (`password_login_available?` rather than `auth_method?(:password)`); for every
90
+ app that boots, the two agree. A password app renders exactly as before.
91
+ Consumers that wrote a host `app/views/registrations/new.html.erb` to hide the
92
+ password fields (McRitchie Industries) may delete it after the bump; the hub
93
+ and Turf Monster override it for their unified auth card and keep theirs.
94
+
7
95
  ## 0.85.0 — 2026-10-02
8
96
 
9
97
  ### Changed
data/README.md CHANGED
@@ -41,7 +41,7 @@ resolved.
41
41
  - **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
42
42
  - **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
43
43
  - **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
44
- - **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer from `config.site_footer` (brand, social profiles, link columns, an address with a live map, legal links), every part optional. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
44
+ - **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
45
45
  - **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
46
46
 
47
47
  ## Configuration
@@ -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)
@@ -10,15 +10,18 @@ module Studio
10
10
  # <%= studio_booking_popup %> the dialog those links open
11
11
  # <%= studio_footer_map %> the map alone, e.g. on a contact page
12
12
  #
13
- # Every one of them renders NOTHING when its configuration is absent
14
- # (Studio.site_footer, Studio.booking_url), so a layout or a shared view can
15
- # call them before an app has declared anything.
13
+ # THE FOOTER IS ON BY DEFAULT: with Studio.site_footer unset, the layout line
14
+ # renders the default footer (the site name, the logo, and the legal links the
15
+ # host has routes for), and `config.site_footer = false` turns it off. The
16
+ # booking helpers render NOTHING without Studio.booking_url, and the map
17
+ # nothing without an address, so a layout or a shared view can call any of
18
+ # them before an app has declared anything.
16
19
  #
17
20
  # Prefixed names for the reason Studio::GeoHelper gives: every helper module is
18
21
  # included into every view.
19
22
  module SiteFooterHelper
20
- # The resolved facts (Studio::SiteFooter.resolve), or nil. Resolved once per
21
- # request.
23
+ # The resolved facts (Studio::SiteFooter.resolve): the app's own, else the
24
+ # default footer's. nil when the footer is off. Resolved once per request.
22
25
  #
23
26
  # A callable that RAISES must not take every page down with it, because the
24
27
  # footer is in the layout. In production the error is logged once per process
@@ -41,7 +44,7 @@ module Studio
41
44
  # Where the footer shows: Studio.site_footer_visible, asked of this view. By
42
45
  # default that is every page for a visitor and, for a signed-in viewer, only
43
46
  # the controllers in Studio.site_footer_controllers (plus the booking page).
44
- # Always false for an app that declared no footer.
47
+ # Always false for an app that turned the footer off.
45
48
  def studio_show_site_footer?
46
49
  return false if studio_site_footer_facts.nil?
47
50
 
@@ -155,6 +158,15 @@ module Studio
155
158
  render "studio/site_footer/assets"
156
159
  end
157
160
 
161
+ # The map's styles and mount script, once per page. Only the map partial
162
+ # asks, so a page with no map names no Leaflet.
163
+ def studio_footer_map_assets
164
+ return if @_studio_footer_map_assets_rendered
165
+
166
+ @_studio_footer_map_assets_rendered = true
167
+ render "studio/site_footer/map_assets"
168
+ end
169
+
158
170
  def studio_booking_assets
159
171
  return if @_studio_booking_assets_rendered
160
172
 
@@ -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