studio-engine 0.84.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.
@@ -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>