You need to sign in or sign up before continuing.

studio-engine 0.83.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51cd7605f076d91cafbf154a51531843b2e1c621e7a9246070fc947b365714a7
4
- data.tar.gz: 8edb99d5c852b69d73068d9c034d296a9a117416b6a584a0333df141475b4bc8
3
+ metadata.gz: 440b221a14bf9d80f6e58358f49d7b84f6495361e8d8a1a68e00b3980e02f024
4
+ data.tar.gz: 1efda067ddfb1277ceb5cfd6b399195689081c3d02b077489e75ff0c756aaf19
5
5
  SHA512:
6
- metadata.gz: 5b9ef1be9cc7ec70f7afa978140a5fad06d4b828a561ba774ebd4ad83890584eaa3bb6678067280a9e22526291393a11e2fb30615687d995f2005c6876a02bd9
7
- data.tar.gz: 3f7ef9e1b17181cc63de69106270690245e3762826abdca4af9efaa42483b95d89f61e9519465449fbff7082c35b31b7cc10751d0ef01a73706da289fd33eaf0
6
+ metadata.gz: bde626a90189833853f31e49c8767d72fe48f82f99b06a3c7b3c282a790b55dc8479fcf066c12d7fe5324e7f84e6aa1c2f22536fa2af98908cb6c7cf237f30b2
7
+ data.tar.gz: 5e5034bebe474a62d7821e9e8cc3efd23f23a2daad1ac709a335bc88f52c8c816e37c121ae710ddc0643d5fad26a0912297dd47c6451835b7af23f0bd294fa14
data/CHANGELOG.md CHANGED
@@ -4,6 +4,87 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.84.0 — 2026-10-01
8
+
9
+ ### Changed
10
+
11
+ - **BREAKING for a 0.83.0 consumer that shows the booking frame: the frame is no
12
+ longer cropped by default.** 0.83.0 cropped every `studio_booking_frame` to a
13
+ 414px window, 205px down a 732px frame. Those three numbers were measured from
14
+ one Google schedule, and on a schedule with a different header they sliced the
15
+ heading and exposed blank space. A crop that fits one schedule must not be a
16
+ default, so the frame now shows Google's whole page at rest until the app
17
+ declares its own crop. To keep exactly what 0.83.0 drew, add:
18
+
19
+ ```ruby
20
+ config.booking_crop = { top: 211, bottom: 613, frame_height: 732 }
21
+ ```
22
+
23
+ Better, measure the app's own schedule (below): 613 was that schedule's box
24
+ with five slots showing, and its fullest day has eight, so the 0.83.0 window
25
+ cut off the last three. `crop: false` still shows the whole frame; `crop:
26
+ true`, the default, now means "the app's crop". See *Upgrading from 0.83.0* in
27
+ [`docs/BOOKING.md`](docs/BOOKING.md).
28
+
29
+ ### Added
30
+
31
+ - **`config.booking_crop`: the booking frame's crop, per site.** A Hash of
32
+ `top:`, `bottom:` and `frame_height:`, in pixels of Google's own page: the
33
+ "Select an appointment time" box's top, its bottom on its fullest day, and the
34
+ page's height that day. The window is that box plus 6px on each edge. Because
35
+ `bottom` is the tallest the box gets, a day with fewer slots shows a strip of
36
+ Google's credit lines under a shorter box and never a cut one.
37
+ `studio_booking_frame crop:` takes `false`, `true` (the app's crop) or a Hash
38
+ for one frame. The numbers ride on each wrapper as custom properties, so two
39
+ frames with different crops share a page. The engine's `/schedule` honours the
40
+ app's crop; the popup is never cropped. A crop that cannot be one (not numbers,
41
+ negative, bottom not below top or outside the frame, a window as tall as the
42
+ frame) shows the frame whole and is logged once.
43
+ - **`bin/booking-crop-measure <booking url>`** prints that Hash. It opens Google's
44
+ embedded page at two widths, selects every bookable day of the next three
45
+ months, and reports the month-grid rows and the slot rows of the fullest day it
46
+ measured against. It reaches the live Google page, so it refuses to run in CI.
47
+ - **`config.booking_path`: an app's own booking page.** A path, or a callable
48
+ receiving the view. An app that renders `studio_booking_frame` in its own view,
49
+ rather than drawing the engine's `/schedule`, names that page here and gets
50
+ what the engine's page had: a footer link to it opens the popup, it is
51
+ `studio_booking_link`'s fallback href, and a signed-in viewer keeps the footer
52
+ on it. With neither set, nothing changes. It must be a local path (one leading
53
+ `/`): a scheme or a protocol-relative `//host` is refused, from a String at
54
+ assignment and from a callable on each request.
55
+ - [`docs/BOOKING.md`](docs/BOOKING.md): booking as a primitive of its own,
56
+ adoptable without the footer. Setting the schedule up in Google, sharing other
57
+ calendars in for conflict checking, the booking URL, measuring the crop, CSP,
58
+ and the limits. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) keeps the footer.
59
+ - `/admin/style` shows the frame's two states, whole and cropped, as drawings.
60
+
61
+ ### Fixed
62
+
63
+ - **A footer link column no longer breaks an email address or a URL mid-word.**
64
+ The link columns were equal `minmax(0, 1fr)` tracks with `overflow-wrap:
65
+ anywhere`, so an address in a column broke in two at every width. The rows are
66
+ unchanged (two link columns to a row on a phone, one row from 768px, beside the
67
+ brand from 1024px), but a track is now a plain `fr`, at least as wide as its
68
+ longest word, and an address is kept on one line: its column grows to hold it
69
+ and its neighbours give way. The first column is 1.5 shares by default and a
70
+ column may set its own, `[ "Contact", links, { width: 2 } ]`. More than four
71
+ columns wrap four to a row under the brand. An app that overrode 0.83.0's
72
+ tracks from its own stylesheet may leave that block (it draws the same rows) or
73
+ delete it once it is on this version; while it stays, `width:` hints do nothing.
74
+ [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) lists the stable class hooks.
75
+ - **A booking link that jumps to an inline frame asks Google once.** When the
76
+ frame had never been scrolled near, the link assigned its `src` and the
77
+ still-armed observer assigned it again as the frame scrolled into view: two
78
+ requests for one calendar. Whoever comes second now finds it set.
79
+ - **The footer and the booking note set their own line heights.** The Location
80
+ heading inherited the page's (a 45px line box at 1.5; it is 2.25rem, 36px,
81
+ now), and the legal line, the © line and the "Scheduling by Google Calendar"
82
+ note were each a pixel taller per line than their 1.25rem.
83
+ - **A second cropped frame on a page opens when focus moves into it from the
84
+ first.** Focus that goes from one frame straight into another never passes
85
+ through the window, so no second `blur` fired and the second frame stayed
86
+ cropped under the cursor. Only a page with two cropped frames could show it.
87
+
7
88
  ## 0.83.0 — 2026-10-01
