studio-engine 0.83.0 → 0.85.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: 940823449254339d163e66fe93eabdab83cc36e25f4b80600ab02c6552e3d624
4
+ data.tar.gz: 1ce338c5ba90749bbd3a5217c5dba0bd3813b5be96996c15c734cdacdb1b0f0d
5
5
  SHA512:
6
- metadata.gz: 5b9ef1be9cc7ec70f7afa978140a5fad06d4b828a561ba774ebd4ad83890584eaa3bb6678067280a9e22526291393a11e2fb30615687d995f2005c6876a02bd9
7
- data.tar.gz: 3f7ef9e1b17181cc63de69106270690245e3762826abdca4af9efaa42483b95d89f61e9519465449fbff7082c35b31b7cc10751d0ef01a73706da289fd33eaf0
6
+ metadata.gz: c322848f2708206583cff895faf563e34b85853494172dfd6f859568a771555a5f5b7859c0ac517a361679a282dbe5af1e5aa91b4bdce9a5e5df57c38a4cbf59
7
+ data.tar.gz: 70c41c33f57623eee5a8fe63aab6ff5c804a3570f055cca12bde70c8ae0919b20c3026de2100518e01ccab75c79f77afcc21674dede4c76ea7d6b1be4b6d5e0f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,132 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.85.0 — 2026-10-02
8
+
9
+ ### Changed
10
+
11
+ - **`Studio::S3.delete` now moves the object to `trash/` instead of deleting
12
+ it.** The object is copied to `trash/<UTC date>/<epoch ms>/<key>` in the same
13
+ bucket, then deleted; a failed copy raises and never deletes. It returns the
14
+ trash key (nil when nothing was there) rather than the S3 response. The
15
+ bucket's lifecycle rule must expire `trash/` after three days, or trash never
16
+ expires: set it up before upgrading (README, *Trash and restore*). The
17
+ `/admin/emails` banner and logo replace and revert paths go through this
18
+ delete.
19
+ - **`Studio::S3.list` leaves `trash/` keys out.** Pass `include_trash: true` to
20
+ keep them. The filter runs after `max_keys`, so a page can come back short.
21
+ - **The engine's auth pages speak `Studio.sign_in_label`.** 0.80.0 put the label
22
+ on the navbar's signed-out buttons only, so an app that set `"Sign in"` still
23
+ showed "Log in to continue", a "Log In" button and a "Log in" link on its
24
+ `/login` and `/signup` pages. Those words, the magic-link button, the SSO
25
+ divider, the magic-link confirm page's button and the link-sent notice now
26
+ derive from the label (`lib/studio/auth_labels.rb`; README, *Navbar
27
+ identity*). An app that leaves the label at its default renders those pages
28
+ byte-identical to before. No routes change.
29
+
30
+ ### Added
31
+
32
+ - **`Studio::S3.purge!(key:)`**, the hard delete, for objects with no value
33
+ after deletion.
34
+ - **A production-bucket guard.** `Studio::S3.delete`, `purge!`, and the trash
35
+ service's `delete` and `delete_prefixed` raise
36
+ `Studio::S3::Trash::ProductionBucketRefused` before any request when the
37
+ bucket ends in `-production` and the process is not production (`QA_ENV`
38
+ first, then `Rails.env`).
39
+ - **`ActiveStorage::Service::StudioTrashS3Service`** (`service: StudioTrashS3`
40
+ in `config/storage.yml`): Active Storage's S3 service whose `delete` trashes
41
+ the blob's object, recording the content type, byte size, checksum and
42
+ filename it can read off the object so a blob row can be rebuilt.
43
+ `delete_prefixed` (variants) stays a hard delete. Opt-in per app.
44
+ - **`rake studio:trash:list[KEY]` and `studio:trash:restore[TRASH_KEY]`**: find
45
+ a key's trash copies, and copy one back to its original key (`FORCE=1` to
46
+ overwrite, `SERVICE=<name>` for an Active Storage bucket). A restore prints the
47
+ `ActiveStorage::Blob.create!` that re-creates the blob row.
48
+ - **`Studio::S3::Trash`**, the shared copy-then-delete, key mapping, listing and
49
+ restore. A single CopyObject moves at most 5 GiB, so a larger object raises
50
+ `TooLarge` and is left in place.
51
+
52
+ ## 0.84.0 — 2026-10-01
53
+
54
+ ### Changed
55
+
56
+ - **BREAKING for a 0.83.0 consumer that shows the booking frame: the frame is no
57
+ longer cropped by default.** 0.83.0 cropped every `studio_booking_frame` to a
58
+ 414px window, 205px down a 732px frame. Those three numbers were measured from
59
+ one Google schedule, and on a schedule with a different header they sliced the
60
+ heading and exposed blank space. A crop that fits one schedule must not be a
61
+ default, so the frame now shows Google's whole page at rest until the app
62
+ declares its own crop. To keep exactly what 0.83.0 drew, add:
63
+
64
+ ```ruby
65
+ config.booking_crop = { top: 211, bottom: 613, frame_height: 732 }
66
+ ```
67
+
68
+ Better, measure the app's own schedule (below): 613 was that schedule's box
69
+ with five slots showing, and its fullest day has eight, so the 0.83.0 window
70
+ cut off the last three. `crop: false` still shows the whole frame; `crop:
71
+ true`, the default, now means "the app's crop". See *Upgrading from 0.83.0* in
72
+ [`docs/BOOKING.md`](docs/BOOKING.md).
73
+
74
+ ### Added
75
+
76
+ - **`config.booking_crop`: the booking frame's crop, per site.** A Hash of
77
+ `top:`, `bottom:` and `frame_height:`, in pixels of Google's own page: the
78
+ "Select an appointment time" box's top, its bottom on its fullest day, and the
79
+ page's height that day. The window is that box plus 6px on each edge. Because
80
+ `bottom` is the tallest the box gets, a day with fewer slots shows a strip of
81
+ Google's credit lines under a shorter box and never a cut one.
82
+ `studio_booking_frame crop:` takes `false`, `true` (the app's crop) or a Hash
83
+ for one frame. The numbers ride on each wrapper as custom properties, so two
84
+ frames with different crops share a page. The engine's `/schedule` honours the
85
+ app's crop; the popup is never cropped. A crop that cannot be one (not numbers,
86
+ negative, bottom not below top or outside the frame, a window as tall as the
87
+ frame) shows the frame whole and is logged once.
88
+ - **`bin/booking-crop-measure <booking url>`** prints that Hash. It opens Google's
89
+ embedded page at two widths, selects every bookable day of the next three
90
+ months, and reports the month-grid rows and the slot rows of the fullest day it
91
+ measured against. It reaches the live Google page, so it refuses to run in CI.
92
+ - **`config.booking_path`: an app's own booking page.** A path, or a callable
93
+ receiving the view. An app that renders `studio_booking_frame` in its own view,
94
+ rather than drawing the engine's `/schedule`, names that page here and gets
95
+ what the engine's page had: a footer link to it opens the popup, it is
96
+ `studio_booking_link`'s fallback href, and a signed-in viewer keeps the footer
97
+ on it. With neither set, nothing changes. It must be a local path (one leading
98
+ `/`): a scheme or a protocol-relative `//host` is refused, from a String at
99
+ assignment and from a callable on each request.
100
+ - [`docs/BOOKING.md`](docs/BOOKING.md): booking as a primitive of its own,
101
+ adoptable without the footer. Setting the schedule up in Google, sharing other
102
+ calendars in for conflict checking, the booking URL, measuring the crop, CSP,
103
+ and the limits. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) keeps the footer.
104
+ - `/admin/style` shows the frame's two states, whole and cropped, as drawings.
105
+
106
+ ### Fixed
107
+
108
+ - **A footer link column no longer breaks an email address or a URL mid-word.**
109
+ The link columns were equal `minmax(0, 1fr)` tracks with `overflow-wrap:
110
+ anywhere`, so an address in a column broke in two at every width. The rows are
111
+ unchanged (two link columns to a row on a phone, one row from 768px, beside the
112
+ brand from 1024px), but a track is now a plain `fr`, at least as wide as its
113
+ longest word, and an address is kept on one line: its column grows to hold it
114
+ and its neighbours give way. The first column is 1.5 shares by default and a
115
+ column may set its own, `[ "Contact", links, { width: 2 } ]`. More than four
116
+ columns wrap four to a row under the brand. An app that overrode 0.83.0's
117
+ tracks from its own stylesheet may leave that block (it draws the same rows) or
118
+ delete it once it is on this version; while it stays, `width:` hints do nothing.
119
+ [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) lists the stable class hooks.
120
+ - **A booking link that jumps to an inline frame asks Google once.** When the
121
+ frame had never been scrolled near, the link assigned its `src` and the
122
+ still-armed observer assigned it again as the frame scrolled into view: two
123
+ requests for one calendar. Whoever comes second now finds it set.
124
+ - **The footer and the booking note set their own line heights.** The Location
125
+ heading inherited the page's (a 45px line box at 1.5; it is 2.25rem, 36px,
126
+ now), and the legal line, the © line and the "Scheduling by Google Calendar"
127
+ note were each a pixel taller per line than their 1.25rem.
128
+ - **A second cropped frame on a page opens when focus moves into it from the
129
+ first.** Focus that goes from one frame straight into another never passes
130
+ through the window, so no second `blur` fired and the second frame stayed
131
+ cropped under the cursor. Only a page with two cropped frames could show it.
132
+
7
133
  ## 0.83.0 — 2026-10-01
8
134
 
9
135
  ### 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
 
@@ -628,7 +629,8 @@ Studio.configure do |config|
628
629
  config.navbar_user_name = :player_name # a method on the user
629
630
  # config.navbar_user_name = ->(user, view) { user.player_name } # or a callable
630
631
 
631
- # The signed-out button in layouts/_navbar and components/_user_nav.
632
+ # The signed-out button in layouts/_navbar and components/_user_nav, and the
633
+ # sign-in wording on the engine's own auth pages (below).
632
634
  config.sign_in_label = "Sign in" # default "Log in"
633
635
  end
634
636
  ```
@@ -645,6 +647,24 @@ end
645
647
  - The engine has no I18n catalogue, so the label is plain config, not a locale
646
648
  key. A blank or non-String label, or a `navbar_user_name` of any other type,
647
649
  raises `Studio::NavbarIdentity::InvalidConfig` at assignment.
650
+ - The label also sets the sign-in words on the engine's own auth pages, so the
651
+ navbar and `/login` never disagree. Rules: `lib/studio/auth_labels.rb`.
652
+
653
+ | Where | `"Log in"` (default) | `"Sign in"` |
654
+ |-------|----------------------|-------------|
655
+ | `/login` prompt | Log in to continue | Sign in to continue |
656
+ | `/login` password button | Log In | Sign In |
657
+ | `/login` magic-link button | Send sign-in link | Send sign-in link |
658
+ | `/login` SSO divider | or sign in below | or sign in below |
659
+ | `/signup` link back to `/login` | Log in | Sign in |
660
+ | Magic-link confirm page button | Sign in to *App* | Sign in to *App* |
661
+ | Link-sent notice | …emailed you a sign-in link. | …emailed you a sign-in link. |
662
+
663
+ The default reproduces the pages' earlier wording byte for byte, including the
664
+ four places that already said "sign in". Any other label derives every form:
665
+ `"Log on"` gives *Log On*, *Log on to continue*, *or log on below* and
666
+ *log-on link*. The "Sign in with Google" and "Sign up" buttons are not
667
+ sign-in labels and do not change.
648
668
 
649
669
  ### User nav slots
650
670
 
@@ -919,6 +939,9 @@ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
919
939
  endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
920
940
  `upload` writes but returns `nil`; serve private objects with `signed_url`.
921
941
 
942
+ `Studio::S3.delete` moves the object to `trash/` for three days rather than
943
+ deleting it; see [Trash and restore](#trash-and-restore).
944
+
922
945
  An app with **no** bucket configured does not error — `/admin/emails` renders
923
946
  read-only, showing the inherited defaults it is genuinely sending and naming the
924
947
  one setting that turns uploads on.
@@ -931,6 +954,137 @@ Add it to the app's admin sidebar section:
931
954
  { label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
932
955
  ```
933
956
 
957
+ ## Trash and restore
958
+
959
+ A delete through the engine is recoverable for three days. R2 has no object
960
+ versioning, so instead of deleting, the engine **moves** the object under
961
+ `trash/` in the same bucket, and a lifecycle rule on the bucket expires
962
+ `trash/` after three days:
963
+
964
+ ```text
965
+ avatars/abc.png -> trash/2026-10-01/1759302000123/avatars/abc.png
966
+ trash/<UTC date>/<epoch ms>/<original key>
967
+ ```
968
+
969
+ The copy goes first and the delete second. A copy that fails raises and the
970
+ delete is never sent, so a failure leaves the original in place. The trash copy
971
+ keeps the object's content type, cache headers and metadata, and adds
972
+ `original-key`, `deleted-at`, `deleted-env`, and what an Active Storage blob row
973
+ needs to be rebuilt (`blob-content-type`, `blob-byte-size`, `blob-checksum`
974
+ from a single-part ETag, and `blob-filename` when the upload carried a
975
+ Content-Disposition). A single CopyObject moves at most 5 GiB, so a larger
976
+ object raises `Studio::S3::Trash::TooLarge` and stays where it is.
977
+
978
+ `trash/` always sits at the bucket **root**, even for an app under a
979
+ `s3_key_prefix` in a shared bucket, so one lifecycle rule and one Cloudflare
980
+ rule cover every app in the bucket.
981
+
982
+ ### What deletes, and what purges
983
+
984
+ | Call | Effect |
985
+ |------|--------|
986
+ | `Studio::S3.delete(key:)` | Moves to trash; returns the trash key (nil if nothing was there) |
987
+ | `Studio::S3.purge!(key:)` | Hard delete, gone at once |
988
+ | `Studio::S3.list` | Leaves `trash/` keys out; `include_trash: true` keeps them |
989
+ | Active Storage `service: StudioTrashS3`, `delete` | Moves the blob's object to trash (`Blob#purge`, a replaced attachment) |
990
+ | Active Storage `service: StudioTrashS3`, `delete_prefixed` | Hard delete: Active Storage calls it for `variants/<key>/`, which regenerate from the original |
991
+
992
+ **The production guard.** All four destructive paths refuse a bucket whose name
993
+ ends in `-production` when the process is not production, by `Studio::S3`'s own
994
+ resolution (`QA_ENV` first, so a QA app running Rails as production is NOT
995
+ production, then `Rails.env`). They raise
996
+ `Studio::S3::Trash::ProductionBucketRefused` before sending any request. The
997
+ guard reads the bucket NAME, so it protects only buckets that follow the
998
+ `<app>-production` convention.
999
+
1000
+ ### Set up the bucket (once per bucket, before an app adopts)
1001
+
1002
+ Without the lifecycle rule, trash never expires. In the Cloudflare dashboard:
1003
+ R2 → the bucket → Settings → Object lifecycle rules → add a rule for prefix
1004
+ `trash/` that deletes objects 3 days after upload. A trash copy is a new object,
1005
+ so "uploaded" is the moment it was deleted. The same rule as S3 lifecycle JSON
1006
+ (R2's S3 API and AWS both take it). `aws s3api
1007
+ put-bucket-lifecycle-configuration` REPLACES the bucket's whole lifecycle
1008
+ configuration, R2's default abort-incomplete-multipart rule included, so read
1009
+ the current rules first (`get-bucket-lifecycle-configuration`) and send them
1010
+ together with this one, or use the dashboard:
1011
+
1012
+ ```json
1013
+ {
1014
+ "Rules": [
1015
+ {
1016
+ "ID": "expire-trash-3d",
1017
+ "Status": "Enabled",
1018
+ "Filter": { "Prefix": "trash/" },
1019
+ "Expiration": { "Days": 3 }
1020
+ }
1021
+ ]
1022
+ }
1023
+ ```
1024
+
1025
+ A public bucket also serves `trash/` from its custom domain unless it is
1026
+ blocked, which would keep a "deleted" profile picture reachable by URL for three
1027
+ days. Add a Cloudflare WAF custom rule on the zone, action **Block**:
1028
+
1029
+ ```text
1030
+ (http.host in {"assets.example.com"} and starts_with(http.request.uri.path, "/trash/"))
1031
+ ```
1032
+
1033
+ Name every `assets.<domain>` host the bucket answers on. If the bucket has the
1034
+ `r2.dev` public URL enabled, turn it off; a WAF rule cannot cover it.
1035
+
1036
+ ### Adopt it in an app
1037
+
1038
+ Active Storage: change the R2 service in `config/storage.yml` from `service: S3`
1039
+ to `service: StudioTrashS3`. Every other key stays as it is:
1040
+
1041
+ ```yaml
1042
+ r2:
1043
+ service: StudioTrashS3
1044
+ bucket: my-app-production
1045
+ endpoint: <%= ENV["R2_ENDPOINT"] %>
1046
+ region: auto
1047
+ access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
1048
+ secret_access_key: <%= ENV["R2_SECRET_ACCESS_KEY"] %>
1049
+ ```
1050
+
1051
+ An app with its own S3 service subclass (turf-monster's
1052
+ `ActiveStorage::Service::R2PublicService`) inherits from the trash service
1053
+ instead:
1054
+
1055
+ ```ruby
1056
+ require "active_storage/service/studio_trash_s3_service"
1057
+
1058
+ module ActiveStorage
1059
+ class Service::R2PublicService < Service::StudioTrashS3Service
1060
+ # ...
1061
+ end
1062
+ end
1063
+ ```
1064
+
1065
+ `Studio::S3` callers need no change: `delete` trashes from this release on. Call
1066
+ `purge!` where an object truly has no value after deletion.
1067
+
1068
+ ### Find and restore a deleted object
1069
+
1070
+ ```bash
1071
+ bin/rails "studio:trash:list" # everything in trash
1072
+ bin/rails "studio:trash:list[avatars/abc.png]" # one key's trash copies
1073
+ bin/rails "studio:trash:restore[trash/2026-10-01/1759302000123/avatars/abc.png]"
1074
+ ```
1075
+
1076
+ Both read `Studio::S3`'s bucket. `SERVICE=<storage.yml service name>` reads an
1077
+ Active Storage service's bucket instead, e.g. `SERVICE=r2`. A restore copies the
1078
+ object back to its original key and leaves the trash copy for the lifecycle rule
1079
+ to expire. It refuses to overwrite an object already at the original key unless
1080
+ `FORCE=1`.
1081
+
1082
+ Restoring an Active Storage object restores its **bytes only**. The blob row was
1083
+ destroyed before the object was trashed, so the task prints the
1084
+ `ActiveStorage::Blob.create!(...)` to run, filled from the trash copy's metadata,
1085
+ and the record must be re-attached by hand. `filename` is known only when the
1086
+ upload carried a Content-Disposition; supply it otherwise.
1087
+
934
1088
  ## Overriding Views
935
1089
 
936
1090
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -28,7 +28,7 @@ class MagicLinksController < ApplicationController
28
28
  end
29
29
  respond_to do |format|
30
30
  format.json { render json: { success: true } }
31
- format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link." }
31
+ format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}." }
32
32
  end
33
33
  end
34
34
 
@@ -18,7 +18,7 @@ class RegistrationsController < ApplicationController
18
18
  token = issue_magic_link(email, nil)
19
19
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
20
20
  end
21
- return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
21
+ return redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}."
22
22
  end
23
23
 
24
24
  @user = User.new(user_params)
@@ -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
@@ -73,7 +73,7 @@
73
73
 
74
74
  <p class="text-center text-secondary text-sm mt-6">
75
75
  Already have an account?
76
- <%= link_to "Log in", login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
76
+ <%= link_to Studio::AuthLabels.link(Studio.sign_in_label), login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
77
77
  </p>
78
78
  </div>
79
79
  </div>
@@ -11,7 +11,7 @@
11
11
 
12
12
  <div class="flex items-center gap-3 mt-5">
13
13
  <div class="flex-1 h-px border-t border-subtle"></div>
14
- <span class="text-muted text-xs uppercase tracking-wider">or sign in below</span>
14
+ <span class="text-muted text-xs uppercase tracking-wider"><%= Studio::AuthLabels.or_below(Studio.sign_in_label) %></span>
15
15
  <div class="flex-1 h-px border-t border-subtle"></div>
16
16
  </div>
17
17
  </div>
@@ -4,6 +4,9 @@
4
4
  words = Studio.app_name.split
5
5
  last_word = words.pop
6
6
  prefix = words.join(" ")
7
+ # The sign-in wording follows Studio.sign_in_label, as the navbar does
8
+ # (lib/studio/auth_labels.rb); the default label prints the wording these pages always had.
9
+ sign_in_label = Studio.sign_in_label
7
10
  %>
8
11
 
9
12
  <div class="min-h-[70vh] flex items-center justify-center">
@@ -13,7 +16,7 @@
13
16
  <img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="w-20 h-20 rounded-full shadow-lg mx-auto mb-4">
14
17
  <% end %>
15
18
  <h1 class="text-3xl font-extrabold text-heading tracking-tight"><%= prefix %> <span class="text-primary"><%= last_word %></span></h1>
16
- <p class="text-secondary mt-2">Log in to continue</p>
19
+ <p class="text-secondary mt-2"><%= Studio::AuthLabels.prompt(sign_in_label) %></p>
17
20
  </div>
18
21
 
19
22
  <% if has_sso %>
@@ -57,7 +60,7 @@
57
60
  </div>
58
61
 
59
62
  <button type="submit" class="btn btn-primary btn-lg w-full">
60
- Log In
63
+ <%= Studio::AuthLabels.button(sign_in_label) %>
61
64
  </button>
62
65
  <% end %>
63
66
  <% elsif Studio.auth_method?(:magic_link) %>
@@ -71,7 +74,7 @@
71
74
  </div>
72
75
 
73
76
  <button type="submit" class="btn btn-primary btn-lg w-full">
74
- Send sign-in link
77
+ Send <%= Studio::AuthLabels.link_noun(sign_in_label) %>
75
78
  </button>
76
79
  <% end %>
77
80
  <% end %>
@@ -75,7 +75,7 @@
75
75
  method: :post,
76
76
  form: { id: "magic-consume-form", data: { turbo: "false" } },
77
77
  class: "magic-submit" do %>
78
- Sign in to <%= Studio.app_name %>
78
+ <%= Studio::AuthLabels.to_app(Studio.sign_in_label, Studio.app_name) %>
79
79
  <% end %>
80
80
  </div>
81
81
  </main>
@@ -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) %>