studio-engine 0.82.1 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
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).
3
+
4
+ SCOPED CSS, NOT UTILITIES. Everything the footer needs to lay out is here,
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. %>
7
+ <style>
8
+ .ftr { --ftr-primary: var(--color-primary, #8b5cf6); --ftr-primary-rgb: var(--color-primary-500-rgb, 139 92 246);
9
+ --ftr-line: var(--color-border, rgb(127 127 127 / .25)); --ftr-ink: var(--color-text, inherit);
10
+ background: var(--color-surface-alt, transparent); color: var(--color-text-body, inherit);
11
+ border-top: 1px solid var(--ftr-line); }
12
+ .ftr a { transition: color .15s ease, opacity .15s ease; }
13
+ .ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
14
+ .ftr-link { color: inherit; opacity: .78; text-decoration: none; }
15
+ /* A label that is an address (an email address, a URL) stays on one line: a browser
16
+ would otherwise wrap it after a hyphen or a slash. Its track is at least as
17
+ wide as it is (the grid below), and the tracks beside it give way. It is
18
+ broken only on a screen narrower than any phone, where no layout holds it. */
19
+ .ftr-link-solid { white-space: nowrap; }
20
+ @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); }
24
+
25
+ /* Brand, then the link columns (.ftr-col), on a grid with explicit tracks.
26
+ below 768px: the brand across the top, the link columns two to a row
27
+ from 768px: the brand across the top, every link column in one row
28
+ from 1024px: the brand in that row too, at 1.7 shares
29
+ A track is `Nfr`, never `minmax(0, Nfr)`: an fr track is at least as wide as
30
+ its longest unbreakable word, so the column holding an email address grows
31
+ to hold it and its neighbours give way. Nothing moves to another row.
32
+ --ftr-tracks is one track per link column (Studio::SiteFooter.tracks): the
33
+ first is wider by default, and a column's `width:` hint sets its own.
34
+ A footer with more than four link columns (.ftr-cols-many) keeps the brand
35
+ across the top and wraps the columns four to a row. */
36
+ .ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: 1.2fr 1fr; }
37
+ .ftr-brand { grid-column: 1 / -1; }
38
+ /* The smallest phones (320px): two columns still share a row beside an email
39
+ address, by a narrower gutter and a slightly smaller address. */
40
+ @media (max-width: 359px) {
41
+ .ftr-cols { column-gap: 1rem; }
42
+ .ftr-col .ftr-link-solid { font-size: .875rem; }
43
+ }
44
+ @media (min-width: 768px) {
45
+ .ftr-cols { grid-template-columns: var(--ftr-tracks, 1fr); }
46
+ .ftr-cols-many { grid-template-columns: repeat(4, 1fr); }
47
+ }
48
+ @media (min-width: 1024px) {
49
+ .ftr-cols:not(.ftr-cols-many) { grid-template-columns: 1.7fr var(--ftr-tracks,); }
50
+ .ftr-cols:not(.ftr-cols-many) .ftr-brand { grid-column: auto; }
51
+ }
52
+ .ftr-home { display: inline-flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
53
+ .ftr-logo { width: 3rem; height: 3rem; object-fit: contain; }
54
+ /* A dark mark on a light page: inverted in light mode, as drawn in dark. */
55
+ .ftr-logo-invert { filter: invert(1); }
56
+ .dark .ftr-logo-invert { filter: none; }
57
+ .ftr-wordmark { font-size: 1.5rem; font-weight: 800; line-height: 1.25; letter-spacing: .14em;
58
+ text-transform: uppercase; color: var(--ftr-ink); }
59
+ .ftr-wordmark span { display: block; }
60
+ .ftr-wordmark-accent { color: var(--ftr-primary); }
61
+ .ftr-tagline { margin: 1.5rem 0 0; line-height: 1.625; color: var(--color-text-secondary, inherit); }
62
+ .ftr-email { margin: .75rem 0 0; }
63
+ .ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
64
+ .ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
65
+ border-radius: 9999px; background: var(--ftr-primary); color: #fff; transition: transform .15s ease, filter .15s ease; }
66
+ a.ftr-social:hover { transform: translateY(-2px); filter: brightness(1.12); }
67
+ .ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; }
68
+ .ftr-social-letter { font-weight: 700; font-size: .95rem; line-height: 1; }
69
+ .ftr-heading { margin: 0 0 1rem; font-size: 1rem; font-weight: 700; color: var(--ftr-ink); }
70
+ .ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
71
+
72
+ .ftr-location { border-top: 1px solid var(--ftr-line); padding-block: 3.5rem 2rem; text-align: center; }
73
+ .ftr-location-title { margin: 0; font-size: 1.875rem; line-height: 2.25rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
74
+ .ftr-address { margin-top: 1rem; font-style: normal; line-height: 1.625; }
75
+ .ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; line-height: 1.25rem; }
76
+ .ftr-legal p { margin: 0; }
77
+ .ftr-legal .ftr-copyright { margin-top: .5rem; color: var(--color-text-muted, inherit); }
78
+ /* With no map between them, a rule separates the legal line from what is above. */
79
+ .ftr-legal-ruled { border-top: 1px solid var(--ftr-line); }
80
+
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; } }
107
+ </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>
@@ -0,0 +1,100 @@
1
+ <%# The site footer (Studio.site_footer; docs/SITE_FOOTER.md): brand and social
2
+ profiles at the left, plain link columns under bold headings, a centred
3
+ Location band with a live map, then a quiet centred legal line. Rendered by
4
+ `studio_site_footer`, which passes the resolved `facts`.
5
+
6
+ Every part is optional and a missing fact removes its part. The layout lives
7
+ in studio/site_footer/_assets as scoped CSS (.ftr-*), not Tailwind
8
+ utilities: a utility a consumer's build has not already seen compiles to
9
+ nothing, and a footer that silently loses its grid is the result. %>
10
+ <% address = facts[:address] %>
11
+ <% booking = studio_booking? %>
12
+ <% columns = facts[:columns] %>
13
+ <%= studio_site_footer_assets %>
14
+ <footer class="ftr" data-site-footer>
15
+ <div class="ftr-wrap">
16
+ <% tracks = Studio::SiteFooter.tracks(columns) %>
17
+ <div class="ftr-cols<%= " ftr-cols-many" if columns.size > Studio::SiteFooter::MAX_ROW_COLUMNS %>"<% if tracks %> style="--ftr-tracks: <%= tracks %>"<% end %>>
18
+ <div class="ftr-brand">
19
+ <% if facts[:logo] || facts[:wordmark] %>
20
+ <a href="<%= facts[:home_path] %>" class="ftr-home" aria-label="<%= facts[:name] %>">
21
+ <% if facts[:logo] %>
22
+ <%= image_tag facts[:logo], class: [ "ftr-logo", ("ftr-logo-invert" if facts[:logo_invert]) ].compact.join(" "), alt: "" %>
23
+ <% end %>
24
+ <% if facts[:wordmark] %>
25
+ <span class="ftr-wordmark"><% if facts[:wordmark][0].present? %><span><%= facts[:wordmark][0] %></span><% end %><span class="ftr-wordmark-accent"><%= facts[:wordmark][1] %></span></span>
26
+ <% end %>
27
+ </a>
28
+ <% end %>
29
+ <% if facts[:tagline] %>
30
+ <p class="ftr-tagline"><%= facts[:tagline] %></p>
31
+ <% end %>
32
+ <% if facts[:email] %>
33
+ <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
34
+ <% end %>
35
+ <% if facts[:social].any? %>
36
+ <ul class="ftr-socials" aria-label="Social profiles">
37
+ <% facts[:social].each do |profile| %>
38
+ <li>
39
+ <%# A nil url renders the icon unlinked: the handle is not on record yet. %>
40
+ <%= content_tag(profile[:url] ? :a : :span,
41
+ render("studio/site_footer/social_icon", icon: profile[:icon], label: profile[:label]),
42
+ { class: "ftr-social", title: profile[:url] || profile[:unlinked] ? profile[:label] : "#{profile[:label]} (handle needed)",
43
+ "aria-label": profile[:label] }
44
+ .merge(profile[:url] ? { href: profile[:url], target: "_blank", rel: "noopener" } : { "data-pending": true })) %>
45
+ </li>
46
+ <% end %>
47
+ </ul>
48
+ <% end %>
49
+ </div>
50
+
51
+ <% columns.each do |column| %>
52
+ <nav class="ftr-col" aria-label="<%= column[:heading] || "Footer" %>">
53
+ <% if column[:heading] %>
54
+ <h2 class="ftr-heading"><%= column[:heading] %></h2>
55
+ <% end %>
56
+ <ul class="ftr-list">
57
+ <% column[:links].each do |link| %>
58
+ <li><%= render "studio/site_footer/link", link: link, booking: booking %></li>
59
+ <% end %>
60
+ </ul>
61
+ </nav>
62
+ <% end %>
63
+ </div>
64
+
65
+ <% if address %>
66
+ <div class="ftr-location" data-footer-location>
67
+ <h2 class="ftr-location-title">Location</h2>
68
+ <address class="ftr-address">
69
+ <a href="<%= address[:directions_url] %>" target="_blank" rel="noopener" class="ftr-link"
70
+ aria-label="Get directions to <%= address[:full] %>">
71
+ <%= safe_join([ address[:street], address[:city_line] ].compact, tag.br) %>
72
+ </a>
73
+ </address>
74
+ </div>
75
+ <% end %>
76
+ </div>
77
+
78
+ <%# Outside the centred column on purpose: the map runs edge to edge. %>
79
+ <% if address && address[:map] %>
80
+ <%= render "studio/site_footer/map", address: address, classes: "ftr-map-wide", style: nil, zoom: nil, controls: true %>
81
+ <% end %>
82
+
83
+ <div class="ftr-wrap">
84
+ <div class="ftr-legal<%= " ftr-legal-ruled" unless address && address[:map] %>">
85
+ <% if facts[:legal].any? %>
86
+ <p>
87
+ <% facts[:legal].each_with_index do |link, index| %>
88
+ <%= " · " unless index.zero? %><%= render "studio/site_footer/link", link: link, booking: booking %>
89
+ <% end %>
90
+ </p>
91
+ <% end %>
92
+ <% if facts[:name] %>
93
+ <p class="ftr-copyright">© <%= Time.current.year %> <%= facts[:name] %></p>
94
+ <% end %>
95
+ </div>
96
+ </div>
97
+ </footer>
98
+
99
+ <%# The dialog the footer's booking links open. Nothing without a booking_url. %>
100
+ <%= studio_booking_popup(label: facts[:booking_label], title: facts[:booking_title]) %>
@@ -0,0 +1,16 @@
1
+ <%# One footer link (a normalized row from Studio::SiteFooter.link). A nil href
2
+ is a disabled label. An off-site link opens in a new tab, so the visitor
3
+ keeps the page. A booking link opens the booking popup when the app has a
4
+ booking_url; its href stays as the fallback. %>
5
+ <% if link[:unlinked] %>
6
+ <%# Its href was refused (not a relative path, http, https, mailto or tel). %>
7
+ <span class="ftr-link-plain"><%= link[:label] %></span>
8
+ <% elsif link[:disabled] %>
9
+ <span class="ftr-link-disabled" aria-disabled="true" title="Coming soon"><%= link[:label] %></span>
10
+ <% else %>
11
+ <%# An email address or a URL as a label is one word however many hyphens and
12
+ slashes it holds: .ftr-link-solid keeps it on one line. %>
13
+ <%= link_to link[:label], link[:href], { class: Studio::SiteFooter.solid_label?(link[:label]) ? "ftr-link ftr-link-solid" : "ftr-link" }
14
+ .merge(link[:external] ? { target: "_blank", rel: "noopener" } : {})
15
+ .merge(link[:booking] && booking ? { data: { booking_popup: true, studio_booking: true } } : {}) %>
16
+ <% end %>
@@ -0,0 +1,22 @@
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.
5
+
6
+ Leaflet's two files ride the engine's asset pipeline (studio/leaflet.js,
7
+ studio/leaflet.css). Their URLs travel on the element, so the script fetches
8
+ them only on a page that has a map. %>
9
+ <%= studio_site_footer_assets %>
10
+ <div class="ftr-map <%= local_assigns[:classes] %>"
11
+ <% if local_assigns[:style].present? %>style="<%= style %>"<% end %>
12
+ data-footer-map
13
+ data-lat="<%= address[:lat] %>" data-lng="<%= address[:lng] %>"
14
+ data-zoom="<%= local_assigns[:zoom] || address[:zoom] %>"
15
+ data-controls="<%= local_assigns.fetch(:controls, true) %>"
16
+ data-leaflet-js="<%= asset_path("studio/leaflet.js") %>"
17
+ data-leaflet-css="<%= asset_path("studio/leaflet.css") %>"
18
+ role="region" aria-label="Map of <%= address[:full] %>">
19
+ <a href="<%= address[:directions_url] %>" target="_blank" rel="noopener" class="ftr-map-fallback">
20
+ <%= address[:full] %> · Open in Maps
21
+ </a>
22
+ </div>
@@ -0,0 +1,23 @@
1
+ <%# Brand marks for the footer's social row, drawn in currentColor. An icon the
2
+ engine has no mark for falls back to the label's first letter. %>
3
+ <% case icon %>
4
+ <% when :linkedin, :instagram, :x, :facebook, :youtube %>
5
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
6
+ <% case icon %>
7
+ <% when :linkedin %>
8
+ <path fill="currentColor" d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 1 1 0-4.125 2.062 2.062 0 0 1 0 4.125zm1.782 13.019H3.555V9h3.564v11.452z"/>
9
+ <% when :instagram %>
10
+ <rect x="3" y="3" width="18" height="18" rx="5" fill="none" stroke="currentColor" stroke-width="2"/>
11
+ <circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="2"/>
12
+ <circle cx="17.3" cy="6.7" r="1.3" fill="currentColor"/>
13
+ <% when :x %>
14
+ <path fill="currentColor" d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/>
15
+ <% when :facebook %>
16
+ <path fill="currentColor" d="M22 12a10 10 0 1 0-11.56 9.88v-6.99H7.9V12h2.54V9.8c0-2.5 1.49-3.89 3.78-3.89 1.09 0 2.24.2 2.24.2v2.46h-1.26c-1.24 0-1.63.77-1.63 1.56V12h2.78l-.44 2.89h-2.34v6.99A10 10 0 0 0 22 12z"/>
17
+ <% when :youtube %>
18
+ <path fill="currentColor" d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.6 15.6V8.4l6.2 3.6-6.2 3.6z"/>
19
+ <% end %>
20
+ </svg>
21
+ <% else %>
22
+ <span class="ftr-social-letter" aria-hidden="true"><%= label.to_s[0].to_s.upcase %></span>
23
+ <% end %>
@@ -0,0 +1,102 @@
1
+ <%# The Site footer and booking group of the Tricks board (admin/style).
2
+ The primitives a public site ends with: the footer, its map, and the Google
3
+ Calendar booking frame, popup and link (docs/SITE_FOOTER.md,
4
+ docs/BOOKING.md). Each card
5
+ gives the helper to call; the preview below them is the real footer partial,
6
+ rendered with THIS app's facts when it has declared any and with a sample
7
+ when it has not, so the group is never empty.
8
+
9
+ The booking frame is DRAWN, not embedded: a style guide should not ask
10
+ Google for a calendar every time an admin scrolls past. Its two states, whole
11
+ and cropped, are quarter-scale drawings of a made-up schedule (a 720px page
12
+ whose box runs from 200 to 600), cropped by the same arithmetic the real
13
+ wrapper uses (Studio::Booking.crop). No real schedule's numbers are here. %>
14
+ <%
15
+ own_facts = studio_site_footer_facts
16
+ sample = {
17
+ name: Studio.site_identity[:title], tagline: "What this app is, in a line",
18
+ email: "team@example.com",
19
+ address: { street: "123 Example St", city_line: "Washington, DC 20024", lat: 38.8894, lng: -77.0352 },
20
+ social: [ [ "LinkedIn", :linkedin, "https://www.linkedin.com/" ], [ "Instagram", :instagram, nil ], [ "X", :x, "https://x.com/" ] ],
21
+ columns: [ [ "Contact", [ [ "Schedule a call", "#site-footer", { booking: true } ], [ "Contact", "#site-footer" ] ] ],
22
+ [ "Company", [ [ "Home", "/" ], [ "Career", nil ], [ "Blog", "https://example.com" ] ] ] ],
23
+ legal: [ [ "Privacy Policy", "#site-footer" ], [ "Terms of Service", "#site-footer" ] ]
24
+ }
25
+ preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
26
+ booking = studio_booking?
27
+ own_crop = studio_booking_crop
28
+ sample_crop = Studio::Booking.crop(top: 200, bottom: 600, frame_height: 720)
29
+ scale = 0.25
30
+ # The made-up page, top to bottom: [label, from, to, is it the box?].
31
+ sample_page = [ [ "Google's header", 0, 200, false ], [ "Select an appointment time", 200, 600, true ],
32
+ [ "Google's credit lines", 600, 720, false ] ]
33
+ %>
34
+ <section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
35
+ <div class="space-y-1">
36
+ <h3 class="text-xl font-bold text-heading">Site footer and booking</h3>
37
+ <p class="text-muted text-sm max-w-2xl">
38
+ One layout line renders the public site's footer from
39
+ <code class="font-mono text-2xs">config.site_footer</code>; every part is optional. With
40
+ <code class="font-mono text-2xs">config.booking_url</code> the same pages get Google Calendar's
41
+ booking page inline, in a popup and on <code class="font-mono text-2xs">/schedule</code>.
42
+ </p>
43
+ <p class="text-muted text-sm" data-site-footer-status>
44
+ This app:
45
+ footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> &middot;
46
+ booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> &middot;
47
+ booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong> &middot;
48
+ frame <strong class="text-body"><%= own_crop ? "cropped at rest" : "whole at rest" %></strong>
49
+ </p>
50
+ </div>
51
+
52
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
53
+ <%= render layout: "style/specimen",
54
+ locals: { klass: "studio_site_footer",
55
+ usage: %(studio_site_footer # one line, at the end of the layout body) } do %>
56
+ <p class="text-sm text-secondary text-center">
57
+ The footer, where it should show: every page for a visitor, listed controllers when signed in.
58
+ Previewed below.
59
+ </p>
60
+ <% end %>
61
+
62
+ <%= render layout: "style/specimen",
63
+ locals: { klass: "studio_booking_link",
64
+ disabled: !booking, disabled_label: "no booking_url",
65
+ usage: %(studio_booking_link "Schedule a call", class: "btn btn-primary") } do %>
66
+ <% if booking %>
67
+ <%= studio_booking_link "Schedule a call", class: "btn btn-primary" %>
68
+ <% else %>
69
+ <span class="btn btn-primary">Schedule a call</span>
70
+ <% end %>
71
+ <% end %>
72
+
73
+ <% [ [ "studio_booking_frame", nil, "whole",
74
+ %(studio_booking_frame # whole at rest: the default, and crop: false),
75
+ "Whole at rest, the default. Google's page as Google draws it: its header, the slot picker, its credit lines." ],
76
+ [ "config.booking_crop", sample_crop, "cropped",
77
+ %(config.booking_crop = { top: 200, bottom: 600, frame_height: 720 } # bin/booking-crop-measure prints yours),
78
+ "Cropped at rest to the slot picker, from numbers measured on the app's own schedule. Opens to the whole page when it is used." ]
79
+ ].each do |klass, crop, state, usage, caption| %>
80
+ <%= render layout: "style/specimen",
81
+ locals: { klass: klass, disabled: !booking, disabled_label: "no booking_url", usage: usage } do %>
82
+ <div class="w-full space-y-3" data-booking-specimen="<%= state %>">
83
+ <div style="margin-inline: auto; width: 11rem; overflow: hidden; background: #fff; border-radius: .5rem; border: 1px solid rgb(127 127 127 / .35); height: <%= ((crop ? crop[:window] : 720) * scale).round %>px;">
84
+ <div style="margin-top: -<%= ((crop ? crop[:offset] : 0) * scale).round %>px;">
85
+ <% sample_page.each do |label, from, to, box| %>
86
+ <div style="box-sizing: border-box; height: <%= ((to - from) * scale).round %>px; display: flex; align-items: center; justify-content: center; padding: 0 .5rem; text-align: center; font-size: .625rem; line-height: 1.2; color: #374151;<%= box ? " margin: 0 .35rem; border: 1px solid rgb(17 24 39 / .45); border-radius: .35rem; font-weight: 700;" : " background: rgb(17 24 39 / .06);" %>"><%= label %></div>
87
+ <% end %>
88
+ </div>
89
+ </div>
90
+ <p class="text-sm text-secondary text-center"><%= caption %></p>
91
+ </div>
92
+ <% end %>
93
+ <% end %>
94
+ </div>
95
+
96
+ <div class="space-y-2">
97
+ <p class="label-upper"><%= own_facts ? "This app's footer" : "Sample footer (this app declares none)" %></p>
98
+ <div class="card overflow-hidden" data-site-footer-preview>
99
+ <%= render "studio/site_footer/footer", facts: preview %>
100
+ </div>
101
+ </div>
102
+ </section>
@@ -497,4 +497,7 @@
497
497
  <% end %>
498
498
  </div>
499
499
  </section>
500
+
501
+ <!-- Site footer and booking -->
502
+ <%= render "style/site_footer" %>
500
503
  </section>