8
89
 
9
90
  ### Added
data/README.md CHANGED
@@ -41,7 +41,8 @@ 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 and booking**: `<%= 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. With `config.booking_url`, `studio_booking_frame` embeds Google Calendar's appointment page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule`. 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 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).
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).
45
46
 
46
47
  ## Configuration
47
48
 
@@ -126,7 +127,7 @@ This draws the enabled auth routes (`/login`, `/signup`, `/logout`, `POST /magic
126
127
 
127
128
  Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpoint (`GET /session/state`) — off by default because it inherits the host's filters, which an app should check first ([`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md)).
128
129
 
129
- Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one ([`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md)).
130
+ Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one. An app that keeps its own booking page names it with `Studio.booking_path` instead ([`docs/BOOKING.md`](docs/BOOKING.md)).
130
131
 
131
132
  **Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
132
133
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Studio
4
4
  # The site footer and the booking primitives, as view helpers. See
5
- # docs/SITE_FOOTER.md.
5
+ # docs/SITE_FOOTER.md and docs/BOOKING.md.
6
6
  #
7
7
  # <%= studio_site_footer %> the footer, where it should show
8
8
  # <%= studio_booking_frame %> Google's booking page, inline
@@ -40,8 +40,8 @@ module Studio
40
40
 
41
41
  # Where the footer shows: Studio.site_footer_visible, asked of this view. By
42
42
  # default that is every page for a visitor and, for a signed-in viewer, only
43
- # the controllers in Studio.site_footer_controllers (plus the engine's booking
44
- # page). Always false for an app that declared no footer.
43
+ # the controllers in Studio.site_footer_controllers (plus the booking page).
44
+ # Always false for an app that declared no footer.
45
45
  def studio_show_site_footer?
46
46
  return false if studio_site_footer_facts.nil?
47
47
 
@@ -75,12 +75,11 @@ module Studio
75
75
  def studio_booking_embed_url = Studio::Booking.embed_url(Studio.booking_url)
76
76
 
77
77
  # Where a booking link goes when the popup cannot open (scripts off, a
78
- # modified click, a page with no dialog): the app's booking page when the
79
- # engine draws it, else Google's own page.
78
+ # modified click, a page with no dialog): the app's booking page
79
+ # (Studio.booking_path, or the engine's /schedule when drawn), else Google's
80
+ # own page.
80
81
  def studio_booking_fallback_path
81
- return studio_booking_path if Studio.draw_booking_routes && respond_to?(:studio_booking_path)
82
-
83
- Studio.booking_url
82
+ Studio.booking_path_for(self) || Studio.booking_url
84
83
  end
85
84
 
86
85
  # The frame's accessible name.
@@ -99,11 +98,20 @@ module Studio
99
98
  studio_site_footer_facts&.dig(:name) || Studio.site_identity[:title]
100
99
  end
101
100
 
102
- # The inline booking frame. `crop: false` shows Google's whole page at rest.
101
+ # The crop a frame renders with: { offset:, window:, frame_height: }, or nil
102
+ # for the whole frame at rest. `true` reads Studio.booking_crop; a Hash is a
103
+ # one-off; false or nil is no crop.
104
+ def studio_booking_crop(crop = true)
105
+ Studio::Booking.crop(crop == true ? Studio.booking_crop : crop)
106
+ end
107
+
108
+ # The inline booking frame. `crop:` is true (the default: use
109
+ # Studio.booking_crop, which is no crop until the app declares one), false
110
+ # (the whole page at rest) or a Hash for this frame alone.
103
111
  def studio_booking_frame(title: nil, crop: true)
104
112
  return unless studio_booking?
105
113
 
106
- render "studio/booking/frame", title: studio_booking_title(title), crop: crop
114
+ render "studio/booking/frame", title: studio_booking_title(title), crop: studio_booking_crop(crop)
107
115
  end
108
116
 
109
117
  # The booking dialog, once per page however often it is asked for. The
@@ -10,18 +10,22 @@
10
10
  /* A booking link on this page scrolls here; land below the pinned navbar. */
11
11
  scroll-margin-top: calc(var(--nav-bottom, 4rem) + 1rem); }
12
12
  .booking-frame iframe { display: block; width: 100%; border: 0; height: 1200px; }
13
+ /* From 640px up Google lays the month beside the slots. A cropped wrapper
14
+ carries its own numbers (Studio::Booking.crop_style): how far the frame is
15
+ pulled up, the window's height, and the frame's full height. A wrapper with
16
+ no crop carries none, and shows the frame whole at the default height. */
13
17
  @media (min-width: 640px) {
14
- .booking-frame iframe { height: 732px; }
15
- .booking-frame-cropped { height: 414px; transition: height .25s ease; }
16
- .booking-frame-cropped iframe { margin-top: -205px; transition: margin-top .25s ease; }
17
- .booking-frame-cropped.is-open { height: 732px; }
18
+ .booking-frame iframe { height: var(--booking-frame-height, 732px); }
19
+ .booking-frame-cropped { height: var(--booking-crop-window); transition: height .25s ease; }
20
+ .booking-frame-cropped iframe { margin-top: calc(-1 * var(--booking-crop-offset)); transition: margin-top .25s ease; }
21
+ .booking-frame-cropped.is-open { height: var(--booking-frame-height, 732px); }
18
22
  .booking-frame-cropped.is-open iframe { margin-top: 0; }
19
23
  }
20
24
  /* Scripts off: the frame never gets its src, so it is hidden and a plain link
21
25
  stands in its place (the <noscript> in studio/booking/_frame). */
22
26
  .booking-frame-noscript { margin: 0; padding: 3rem 1.5rem; text-align: center; color: #111; }
23
27
  .booking-frame-noscript a { color: #4338ca; font-weight: 700; text-decoration: underline; }
24
- .booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; color: var(--color-text-muted, inherit); }
28
+ .booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; line-height: 1.25rem; color: var(--color-text-muted, inherit); }
25
29
  .booking-frame-note a { color: var(--color-primary, #8b5cf6); }
26
30
  .booking-frame-note a:hover { text-decoration: underline; }
27
31
 
@@ -69,15 +73,23 @@
69
73
  if (window.__studioBookingFramesArmed) return;
70
74
  window.__studioBookingFramesArmed = true;
71
75
 
76
+ // ASK GOOGLE ONCE. Two things can ask for a frame: scrolling near it, and a
77
+ // booking link that jumps to it. Assigning src a second time, even to the
78
+ // same URL, is a second request, so whoever comes second finds it set.
79
+ function load(frame) {
80
+ if (!frame.getAttribute('src')) frame.src = frame.dataset.src;
81
+ }
82
+ window.__studioBookingLoad = load;
83
+
72
84
  function arm() {
73
85
  document.querySelectorAll('iframe[data-booking-frame][data-studio-booking][data-src]').forEach(function (frame) {
74
86
  if (frame.__bookingArmed) return;
75
87
  frame.__bookingArmed = true;
76
- if (typeof IntersectionObserver !== 'function') { frame.src = frame.dataset.src; return; }
88
+ if (typeof IntersectionObserver !== 'function') { load(frame); return; }
77
89
  var watcher = new IntersectionObserver(function (entries) {
78
90
  if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
79
91
  watcher.disconnect();
80
- frame.src = frame.dataset.src;
92
+ load(frame);
81
93
  }, { rootMargin: '200px' });
82
94
  watcher.observe(frame);
83
95
  });
@@ -90,12 +102,34 @@
90
102
 
91
103
  // Focus moving into the frame is the one signal a cross-origin frame gives
92
104
  // its parent. It means the visitor has started using the calendar.
93
- window.addEventListener('blur', function () {
105
+ function openActive() {
94
106
  var active = document.activeElement;
95
107
  if (!active || !active.matches || !active.matches('iframe[data-booking-frame][data-studio-booking]')) return;
96
108
  var wrap = active.closest('[data-booking-wrap][data-studio-booking]');
97
109
  if (wrap) wrap.classList.add('is-open');
110
+ }
111
+
112
+ // THE WINDOW BLURS ONCE. Focus that moves from one frame straight into
113
+ // another never comes back through this window, so no second blur announces
114
+ // the second frame, and on a page with two cropped frames it would stay
115
+ // cropped under the visitor's cursor. So while focus is away in a frame and a
116
+ // cropped wrapper is still shut, look again a few times a second. It stops
117
+ // when focus returns here or nothing is left to open, and a page with one
118
+ // frame never starts it: that frame is open by then.
119
+ var watching = null;
120
+ function shut() { return document.querySelector('[data-booking-wrap][data-studio-booking][data-booking-crop]:not(.is-open)'); }
121
+ function stopWatching() { if (watching) { clearInterval(watching); watching = null; } }
122
+
123
+ window.addEventListener('blur', function () {
124
+ openActive();
125
+ var active = document.activeElement;
126
+ if (watching || !active || active.tagName !== 'IFRAME' || !shut()) return;
127
+ watching = setInterval(function () {
128
+ openActive();
129
+ if (!shut()) stopWatching();
130
+ }, 250);
98
131
  });
132
+ window.addEventListener('focus', stopWatching);
99
133
 
100
134
  document.addEventListener('turbo:load', armAfterLoad);
101
135
  armAfterLoad();
@@ -127,7 +161,7 @@
127
161
  var wrap = inline.closest('[data-booking-wrap][data-studio-booking]');
128
162
  if (wrap) wrap.classList.add('is-open');
129
163
  // Before `load` the frame keeps waiting: its own script assigns src then.
130
- if (!inline.getAttribute('src') && document.readyState === 'complete') inline.src = inline.dataset.src;
164
+ if (document.readyState === 'complete' && window.__studioBookingLoad) window.__studioBookingLoad(inline);
131
165
  var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
132
166
  (wrap || inline).scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' });
133
167
  inline.focus({ preventScroll: true });
@@ -4,17 +4,20 @@
4
4
  width: month beside the slots from about 600px, stacked below that. It
5
5
  cannot be themed, so it sits on a white panel in both themes.
6
6
 
7
- CROPPED AT REST, WHOLE IN USE. From 640px up the wrapper shows only the
8
- "Select an appointment time" box: Google's header above it and its credit
9
- lines below are clipped (measured in the frame at 640-862px wide: the box
10
- spans 211-613px of a 732px page). The crop cannot stay on, because the form
11
- that opens when a slot is picked is a dialog centred in the frame's FULL
12
- height (93-640px), so a cropped frame cuts off its title and its Book
7
+ WHOLE AT REST unless the app declares a crop (Studio.booking_crop, or
8
+ `crop:` on the call). `crop` here is the resolved crop, a Hash of offset,
9
+ window and frame_height, or nil.
10
+
11
+ CROPPED AT REST, WHOLE IN USE, when it has one. From 640px up the wrapper
12
+ shows only the "Select an appointment time" box: Google's header above it
13
+ and its credit lines below are clipped. Where that box sits belongs to one
14
+ schedule, so the numbers ride on the wrapper as custom properties and two
15
+ frames with different crops can share a page. The crop cannot stay on,
16
+ because the form that opens when a slot is picked is a dialog centred in the
17
+ frame's FULL height, so a cropped frame cuts off its title and its Book
13
18
  button. A cross-origin frame reports no clicks, but the parent window does
14
19
  lose focus to it; on that signal the wrapper opens to the full frame.
15
20
  Below 640px Google stacks the month above the slots and nothing is cropped.
16
- Those numbers are Google's layout, measured 2026-09-30; `crop: false` drops
17
- the crop if that layout moves.
18
21
 
19
22
  THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
20
23
  party, and a frame that starts loading before the window's `load` event
@@ -25,7 +28,7 @@
25
28
  WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
26
29
  empty frame and puts a plain link to Google's booking page in its place. %>
27
30
  <%= studio_booking_assets %>
28
- <div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking>
31
+ <div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
29
32
  <iframe data-booking-frame data-studio-booking
30
33
  data-src="<%= studio_booking_embed_url %>"
31
34
  title="<%= title %>"
@@ -1,7 +1,9 @@
1
1
  <%# /schedule — the booking page (Studio::BookingsController), drawn by
2
2
  Studio.draw_booking_routes. It is where a booking link lands when the popup
3
- cannot open. An app that wants different words around the frame renders
4
- `studio_booking_frame` in a view of its own instead. %>
3
+ cannot open. The frame takes the app's crop (Studio.booking_crop), as it
4
+ does anywhere. An app that wants different words around the frame renders
5
+ `studio_booking_frame` in a view of its own instead, and names that page in
6
+ Studio.booking_path. %>
5
7
  <% label = studio_booking_label %>
6
8
  <% content_for(:title, "#{label} · #{studio_site_footer_name}") %>
7
9
  <% content_for(:full_width, true) %>
@@ -11,20 +11,43 @@
11
11
  border-top: 1px solid var(--ftr-line); }
12
12
  .ftr a { transition: color .15s ease, opacity .15s ease; }
13
13
  .ftr-wrap { max-width: 72rem; margin-inline: auto; padding-inline: 1rem; }
14
- .ftr-link { color: inherit; opacity: .78; text-decoration: none; overflow-wrap: anywhere; }
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; } }
15
21
  .ftr-link-disabled { opacity: .38; cursor: not-allowed; }
16
22
  .ftr-link-plain { opacity: .78; }
17
23
  .ftr-link:hover, .ftr-link:focus-visible { opacity: 1; color: var(--ftr-primary); }
18
24
 
19
- /* Brand, then the link columns. Two tracks on a phone with the brand across
20
- both; from 768px the links sit in one row (at most four across); from
21
- 1024px the brand joins that row. --ftr-n is the number of link columns. */
22
- .ftr-cols { display: grid; gap: 3rem 2rem; padding-block: 4rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
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; }
23
37
  .ftr-brand { grid-column: 1 / -1; }
24
- @media (min-width: 768px) { .ftr-cols { grid-template-columns: repeat(var(--ftr-n-md, 4), minmax(0, 1fr)); } }
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
+ }
25
48
  @media (min-width: 1024px) {
26
- .ftr-cols { grid-template-columns: minmax(0, 1.7fr) repeat(var(--ftr-n, 4), minmax(0, 1fr)); }
27
- .ftr-brand { grid-column: auto; }
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; }
28
51
  }
29
52
  .ftr-home { display: inline-flex; align-items: center; gap: .75rem; color: inherit; text-decoration: none; }
30
53
  .ftr-logo { width: 3rem; height: 3rem; object-fit: contain; }
@@ -47,9 +70,9 @@
47
70
  .ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
48
71
 
49
72
  .ftr-location { border-top: 1px solid var(--ftr-line); padding-block: 3.5rem 2rem; text-align: center; }
50
- .ftr-location-title { margin: 0; font-size: 1.875rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
73
+ .ftr-location-title { margin: 0; font-size: 1.875rem; line-height: 2.25rem; font-weight: 800; letter-spacing: -.025em; color: var(--ftr-ink); }
51
74
  .ftr-address { margin-top: 1rem; font-style: normal; line-height: 1.625; }
52
- .ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; }
75
+ .ftr-legal { padding-block: 2rem; text-align: center; font-size: .875rem; line-height: 1.25rem; }
53
76
  .ftr-legal p { margin: 0; }
54
77
  .ftr-legal .ftr-copyright { margin-top: .5rem; color: var(--color-text-muted, inherit); }
55
78
  /* With no map between them, a rule separates the legal line from what is above. */
@@ -13,7 +13,8 @@
13
13
  <%= studio_site_footer_assets %>
14
14
  <footer class="ftr" data-site-footer>
15
15
  <div class="ftr-wrap">
16
- <div class="ftr-cols" style="--ftr-n: <%= columns.size %>; --ftr-n-md: <%= [ columns.size, 4 ].min.clamp(1, 4) %>;">
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 %>>
17
18
  <div class="ftr-brand">
18
19
  <% if facts[:logo] || facts[:wordmark] %>
19
20
  <a href="<%= facts[:home_path] %>" class="ftr-home" aria-label="<%= facts[:name] %>">
@@ -29,7 +30,7 @@
29
30
  <p class="ftr-tagline"><%= facts[:tagline] %></p>
30
31
  <% end %>
31
32
  <% if facts[:email] %>
32
- <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link"><%= facts[:email] %></a></p>
33
+ <p class="ftr-email"><a href="mailto:<%= facts[:email] %>" class="ftr-link ftr-link-solid"><%= facts[:email] %></a></p>
33
34
  <% end %>
34
35
  <% if facts[:social].any? %>
35
36
  <ul class="ftr-socials" aria-label="Social profiles">
@@ -48,7 +49,7 @@
48
49
  </div>
49
50
 
50
51
  <% columns.each do |column| %>
51
- <nav aria-label="<%= column[:heading] || "Footer" %>">
52
+ <nav class="ftr-col" aria-label="<%= column[:heading] || "Footer" %>">
52
53
  <% if column[:heading] %>
53
54
  <h2 class="ftr-heading"><%= column[:heading] %></h2>
54
55
  <% end %>
@@ -8,7 +8,9 @@
8
8
  <% elsif link[:disabled] %>
9
9
  <span class="ftr-link-disabled" aria-disabled="true" title="Coming soon"><%= link[:label] %></span>
10
10
  <% else %>
11
- <%= link_to link[:label], link[:href], { class: "ftr-link" }
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" }
12
14
  .merge(link[:external] ? { target: "_blank", rel: "noopener" } : {})
13
15
  .merge(link[:booking] && booking ? { data: { booking_popup: true, studio_booking: true } } : {}) %>
14
16
  <% end %>
@@ -1,12 +1,16 @@
1
1
  <%# The Site footer and booking group of the Tricks board (admin/style).
2
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). Each card
3
+ Calendar booking frame, popup and link (docs/SITE_FOOTER.md,
4
+ docs/BOOKING.md). Each card
4
5
  gives the helper to call; the preview below them is the real footer partial,
5
6
  rendered with THIS app's facts when it has declared any and with a sample
6
7
  when it has not, so the group is never empty.
7
8
 
8
- The booking frame is described, not embedded: a style guide should not ask
9
- Google for a calendar every time an admin scrolls past. %>
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. %>
10
14
  <%
11
15
  own_facts = studio_site_footer_facts
12
16
  sample = {
@@ -20,6 +24,12 @@
20
24
  }
21
25
  preview = own_facts || Studio::SiteFooter.resolve(sample, self, logo: Studio.logo_for("Footer Logo"))
22
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 ] ]
23
33
  %>
24
34
  <section id="site-footer" class="space-y-5" style="scroll-margin-top: calc(var(--nav-h, 0px) + 5rem)">
25
35
  <div class="space-y-1">
@@ -34,7 +44,8 @@
34
44
  This app:
35
45
  footer <strong class="text-body"><%= own_facts ? "declared" : "not declared" %></strong> &middot;
36
46
  booking <strong class="text-body"><%= booking ? "on" : "off" %></strong> &middot;
37
- booking page <strong class="text-body"><%= Studio.draw_booking_routes ? "drawn" : "not drawn" %></strong>
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>
38
49
  </p>
39
50
  </div>
40
51
 
@@ -59,14 +70,26 @@
59
70
  <% end %>
60
71
  <% end %>
61
72
 
62
- <%= render layout: "style/specimen",
63
- locals: { klass: "studio_booking_frame",
64
- disabled: !booking, disabled_label: "no booking_url",
65
- usage: %(studio_booking_frame # crop: false shows Google's whole page at rest) } do %>
66
- <p class="text-sm text-secondary text-center">
67
- Google's booking page inline. Asked for only after the page has loaded and the frame is near
68
- the viewport; cropped to the slot picker until it is used.
69
- </p>
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 %>
70
93
  <% end %>
71
94
  </div>
72
95
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Studio.booking_url — the app's Google Calendar appointment schedule — and the
4
- # two URLs the booking primitives derive from it. Pure Ruby. See
5
- # docs/SITE_FOOTER.md.
3
+ # Studio.booking_url — the app's Google Calendar appointment schedule — the two
4
+ # URLs the booking primitives derive from it, and the frame's crop
5
+ # (Studio.booking_crop). Pure Ruby. See docs/BOOKING.md.
6
6
  #
7
7
  # The configured value is the schedule's PUBLIC page, the URL Google gives you
8
8
  # to share, without `?gv=true`:
@@ -15,15 +15,175 @@ module Studio
15
15
  module Booking
16
16
  EMBED_PARAM = "gv=true"
17
17
 
18
+ # THE CROP. At rest the inline frame can show only Google's "Select an
19
+ # appointment time" box, with Google's header above it and its credit lines
20
+ # below clipped away. Where that box sits is a fact about ONE schedule (its
21
+ # header is as tall as its title, description and meeting line; its box is as
22
+ # tall as its fullest day), so the app measures its own and declares it:
23
+ #
24
+ # config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
25
+ #
26
+ # All three are pixels in Google's own page, at 640px wide or more:
27
+ #
28
+ # top: the box's top edge. Stable for a schedule.
29
+ # bottom: the box's bottom edge ON ITS FULLEST DAY. Not stable: the box
30
+ # grows a row per appointment slot, so it is shorter on a day
31
+ # with fewer slots left. Declare the tallest it gets.
32
+ # frame_height: the whole page's height on that fullest day. The frame is
33
+ # this tall once it opens.
34
+ #
35
+ # `bin/booking-crop-measure <booking url>` prints the Hash.
36
+ #
37
+ # The window is [top - CROP_MARGIN, bottom + CROP_MARGIN], held inside the
38
+ # frame. Because `bottom` is the tallest the box gets, a day with fewer slots
39
+ # shows a strip of Google's credit lines under a shorter box; it never cuts
40
+ # the box. A value that cannot be a crop (not a number, negative, bottom not
41
+ # below top, a box that ends outside the frame, a window that hides nothing)
42
+ # is reported once and the frame shows whole.
43
+ CROP_MARGIN = 6
44
+ CROP_KEYS = %i[top bottom frame_height].freeze
45
+ REPORTED_LIMIT = 100
46
+
47
+ class << self
48
+ # Where a refused crop is reported: a callable taking the message. The
49
+ # default writes one warning to the Rails log (or to stderr without Rails).
50
+ attr_writer :reporter
51
+
52
+ def reporter
53
+ @reporter ||= lambda do |message|
54
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
55
+ ::Rails.logger.warn(message)
56
+ else
57
+ warn(message)
58
+ end
59
+ end
60
+ end
61
+
62
+ def reported
63
+ @reported ||= {}
64
+ end
65
+ end
66
+
18
67
  module_function
19
68
 
69
+ def validate_crop!(declared)
70
+ return if declared.nil? || declared == false || declared.is_a?(Hash)
71
+
72
+ raise ArgumentError,
73
+ "Studio.booking_crop must be nil, false, or a Hash of top:, bottom: and frame_height: " \
74
+ "(got #{declared.inspect}). See docs/BOOKING.md."
75
+ end
76
+
77
+ # The crop to render: { offset:, window:, frame_height: } in whole pixels, or
78
+ # nil for the whole frame at rest. `offset` is how far the frame is pulled up
79
+ # behind its wrapper and `window` is the wrapper's height.
80
+ def crop(declared)
81
+ return nil if declared.nil? || declared == false
82
+ return refuse_crop(declared, "it is not a Hash") unless declared.is_a?(Hash)
83
+
84
+ values = CROP_KEYS.to_h { |key| [key, declared[key] || declared[key.to_s]] }
85
+ missing = values.reject { |_key, value| value.is_a?(Numeric) && value.respond_to?(:round) && value.finite? }.keys
86
+ return refuse_crop(declared, "#{missing.join(', ')} must be a number") unless missing.empty?
87
+
88
+ top, bottom, frame_height = values.values_at(*CROP_KEYS).map(&:round)
89
+ return refuse_crop(declared, "top is negative") if top.negative?
90
+ return refuse_crop(declared, "bottom is not below top") unless bottom > top
91
+ return refuse_crop(declared, "bottom is outside the frame") if bottom > frame_height
92
+
93
+ offset = [top - CROP_MARGIN, 0].max
94
+ window = [bottom + CROP_MARGIN, frame_height].min - offset
95
+ return refuse_crop(declared, "the window is the whole frame") unless window < frame_height
96
+
97
+ { offset: offset, window: window, frame_height: frame_height }
98
+ end
99
+
100
+ # The crop as the custom properties the wrapper carries, so two frames with
101
+ # different crops can share a page (studio/booking/_assets reads them).
102
+ def crop_style(crop)
103
+ return nil if crop.nil?
104
+
105
+ "--booking-crop-offset: #{crop[:offset]}px; --booking-crop-window: #{crop[:window]}px; " \
106
+ "--booking-frame-height: #{crop[:frame_height]}px"
107
+ end
108
+
109
+ def refuse_crop(declared, why)
110
+ report_once("[studio.booking] booking crop #{declared.inspect} is ignored (#{why}); the frame shows whole. " \
111
+ "See docs/BOOKING.md.")
112
+ nil
113
+ end
114
+
115
+ # A LOCAL PATH, and nothing else, may be the booking page: it is written into
116
+ # hrefs (the footer's booking links, `studio_booking_link`). It begins with
117
+ # one "/" and carries no control character. That refuses a scheme
118
+ # ("javascript:", "https:") and a protocol-relative "//host", which a browser
119
+ # reads as another site.
120
+ def local_path?(path)
121
+ string = path.to_s
122
+ string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
123
+ end
124
+
125
+ # Studio.booking_path: nil, a local path, or a callable receiving the view.
126
+ def validate_path!(declared)
127
+ return if declared.nil? || declared.respond_to?(:call)
128
+ return if declared.is_a?(String) && (declared.strip.empty? || local_path?(declared.strip))
129
+
130
+ raise ArgumentError,
131
+ "Studio.booking_path must be nil, a path beginning with one \"/\", or a callable receiving the view " \
132
+ "(got #{declared.inspect}). See docs/BOOKING.md."
133
+ end
134
+
135
+ # The declared value as it is stored: a stripped path, a callable, or nil.
136
+ def normalize_path(declared)
137
+ validate_path!(declared)
138
+ return declared unless declared.is_a?(String)
139
+
140
+ declared.strip.empty? ? nil : declared.strip
141
+ end
142
+
143
+ # The booking page's path for a view: the app's own (`declared`, a path or
144
+ # a callable receiving the view), else the engine's /schedule when it is
145
+ # `drawn`, else nil. A callable may decline with nil. What a callable returns
146
+ # is held to the same rule as a declared String: anything but a local path is
147
+ # reported once and treated as no answer.
148
+ def path_for(declared, view, drawn: false)
149
+ unless declared.nil?
150
+ path = (declared.respond_to?(:call) ? declared.call(view) : declared).to_s.strip
151
+ return path if local_path?(path)
152
+
153
+ refuse_path(path) unless path.empty?
154
+ end
155
+
156
+ drawn && view.respond_to?(:studio_booking_path) ? view.studio_booking_path : nil
157
+ end
158
+
159
+ def refuse_path(path)
160
+ report_once("[studio.booking] booking_path #{path.inspect} is ignored: it is not a local path " \
161
+ "(one leading \"/\"). See docs/BOOKING.md.")
162
+ nil
163
+ end
164
+
165
+ def report_once(message)
166
+ seen = Studio::Booking.reported
167
+ return if seen.key?(message)
168
+
169
+ seen[message] = true if seen.size < REPORTED_LIMIT
170
+ Studio::Booking.reporter.call(message)
171
+ end
172
+
173
+ # True when `current` (a request path) is the page `declared` names. A query
174
+ # string, a fragment and a trailing slash on either side do not count.
175
+ def same_path?(current, declared)
176
+ a, b = [current, declared].map { |path| path.to_s.strip.sub(/[?#].*\z/m, "").sub(%r{(?<=.)/+\z}, "") }
177
+ !a.empty? && a == b
178
+ end
179
+
20
180
  def validate!(url)
21
181
  return if url.nil? || url.to_s.strip.empty?
22
182
  return if url.to_s.strip.match?(%r{\Ahttps://\S+\z})
23
183
 
24
184
  raise ArgumentError,
25
185
  "Studio.booking_url must be an https URL (got #{url.inspect}). Use the appointment " \
26
- "schedule's public link. See docs/SITE_FOOTER.md."
186
+ "schedule's public link. See docs/BOOKING.md."
27
187
  end
28
188
 
29
189
  # The public page, with any embed parameter removed. nil when unset.
@@ -94,6 +94,16 @@ module Studio
94
94
  nil
95
95
  end
96
96
 
97
+ # True on the app's OWN booking page (Studio.booking_path), which keeps the
98
+ # footer for a signed-in viewer as the engine's page does. Matched by the
99
+ # request's path, because the app's controller could be named anything.
100
+ def booking_page?(view, booking_path)
101
+ return false if booking_path.nil?
102
+ return false unless view.respond_to?(:request) && view.request.respond_to?(:path)
103
+
104
+ Studio::Booking.same_path?(view.request.path, booking_path)
105
+ end
106
+
97
107
  def validate!(declared)
98
108
  return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
99
109
 
@@ -106,7 +116,7 @@ module Studio
106
116
  #
107
117
  # name: the site name the engine resolved (the site identity's title)
108
118
  # logo: the logo the engine resolved (the navbar logo), or nil
109
- # booking_path: the booking page's path when the engine draws it, or nil
119
+ # booking_path: the booking page's path (Studio.booking_path_for), or nil
110
120
  def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
111
121
  raw = declared.respond_to?(:call) ? declared.call(view) : declared
112
122
  return nil if raw.nil?
@@ -149,8 +159,16 @@ module Studio
149
159
  # config.site_footer_visible = ->(view) {
150
160
  # Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("app/")
151
161
  # }
152
- def default_visible?(view, controllers: nil)
162
+ #
163
+ # `booking_path` is the app's own booking page (Studio.booking_path, resolved
164
+ # for this view); the engine's page is exempt by its controller instead.
165
+ def default_visible?(view, controllers: nil, booking_path: nil)
153
166
  controllers = Studio.site_footer_controllers if controllers.nil? && defined?(Studio.site_footer_controllers)
167
+ if booking_path.nil? && Studio.respond_to?(:booking_path) && Studio.booking_path
168
+ booking_path = Studio.booking_path_for(view)
169
+ end
170
+ return true if booking_page?(view, booking_path)
171
+
154
172
  visible?(
155
173
  logged_in: view.respond_to?(:logged_in?) && view.logged_in? ? true : false,
156
174
  controller_name: view.respond_to?(:controller_name) ? view.controller_name : nil,
@@ -235,12 +253,14 @@ module Studio
235
253
  unlinked: !text(url).nil? && safe_href(url, "social url").nil? }
236
254
  end
237
255
 
238
- # [ heading, links ] or { heading:, links: }.
256
+ # [ heading, links ], [ heading, links, { width: 1.5 } ] or { heading:,
257
+ # links:, width: }. `width` is an optional hint: this column's share of the
258
+ # row against the others' 1 (see `tracks`).
239
259
  def column(row, booking_path)
240
- heading, links =
260
+ heading, links, options =
241
261
  if row.respond_to?(:to_h) && !row.is_a?(Array)
242
262
  hash = symbolize(row)
243
- [hash[:heading] || hash[:title], hash[:links]]
263
+ [hash[:heading] || hash[:title], hash[:links], hash]
244
264
  else
245
265
  Array(row)
246
266
  end
@@ -248,12 +268,48 @@ module Studio
248
268
  links = Array(links).filter_map { |link_row| link(link_row, booking_path) }
249
269
  return nil if heading.nil? && links.empty?
250
270
 
251
- { heading: heading, links: links }
271
+ options = options.respond_to?(:to_h) && !options.is_a?(Array) ? symbolize(options) : {}
272
+ { heading: heading, links: links, width: column_width(options[:width]) }
273
+ end
274
+
275
+ # A column's width hint, or nil for the default share. Anything that is not a
276
+ # number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
277
+ MIN_COLUMN_WIDTH = 0.5
278
+ MAX_COLUMN_WIDTH = 4
279
+
280
+ def column_width(value)
281
+ return nil unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
282
+ return nil unless value >= MIN_COLUMN_WIDTH && value <= MAX_COLUMN_WIDTH
283
+
284
+ value.to_f
285
+ end
286
+
287
+ # True for a label that is an address rather than words: no space in it, and
288
+ # an "@", a "." or a "/". It is kept on one line (.ftr-link-solid).
289
+ def solid_label?(label)
290
+ string = label.to_s
291
+ !string.match?(/\s/) && string.match?(%r{[@./]})
292
+ end
293
+
294
+ # THE LINK COLUMNS' GRID TRACKS from 768px, one per column: its `width:`
295
+ # hint as an fr share, else FIRST_COLUMN_WIDTH for the first (the one that
296
+ # usually holds an email address) and 1 for the rest. nil when there are no
297
+ # columns, or more than MAX_ROW_COLUMNS: those wrap as equal tracks.
298
+ FIRST_COLUMN_WIDTH = 1.5
299
+ MAX_ROW_COLUMNS = 4
300
+
301
+ def tracks(columns)
302
+ columns = Array(columns)
303
+ return nil if columns.empty? || columns.size > MAX_ROW_COLUMNS
304
+
305
+ columns.each_with_index.map do |column, index|
306
+ "#{format('%g', column[:width] || (index.zero? ? FIRST_COLUMN_WIDTH : 1))}fr"
307
+ end.join(" ")
252
308
  end
253
309
 
254
310
  # [ label, href ], [ label, href, { booking: true } ] or { label:, href:,
255
311
  # booking: }. A nil href is a disabled label; an http(s) href opens in a new
256
- # tab; `booking: true`, or an href equal to the engine's booking page, opens
312
+ # tab; `booking: true`, or an href equal to the booking page's path, opens
257
313
  # the booking popup (the href stays as the fallback).
258
314
  def link(row, booking_path = nil)
259
315
  label, href, options =
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.83.0"
2
+ VERSION = "0.84.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -565,7 +565,7 @@ module Studio
565
565
  # The public site's footer: brand and social profiles, link columns, an
566
566
  # optional Location band with a live map, and a legal line. And the booking
567
567
  # primitives: Google Calendar's appointment page inline, in a popup, and on a
568
- # page. See docs/SITE_FOOTER.md.
568
+ # page. See docs/SITE_FOOTER.md and docs/BOOKING.md.
569
569
 
570
570
  # The footer's facts: a Hash, or a callable receiving the view. nil (the
571
571
  # default) means this app has no footer, and `studio_site_footer` renders
@@ -590,7 +590,8 @@ module Studio
590
590
  # controller name ("landing") or path ("admin/reports"). A visitor gets the
591
591
  # footer on every page; a signed-in viewer gets it only here, because every
592
592
  # other signed-in page is a working surface (a board, a queue, an editor) and
593
- # stays full height. The engine's own booking page is always included.
593
+ # stays full height. The booking page (the engine's, or the app's own at
594
+ # Studio.booking_path) is always included.
594
595
  #
595
596
  # config.site_footer_controllers = %w[landing packages contact_submissions]
596
597
  mattr_accessor :site_footer_controllers, default: []
@@ -634,6 +635,44 @@ module Studio
634
635
  # config.draw_booking_routes = true
635
636
  mattr_accessor :draw_booking_routes, default: false
636
637
 
638
+ # WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
639
+ # `studio_booking_frame` in a view of its own instead of drawing the engine's
640
+ # /schedule: a path ("/schedule"), or a callable receiving the view
641
+ # (`->(view) { view.schedule_path }`). nil (the default) leaves the engine's
642
+ # page, when drawn, as the booking page. With it set, the page is treated as
643
+ # the engine's own is: a footer link to it opens the popup, `studio_booking_link`
644
+ # falls back to it, and it keeps the footer for a signed-in viewer. It wins
645
+ # over the engine's page when both are declared. It is written into hrefs, so
646
+ # it must be a local path (one leading "/"); see Studio::Booking.local_path?.
647
+ #
648
+ # config.booking_path = "/schedule"
649
+ mattr_reader :booking_path, default: nil
650
+
651
+ def self.booking_path=(declared)
652
+ @@booking_path = Booking.normalize_path(declared)
653
+ end
654
+
655
+ # The booking page's path for a view: Studio.booking_path, else the engine's
656
+ # /schedule when it is drawn, else nil.
657
+ def self.booking_path_for(view)
658
+ Booking.path_for(booking_path, view, drawn: draw_booking_routes)
659
+ end
660
+
661
+ # THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
662
+ # page; a Hash of the schedule's measured box crops the frame to the slot
663
+ # picker until it is used. The numbers belong to ONE schedule, so there is no
664
+ # default crop: measure with `bin/booking-crop-measure <booking url>` and paste
665
+ # what it prints. Keys and derivation: lib/studio/booking.rb.
666
+ #
667
+ # config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
668
+ mattr_reader :booking_crop, default: nil
669
+
670
+ def self.booking_crop=(declared)
671
+ Booking.validate_crop!(declared)
672
+ Booking.crop(declared) # reports a crop that cannot be one, at boot
673
+ @@booking_crop = declared || nil
674
+ end
675
+
637
676
  # THE APP'S IDENTITY COPY, resolved: { title:, description:, image_url: }.
638
677
  # The operator's saved value (Studio::SiteIdentity, edited at
639
678
  # /admin/link_preview) wins, then the drafted Studio.site_title /
@@ -1140,9 +1179,8 @@ module Studio
1140
1179
  def self.site_footer_for(view)
1141
1180
  return nil if site_footer.nil?
1142
1181
 
1143
- booking_path = draw_booking_routes && view.respond_to?(:studio_booking_path) ? view.studio_booking_path : nil
1144
- SiteFooter.resolve(site_footer, view,
1145
- name: site_identity[:title], logo: logo_for("Footer Logo"), booking_path: booking_path)
1182
+ SiteFooter.resolve(site_footer, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
1183
+ booking_path: booking_path_for(view))
1146
1184
  end
1147
1185
 
1148
1186
  # Navbar links resolved for a view context (lib/studio/navbar_links.rb).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.83.0
4
+ version: 0.84.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie