studio-engine 0.85.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/docs/BOOKING.md ADDED
@@ -0,0 +1,377 @@
1
+ # Booking
2
+
3
+ An app on studio-engine can take bookings through a Google Calendar appointment
4
+ schedule: inline in a page, in a popup, and on a `/schedule` page. The gem ships
5
+ the frame, the popup, the page and their scripts. Google does the scheduling.
6
+
7
+ It stands alone. An app does not need the [site footer](SITE_FOOTER.md) to use
8
+ it, and not every app needs it at all: it is a simple scheduler for an app that
9
+ wants one. Everything about *when* someone can book is configured in Google, not
10
+ here: availability, conflict checking across several calendars, buffers between
11
+ appointments, the booking form's questions, and the Meet link on the invitation.
12
+
13
+ ## Adopt it
14
+
15
+ ```ruby
16
+ # config/initializers/studio.rb
17
+ Studio.configure do |config|
18
+ config.booking_url = "https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID"
19
+ config.draw_booking_routes = true # GET /schedule, the engine's booking page
20
+ end
21
+ ```
22
+
23
+ That is a working booking page at `/schedule`, in the app's own layout. To put
24
+ the calendar in a page of your own instead, or as well:
25
+
26
+ ```erb
27
+ <%= studio_booking_frame %>
28
+ <%= studio_booking_link "Book a call", class: "btn btn-primary" %>
29
+ ```
30
+
31
+ Nothing is added to the app's asset manifest or its Tailwind build. An app that
32
+ enforces a Content Security Policy must allow the frame: see
33
+ [Content Security Policy](#content-security-policy).
34
+
35
+ ## Set up the schedule in Google
36
+
37
+ These steps are Google Calendar's, written from memory and not checked against
38
+ Google's menus: the names and their order may differ from what you see. The
39
+ things to end up with are what matter.
40
+
41
+ 1. **Create the appointment schedule.** In Google Calendar, signed in as the
42
+ account that will own the bookings: *Create*, then *Appointment schedule*.
43
+ Give it a title, an appointment length and its general availability. Under
44
+ the booked-appointment settings, set the buffer between appointments and the
45
+ most bookings in a day. Under the booking page's settings, choose Google Meet
46
+ as the place if each invitation should carry a Meet link, and choose what
47
+ the form asks for.
48
+ 2. **Share the other calendars in, for conflict checking.** A schedule can only
49
+ avoid events it can see. For each other calendar that should block a slot (a
50
+ personal account, a second Workspace), open that calendar's settings in the
51
+ account that owns it, share it with the schedule's owner, and let the owner
52
+ see at least whether the time is free or busy. Then, in the schedule's
53
+ availability settings, tick those calendars under the ones checked for
54
+ conflicts. Google limits checking more than one calendar to some paid
55
+ plans; check what the owning account has.
56
+ 3. **Copy the booking URL.** Open the schedule's booking page and take its long
57
+ address, the one of this shape:
58
+
59
+ ```text
60
+ https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID
61
+ ```
62
+
63
+ Google's *Share* dialog may also offer a short link; use the long address,
64
+ which is the one that embeds (the dialog's website-embed code carries it as the
65
+ frame's `src`). Paste it without `?gv=true`: the engine adds that for the
66
+ embedded page and leaves it off the link to Google's own page. A link pasted
67
+ with it is accepted and stored without it.
68
+
69
+ The id in that address is not a secret (it is on every page that embeds it), but
70
+ it is the app's own: keep real ids out of examples and tests.
71
+
72
+ ## Configuration
73
+
74
+ | Setting | What it is | Default |
75
+ |---------|------------|---------|
76
+ | `config.booking_url` | The schedule's public `https` address | `nil`: no booking. Every helper below renders nothing and no link opens a popup |
77
+ | `config.booking_crop` | The frame's crop at rest: `{ top:, bottom:, frame_height: }` | `nil`: the whole frame. See [The crop](#the-crop) |
78
+ | `config.draw_booking_routes` | Draw the engine's booking page, `GET /schedule` | `false` |
79
+ | `config.booking_path` | Where the app's OWN booking page lives: a path, or a callable receiving the view | `nil`. See [A page of your own](#a-page-of-your-own) |
80
+
81
+ ## Helpers
82
+
83
+ | Helper | What it renders |
84
+ |--------|-----------------|
85
+ | `studio_booking_frame` | Google's booking page inline, plus a line linking to the page itself. `title:` names the frame; `crop:` is `true` (the default: use `config.booking_crop`), `false`, or a Hash for this frame alone |
86
+ | `studio_booking_link "Book a call", class: "btn btn-primary"` | A link that opens the popup in place. Its href is the booking page, so it works with scripts off |
87
+ | `studio_booking_popup` | The dialog. The site footer renders it; call it yourself on a page with booking links and no footer |
88
+
89
+ ## The frame
90
+
91
+ - **Deferred.** The URL waits in `data-src`. A script assigns `src` only after the
92
+ window's `load` event, and only once the frame is within 200px of the viewport.
93
+ `loading="lazy"` is not enough: a third-party frame that starts before `load`
94
+ holds that event open for as long as Google takes to answer.
95
+ - **Whole at rest, unless the app declares a crop.** Without one the frame shows
96
+ Google's whole page, 732px tall from 640px up and 1200px below that, where
97
+ Google stacks the month above the slots.
98
+ - **White in both themes.** Google's page cannot be themed.
99
+ - **With scripts off** the frame is replaced by a plain link to the booking page.
100
+
101
+ ## The crop
102
+
103
+ Google's page has a header above the calendar (the owner, the title, the length,
104
+ the meeting line) and credit lines below it. A crop hides both at rest, so the
105
+ page shows only the "Select an appointment time" box, and opens the frame to its
106
+ full height when the visitor starts using it.
107
+
108
+ ```ruby
109
+ config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
110
+ ```
111
+
112
+ The three numbers are pixels in Google's own page, at 640px wide or more:
113
+
114
+ | Key | What it is | Does it move? |
115
+ |-----|------------|---------------|
116
+ | `top` | The box's top edge | Only when the schedule's header changes: its title, description or meeting line |
117
+ | `bottom` | The box's bottom edge **on its fullest day** | Yes. See below |
118
+ | `frame_height` | The whole page's height on that fullest day | With `bottom` |
119
+
120
+ **There is no default crop, because the numbers belong to one schedule.** Two
121
+ schedules with different headers have different tops, and a crop measured on one
122
+ slices the other's heading. An app that has not measured gets the whole frame.
123
+
124
+ ### How the window is derived
125
+
126
+ The wrapper shows `top - 6` to `bottom + 6`, held inside the frame, and the frame
127
+ behind it is `frame_height` tall. Six pixels is the margin 0.83.0 used.
128
+
129
+ The box is as tall as the month grid or the longest column of appointment slots,
130
+ whichever is taller, at 48px a slot. The month grid was six rows in every month
131
+ measured (two schedules, seven months each, on 2026-10-01), so a short month did
132
+ not move the box. The slot column is not fixed: a day that is partly booked,
133
+ or partly over, has fewer slots than a free one. So **the box's bottom on any
134
+ one day is not the schedule's
135
+ bottom**, and a crop measured from one look at the page cuts off the last slots
136
+ of a fuller day.
137
+
138
+ That is why `bottom` is the bottom on the fullest day, and why it cannot slice
139
+ content: on every other day the box is shorter than the window, and what shows
140
+ under it is a strip of Google's credit lines. The crop errs toward showing too
141
+ much, never too little. `frame_height` is the page's height on that same day, so
142
+ the opened frame holds the whole page without a scrollbar of its own.
143
+
144
+ What the crop cannot follow is a change to the schedule itself. Measure again
145
+ when the header changes, or when a day can hold more slots than the fullest day
146
+ measured (longer hours, shorter appointments).
147
+
148
+ ### Measure it
149
+
150
+ ```bash
151
+ npm ci && npx playwright install chromium # once
152
+ bin/booking-crop-measure https://calendar.google.com/calendar/appointments/schedules/EXAMPLE-SCHEDULE-ID
153
+ ```
154
+
155
+ Run it from a checkout of this repo. It opens Google's embedded page in headless
156
+ Chromium at two frame widths (640px and 862px), selects every bookable day of
157
+ the next three months (`--months N` for more), and prints the Hash, with what it
158
+ measured against:
159
+
160
+ ```text
161
+ 640px wide: box 200-600px, page 720px tall.
162
+ month grid: 6 rows. Fullest day: 8 slot rows (Tuesday, January 8, 2030).
163
+ walked 28 bookable days over 3 month(s). As it opened: box bottom 456px, 5 slot rows.
164
+ ...
165
+ config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
166
+ ```
167
+
168
+ It books nothing: it only selects days. It reads Google's page by role and
169
+ attribute (`main`, `table[role=grid]`, `td[data-date]`, `[role=list]`), and stops
170
+ with an error if the page is no longer shaped that way, rather than print numbers
171
+ from the wrong element. `--json` prints the measurements for a script.
172
+
173
+ It reaches the live Google page, so it never runs in CI: the command refuses
174
+ under a `CI` environment, and the browser lane proves the measuring against a
175
+ stub (`e2e/booking_crop_measure.spec.js`).
176
+
177
+ ### One frame's own crop
178
+
179
+ ```erb
180
+ <%= studio_booking_frame crop: false %> <%# whole, whatever the app declares %>
181
+ <%= studio_booking_frame crop: { top: 150, bottom: 480, frame_height: 640 } %> <%# this frame alone %>
182
+ ```
183
+
184
+ Each cropped wrapper carries its numbers as custom properties
185
+ (`--booking-crop-offset`, `--booking-crop-window`, `--booking-frame-height`) in
186
+ its own `style` attribute, so two frames with different crops can share a page.
187
+ Each opens when focus moves into it.
188
+
189
+ ### A crop that cannot be one
190
+
191
+ A Hash whose values are not numbers (a numeric string such as `"200"` is not
192
+ coerced: it is refused like any other non-number), a negative `top`, a `bottom` that is not
193
+ below `top` or is past `frame_height`, or a window as tall as the frame, is not
194
+ applied. The frame shows whole, and one line is logged:
195
+
196
+ ```text
197
+ [studio.booking] booking crop {...} is ignored (bottom is not below top); the frame shows whole.
198
+ ```
199
+
200
+ `config.booking_crop` logs it when it is assigned, at boot. Anything that is not
201
+ a Hash, `nil` or `false` raises there.
202
+
203
+ ### Its limits
204
+
205
+ - **The crop is off below 640px**, where Google stacks the month above the slots.
206
+ - **The breakpoint is the viewport's, not the frame's.** Google lays the month
207
+ beside the slots only when the frame itself is about 600px wide. A frame in a
208
+ narrow column on a wide screen is stacked, and a crop measured side by side
209
+ does not fit it: pass `crop: false` there.
210
+ - **The crop cannot stay on.** The form Google shows after a slot is picked is a
211
+ dialog centred in the frame's full height, so the frame opens fully on use.
212
+
213
+ ## The popup
214
+
215
+ A booking link opens Google's page in a `<dialog>` without leaving the page. The
216
+ frame inside is requested the first time the dialog opens, and kept while that
217
+ page stays up: closing and reopening does not ask Google again. The popup is
218
+ never cropped.
219
+
220
+ A Turbo restoration visit (back or forward) is a new page built from a snapshot,
221
+ and it does ask again. The inline frame is restored with its `src`, so the
222
+ browser re-requests Google's page for it. The popup's frame is not: its `src` is
223
+ put back to waiting before the snapshot is taken, so a restored page asks Google
224
+ for the popup only when someone opens it.
225
+
226
+ - **On a page that already shows the inline frame**, a booking link scrolls to
227
+ that frame, opens its crop and moves focus into it. It does not open a second
228
+ calendar on top of the first.
229
+ - **The link's href is the fallback**: with scripts off, on a modified click (new
230
+ tab), or on a page with no dialog, it is an ordinary link. It goes to the
231
+ app's booking page when there is one, else to Google's own page.
232
+ - **The page behind does not scroll** while the dialog is open.
233
+ - **It is sized to the visible viewport** (`dvh`, with `vh` as the fallback), so
234
+ a phone's toolbars do not push the Close button off screen.
235
+
236
+ It is a native `<dialog>`, not the engine's modal host (`studio/modals/_host`),
237
+ on purpose. The host needs Alpine, its store, and each modal registered inside
238
+ the host's block in the layout, so an app without a host would have no popup.
239
+ The host's card is padded and scrolls, where this body is a full-bleed frame. And
240
+ the host mounts content with `<template x-if>`, which would build a new frame and
241
+ ask Google again on every open.
242
+
243
+ ## The page
244
+
245
+ `config.draw_booking_routes = true` draws `GET /schedule`
246
+ (`Studio::BookingsController`, helper `studio_booking_path`): a heading, the
247
+ frame with the app's crop, and the footer if the app has one, in the app's own
248
+ layout. It is public, and answers `404` when no `booking_url` is set.
249
+
250
+ The route is a flag, off by default, like every route a consumer might already
251
+ own.
252
+
253
+ ### A page of your own
254
+
255
+ An app that wants different words around the calendar renders
256
+ `studio_booking_frame` in a view of its own. The frame is the same one, crop
257
+ included, and needs nothing else. Tell the engine where that page is:
258
+
259
+ ```ruby
260
+ config.booking_path = "/schedule"
261
+ # or, with a route helper:
262
+ config.booking_path = ->(view) { view.schedule_path }
263
+ ```
264
+
265
+ With it set the app's page is treated as the engine's own is:
266
+
267
+ - a site-footer link whose href is that path opens the popup without
268
+ `{ booking: true }`;
269
+ - `studio_booking_link` falls back to it, instead of to Google's page;
270
+ - a signed-in viewer keeps the footer on it, without the controller being listed
271
+ in `config.site_footer_controllers`. The page is matched by the request's path.
272
+
273
+ The path is written into hrefs, so it must be a local path: one leading `/`.
274
+ A String that is anything else (`javascript:...`, `https://...`, a
275
+ protocol-relative `//host/...`) raises when it is assigned. A callable's result
276
+ is held to the same rule on every request: anything else is logged once
277
+ (`[studio.booking] booking_path ... is ignored`) and treated as no answer.
278
+
279
+ Without `booking_path` (and without the engine's route) an app's own page gets
280
+ none of the three: the engine has no way to know it is the booking page. If both
281
+ are declared, `booking_path` is the booking page. A callable that returns `nil`
282
+ falls back to the engine's page.
283
+
284
+ ## With the site footer
285
+
286
+ The footer renders the popup, and prints a booking link in any column that names
287
+ one (see [`SITE_FOOTER.md`](SITE_FOOTER.md)). Its facts may carry the copy:
288
+ `booking: { label:, title: }` sets what the links, the popup and the page call
289
+ the act ("Schedule a call") and the frame's accessible name. Without a footer the
290
+ label is "Schedule a call" and the name is "Schedule a call with <site title>".
291
+
292
+ ## Content Security Policy
293
+
294
+ An app that enforces a policy must allow the frame:
295
+
296
+ | Directive | Source | For |
297
+ |-----------|--------|-----|
298
+ | `frame-src` | `https://calendar.google.com` | The booking frame and the popup |
299
+
300
+ The primitives' own `<style>` and `<script>` blocks are inline and carry no
301
+ nonce, like the rest of the engine's partials, and a cropped wrapper carries its
302
+ numbers in an inline `style` attribute. A policy that forbids inline script or
303
+ style needs `'unsafe-inline'` for them (or hashes), exactly as it does for the
304
+ engine's head partial today.
305
+
306
+ ## Limits
307
+
308
+ - **Google's page cannot be themed.** It is another origin's document in a
309
+ frame: its colours, type and wording are Google's. The frame sits on a white
310
+ panel in both themes.
311
+ - **Escape does not close the popup once focus is inside the frame.** Key
312
+ presses there belong to Google, and nothing in the page can change that. The
313
+ Close button and a click on the backdrop always work, which is why Close is a
314
+ solid, full-size button.
315
+ - **The page cannot see into the frame.** It is told nothing about a click or a
316
+ booking. The one signal it gets is that focus moved into the frame, which is
317
+ what opens a crop.
318
+ - **A crop is a measurement of Google's layout.** If Google redesigns the page,
319
+ measure again, or set `config.booking_crop = nil`.
320
+
321
+ ## Upgrading from 0.83.0
322
+
323
+ **0.83.0 cropped every frame, with one schedule's numbers. The next release
324
+ crops none until the app declares its own.** After upgrading, a frame that was
325
+ cropped shows Google's whole page.
326
+
327
+ To keep exactly the crop 0.83.0 drew (a 414px window, 205px down a 732px frame):
328
+
329
+ ```ruby
330
+ config.booking_crop = { top: 211, bottom: 613, frame_height: 732 }
331
+ ```
332
+
333
+ Prefer to measure. Those numbers were read from one schedule with five slots
334
+ showing (613 is that box's bottom). Measured again on 2026-10-01, the same
335
+ schedule's fullest day has eight, so the 0.83.0 window cuts off its last three;
336
+ and on a schedule with a different header it slices the heading.
337
+ `bin/booking-crop-measure` gives the numbers for yours.
338
+
339
+ `studio_booking_frame crop: false` still means the whole frame. `crop: true`,
340
+ the default, now means "the app's crop", which is none until declared.
341
+
342
+ An app that renders its own booking page should also set `config.booking_path`
343
+ (above); 0.83.0 had no way to name it.
344
+
345
+ ## Coexisting with an app's own copy
346
+
347
+ An app that still renders local booking partials uses the same `data-booking-*`
348
+ attributes. The engine's scripts keep out of its way: their guards are engine
349
+ names (`__studioBookingFramesArmed`, `__studioBookingPopupArmed`), and they act
350
+ only on engine-rendered elements, which carry `data-studio-booking` (the frame,
351
+ its wrapper, the dialog and the links). Write a booking link with
352
+ `studio_booking_link`, not by hand, so it carries the marker.
353
+
354
+ ## Markup hooks
355
+
356
+ Stable, for tests and for an app's own scripts: `[data-booking-wrap]` (also
357
+ `[data-booking-crop]` and `.booking-frame-cropped` when cropped, `.is-open` once
358
+ used), `iframe[data-booking-frame]`, `dialog[data-booking-dialog]`,
359
+ `iframe[data-booking-popup-frame]`, `a[data-booking-popup]`,
360
+ `[data-booking-close]`, `[data-booking-page]`. They also carry
361
+ `data-studio-booking`.
362
+
363
+ ## Tests
364
+
365
+ - `test/lib/studio/booking_test.rb`: the crop's derivation and what it refuses,
366
+ and the booking page's path. `test/lib/studio/site_footer_test.rb` holds the
367
+ booking URLs.
368
+ - `test/integration/site_footer_test.rb`: what a host renders: whole by default,
369
+ the declared crop, one frame's own, the engine's page, and an app's own page
370
+ named by `booking_path`.
371
+ - `e2e/booking_frame.spec.js`: the scripts and the layout in a browser, with
372
+ Google stubbed: deferred loading, whole by default, cropped to declared numbers
373
+ and open on use, two crops on one page, the popup.
374
+ - `e2e/booking_crop_measure.spec.js`: the measuring tool's walk and arithmetic,
375
+ against a stub.
376
+
377
+ `/admin/style` shows the frame's two states under Tricks.