studio-engine 0.133.2 → 0.135.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: a0b1a25cbd9027f85de7e90d9e0b22f113d048a622979fe2a996ddac25526106
4
- data.tar.gz: 5501ce9e395be5812d0eb25d2cc53ab501f1f6f02a7afd0366d1429b506edda8
3
+ metadata.gz: 1b8bd497e5ba5d6c113a0e6c221b03200f395b1c3d4032aa1327691b7c584c34
4
+ data.tar.gz: 0a64eb7f3a70bcbe03a50550d36a1d8da938498d91d5cf0a1fcb257845a76759
5
5
  SHA512:
6
- metadata.gz: a0ad40b43a9674a2b7e67711bb85de70558331279efe955df165d8d96178a779e6ff12282c3d359224e1a78e605ae5f0cc874e7bc63e5d4a180f03161439202c
7
- data.tar.gz: 75d96b512a1c891a23a60c983e45a951fa1e909c9894579485074ec042420a1208895135078e29b8dad0564589d7818f9df4c27381f60eb2cd5ce26ecda0d938
6
+ metadata.gz: c325e02d5e2f0c2a9123ab2b342281b59d27635e48cca92396f792cd39a6fc403cf6e79bfc2e920f891ef9dcc30063e0460e393db83e7148470d3895615b4aad
7
+ data.tar.gz: 96424698d12cf9d7d60bfee31c6f8d97f5bb464439bb421d8f47ba3ab78f1ce359e9a01bd543f3baab4c45d502c73e117ed3074d4bc306c40582b9819ab3aa53
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.135.0 — 2026-11-07
8
+
9
+ ### Fixed
10
+
11
+ - The not-found page's og:url is the app's home, not the address it was asked for: `link_preview` takes `url:` (a path or an absolute URL), and a 404 at a hidden route no longer names that route in its link-preview tags.
12
+
13
+ ### Added
14
+
15
+ - **A friendly not-found page.** `config.exceptions_app = Studio::ExceptionsApp.new` in `config/application.rb` gives a lost visitor the app's name, brand mark, a short message and a button home, inside the app's own layout, for an unknown address and a missing record alike. The status stays 404. A JSON request still gets Rails' 404 JSON, and other statuses keep `public/<status>.html`. If the page cannot render, or the host answers anything but a 404, the static `public/404.html` answers instead, so it cannot loop or change the status. An app with its own wall lists `"studio/not_found" => %w[show]`. See `docs/NOT_FOUND_PAGE.md`.
16
+
17
+ ## 0.134.0 — 2026-11-07
18
+
19
+ ### Changed
20
+
21
+ - **A looping carousel now wraps visually.** `studio/_carousel` with `loop: true` and more than one slide copies its last slides before the first and its first slides after the last, so a spotlight opening on its first slide shows the last one beside it (the hub's `/websites` row). Next at the last slide, Previous at the first and autoplay glide across the seam and then jump silently onto the real slide; reduced motion goes straight there. The copies are `aria-hidden` and `inert` and drop ids, names, `data-test` hooks and Stimulus wiring, so a screen reader and Tab meet each slide once and the dots count real slides. Every slide of a looping standard row is now a stop (six slides three to a view show six dots, not four). Rows without `loop:` are unchanged. Nothing to do in a host; a host spec that counts `.carousel-slide` in a looping row should count `[data-carousel-target='slide']`.
22
+ - **A looping spotlight opens on its first slide.** With `style: :spotlight`, `loop: true`, more than one slide and no `start:`, the row now opens on slide 0, the host's first item, with the last slide showing on its left. It used to open on `:middle`. A spotlight that does not loop still opens on `:middle`, and an explicit `start:` still wins. A host that wants the old opening passes `start: :middle`.
23
+ - **Footer social icons are lit like the buttons.** A linked `.ftr-social` disc in the site footer now wears the glass lit button's family in every app, glass surface or not: a top highlight, a bottom edge, a contact shadow and a soft glow in its own fill, with the dark-mode values in dark mode. Hover still lifts and now deepens (never brightens, so the white glyph keeps its contrast). A pending (dashed) icon stays flat. No consumer change: it arrives with the engine bump.
24
+
7
25
  ## 0.133.2 — 2026-11-06
8
26
 
9
27
  ### Changed
data/README.md CHANGED
@@ -45,6 +45,7 @@ resolved.
45
45
  - **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).
46
46
  - **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).
47
47
  - **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer. It is on by default: with nothing configured it prints the app's name, logo and the legal links the app has routes for, and no address. `config.site_footer` declares the app's own facts (brand, social profiles, link columns, an address with a live map, legal links), every part optional; `config.site_footer_address` adds a location in one setting; `config.site_footer = false` turns the footer off; `config.site_footer_chat_path` adds a standard Chat link to the Contact column. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
48
+ - **Not-found page**: `config.exceptions_app = Studio::ExceptionsApp.new` in `config/application.rb` gives a lost visitor a friendly 404 with the app's name, brand mark and a button home, inside the app's layout. The status stays 404, JSON requests keep Rails' 404 JSON, and `public/404.html` is the fallback if the page cannot render. See [`docs/NOT_FOUND_PAGE.md`](docs/NOT_FOUND_PAGE.md).
48
49
  - **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).
49
50
  - **Voice profile**: an opt-in row on `/profile` ("Set up your voice"): the person reads three phrases with a live level meter, their device turns each take into a speaker embedding (WeSpeaker via transformers.js), and only the averaged vector is saved on their user, never audio. `Studio::VoiceMatch.identify(embedding, among: User.all, model:)` answers who is speaking. Turn it on with `config.voice_profile = true` after installing the migration. See [`docs/VOICE_PROFILE.md`](docs/VOICE_PROFILE.md).
50
51
  - **Voice capture**: the microphone chip (names the microphone a page records from, picks another, Chrome help dialog), one level meter, and a record button that refuses a silent take and hands a heard one to the page as a `studio:voice-recorder:take` event with the blob; it uploads nothing. Render `studio/voice/microphone_chip` and `studio/voice/recorder`. See [`docs/VOICE_CAPTURE.md`](docs/VOICE_CAPTURE.md).
@@ -343,15 +344,23 @@ keys move one slide. Where the browser has scroll-driven animations the look fol
343
344
  scroll; elsewhere the controller marks the centred slide (`data-current`) and CSS
344
345
  transitions it. Reduced motion only dims.
345
346
 
346
- `start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight opens
347
- on its middle slide by default, so neither half of the row is empty at load; a standard
348
- row opens on its first. The browser sets it before the first paint where it has
347
+ `start:` is the slide the row opens on: an index from 0, or `:middle`. A spotlight that
348
+ does not loop opens on its middle slide by default, so neither half of the row is empty at
349
+ load; a standard row and a looping spotlight open on their first (a looping row's wrap
350
+ already shows the last slide left of the first). An explicit `start:` wins. The browser sets it before the first paint where it has
349
351
  `scroll-initial-target`; elsewhere the controller jumps there when it connects, with no
350
352
  glide and nothing announced. A `#fragment` link to an element inside the row wins.
351
353
 
352
- `loop: true` wraps the row: Next at the last slide goes to the first, Previous at the first
353
- to the last, and neither arrow is ever disabled. No slide is cloned, so a screen reader reads
354
- each one once; the row glides back across itself.
354
+ `loop: true` wraps the row, and a row of more than one slide is visually continuous: the
355
+ controller copies the last slides before the first and the first slides after the last, so
356
+ with the first slide centred the last one shows beside it. Next at the last slide glides on
357
+ to the first and Previous at the first back to the last; once the row rests, it jumps with no
358
+ glide onto the real slide, which looks the same. Under reduced motion it goes straight there.
359
+ Neither arrow is ever disabled, and every slide of a looping row is a stop. A copy is
360
+ `aria-hidden` and `inert`, and drops its ids, names, test hooks (`data-test`, `data-testid`)
361
+ and Stimulus wiring, so a screen reader and Tab meet each slide once, a `#fragment` finds the
362
+ real slide, and the dots and the status line count the real slides. A slide that a controller
363
+ fills after load shows its server-rendered copy beside the seam.
355
364
 
356
365
  `autoplay:` is the seconds between moves (default `nil`, off). The row moves one slide each
357
366
  interval, in both styles. It holds still while the pointer is over the slides, while keyboard
@@ -1064,6 +1064,15 @@ body.modal-open .studio-app-banner {
1064
1064
  opacity: 0;
1065
1065
  }
1066
1066
 
1067
+ /* A looping row's jump across the seam (data-carousel-wrapping, one frame): the
1068
+ mark moves from a clone to the real slide it copies, in the same place, so
1069
+ nothing may transition. A clone is a carousel-slide, so every other rule here
1070
+ (the scroll-driven look, the mark) styles it as it styles a real slide. */
1071
+ &[data-carousel-wrapping] .carousel-slide .carousel-spotlight-body,
1072
+ &[data-carousel-wrapping] .carousel-slide .carousel-caption {
1073
+ transition: none;
1074
+ }
1075
+
1067
1076
  /* A neighbour holding focus drops the neighbour look on every path, so its focus
1068
1077
  ring shows at full strength (WCAG 2.4.7). It outranks the animation and the mark. */
1069
1078
  & .carousel-slide:focus-within .carousel-spotlight-body,
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The friendly not-found page, drawn by Studio::ExceptionsApp for an HTML 404
5
+ # (docs/NOT_FOUND_PAGE.md). It has no route: only the exceptions app calls it.
6
+ #
7
+ # It inherits the host's ApplicationController so the page sits inside the
8
+ # app's own layout, navbar, footer and theme, as Studio::PublicUsersController
9
+ # does. A signed-out visitor must see it, so it skips the engine's sign-in gate.
10
+ # A host with its own wall lists it there (the hub's AdminWall names
11
+ # "studio/not_found" => %w[show]). If the host still answers anything but a
12
+ # 404 here, the exceptions app throws that answer away and serves the static
13
+ # public/404.html, so a wall can cost the page its layout but never its status.
14
+ class NotFoundController < ::ApplicationController
15
+ skip_before_action :require_authentication, raise: false
16
+ # No forgery skip: Rails' ShowExceptions calls the exceptions app as a GET
17
+ # (request_method = "GET"), which never needs the token, so the host's CSRF
18
+ # allow-lists stay as they are.
19
+
20
+ def show
21
+ @studio_not_found = {
22
+ app_name: Studio.app_name,
23
+ logo: not_found_logo,
24
+ home_path: not_found_home_path,
25
+ back_path: not_found_back_path
26
+ }
27
+ render "studio/not_found/show", status: :not_found, formats: [:html], content_type: "text/html"
28
+ end
29
+
30
+ private
31
+
32
+ def not_found_logo
33
+ Studio.navbar_brand&.dig(:logo) || Studio.logo_for("Navbar Logo")
34
+ end
35
+
36
+ def not_found_home_path
37
+ main_app.respond_to?(:root_path) ? main_app.root_path : "/"
38
+ end
39
+
40
+ # The page the visitor came from, when it is on this app: "Go back" returns
41
+ # there. A link from elsewhere, or none, draws no second link.
42
+ def not_found_back_path
43
+ referer = URI.parse(request.referer.to_s)
44
+ return nil unless referer.host == request.host && referer.path.present?
45
+ # "//other.host/x" is a same-host path a browser reads as another site.
46
+ return nil if referer.path.start_with?("//")
47
+ return nil if referer.path == request.env["action_dispatch.original_path"]
48
+
49
+ [referer.path, referer.query].compact.join("?")
50
+ rescue URI::InvalidURIError
51
+ nil
52
+ end
53
+ end
54
+ end
@@ -10,7 +10,7 @@ module Studio
10
10
  # reason Studio::GeoHelper gives: every helper module is included into every
11
11
  # view, so an unprefixed name would collide with a host's own helper.
12
12
  module LinkPreviewHelper
13
- OVERRIDE_KEYS = %i[image title description image_alt].freeze
13
+ OVERRIDE_KEYS = %i[image title description image_alt url].freeze
14
14
 
15
15
  # THE PAGE OVERRIDE. Call it from any view (the layout renders later, so it
16
16
  # sees the call):
@@ -71,7 +71,7 @@ module Studio
71
71
  image_height: height,
72
72
  image_alt: (overrides[:image_alt].to_s.strip.presence || preview[:title] if preview[:image]),
73
73
  site_name: Studio.app_name.to_s,
74
- url: studio_link_preview_page_url
74
+ url: studio_link_preview_page_url(overrides[:url])
75
75
  )
76
76
  end
77
77
 
@@ -127,9 +127,22 @@ module Studio
127
127
  request_base_url: (request.base_url if studio_link_preview_request?))
128
128
  end
129
129
 
130
- def studio_link_preview_page_url
130
+ # The page's og:url: the request's own URL, or the `url:` a page passed to
131
+ # link_preview. A path is built on the same base as the request's; an
132
+ # absolute URL is used as is. The not-found page passes the app's home, so a
133
+ # 404 never advertises the broken address it was asked for (a 404 at a
134
+ # hidden route would otherwise name that route in its og:url).
135
+ def studio_link_preview_page_url(override = nil)
131
136
  return nil unless studio_link_preview_request?
132
137
 
138
+ override = override.to_s.strip
139
+ return override if override.match?(%r{\Ahttps?://}i)
140
+
141
+ if override.start_with?("/")
142
+ return Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
143
+ request_url: "#{request.base_url}#{override}", path: override)
144
+ end
145
+
133
146
  Studio::LinkPreview.page_url(base_url: Studio.link_preview_base_url,
134
147
  request_url: request.original_url, path: request.path)
135
148
  end
@@ -51,6 +51,8 @@ export function perPage(clientWidth, slideWidth, gap) {
51
51
  // A LOOPING row (loop: true) wraps instead: Next at the last stop goes to the
52
52
  // first, Previous at the first goes to the last. A page that would overshoot an
53
53
  // end still lands on that end first, so no slide is skipped on the way round.
54
+ // The controller uses this wrap only for a looping row it has not cloned; a cloned
55
+ // row (THE LOOP'S CLONES, below) moves by loopStop instead, round the seam.
54
56
  export function targetStop(action, current, page, count, loop = false) {
55
57
  const last = Math.max(0, count - 1)
56
58
  if (loop && last > 0) {
@@ -170,3 +172,77 @@ export function startStop(start, count) {
170
172
  const index = Math.floor(Number(start)) || 0
171
173
  return Math.min(count - 1, Math.max(0, index))
172
174
  }
175
+
176
+ // THE LOOP'S CLONES. A looping row (loop: true, more than one slide) is visually
177
+ // continuous: the controller copies the last slides before the first and the first
178
+ // slides after the last, so the space either side of an end shows the slides round
179
+ // the seam. A clone is aria-hidden and inert, with no id and no test hook, so a
180
+ // screen reader and Tab meet each slide once; the dots and the status line count the
181
+ // real slides only.
182
+ //
183
+ // Each place in that longer row has a VIRTUAL index: the real slides are 0 to
184
+ // total - 1, the clones before them -count to -1, the clones after total to
185
+ // total + count - 1. A move across the seam glides to a clone, and once the row
186
+ // rests there the controller jumps, with no glide, to the real slide it copies.
187
+
188
+ // The index of a real slide from a virtual one.
189
+ export function wrapIndex(index, total) {
190
+ if (!(total > 0)) return 0
191
+ return ((index % total) + total) % total
192
+ }
193
+
194
+ // How many clones each side: a wide row's slides plus one, enough to fill the view
195
+ // beside either end in both styles. None for a row of one slide.
196
+ export function cloneCount(perView, total) {
197
+ if (!(total > 1)) return 0
198
+ const view = Number(perView)
199
+ return Math.ceil(Number.isFinite(view) && view > 0 ? Math.min(view, 6) : 3) + 1
200
+ }
201
+
202
+ // The real slide each clone copies, in row order: `before` sits ahead of the first
203
+ // slide, `after` behind the last. A short row repeats round itself.
204
+ export function cloneLayout(total, count) {
205
+ if (!(total > 1) || !(count > 0)) return { before: [], after: [] }
206
+ const before = []
207
+ const after = []
208
+ for (let k = count; k >= 1; k -= 1) before.push(wrapIndex(-k, total))
209
+ for (let k = 0; k < count; k += 1) after.push(wrapIndex(k, total))
210
+ return { before, after }
211
+ }
212
+
213
+ // The virtual index an arrow, a key or autoplay takes a cloned row to: a page on
214
+ // either way, round the seam with no end. Home and End go to the first and last
215
+ // real slide. Held to the clones, so a press mid-glide never runs off the row.
216
+ export function loopStop(action, current, page, total, count) {
217
+ const to = { prev: current - page, next: current + page, first: 0, last: total - 1 }[action]
218
+ if (to === undefined) return current
219
+ return Math.min(total - 1 + count, Math.max(-count, to))
220
+ }
221
+
222
+ // The virtual index nearest `current` that shows real slide `index`: a dot takes
223
+ // the short way round the seam.
224
+ export function nearestVirtual(index, current, total, count) {
225
+ const options = [index - total, index, index + total].filter((v) => v >= -count && v <= total - 1 + count)
226
+ return options.reduce((best, v) => (Math.abs(v - current) < Math.abs(best - current) ? v : best), index)
227
+ }
228
+
229
+ // The real slide a row resting on a clone jumps to, or null when it rests on a real one.
230
+ export function seamJump(virtual, total) {
231
+ if (!(total > 0)) return null
232
+ return virtual < 0 || virtual >= total ? wrapIndex(virtual, total) : null
233
+ }
234
+
235
+ // The attributes a clone drops: ids and names (a clone must not answer a #fragment or
236
+ // join a form), test hooks, and Stimulus wiring (a clone runs no controller and is
237
+ // no one's target), and the carousel's own marks.
238
+ export const CLONE_STRIP = /^(id|name|data-(test|testid|test-id|qa|cy)|data-(studio-)?(controller|action)|data-[\w-]+-target|data-carousel-start-slide|data-current)$/
239
+
240
+ // The status line of a looping row, whose view may run round the seam:
241
+ // "Slides 5 to 6 and 1 of 6".
242
+ export function loopPositionText(first, shown, total) {
243
+ const count = Math.min(shown, total)
244
+ if (first + count <= total) return positionText(first, count, total)
245
+ const over = first + count - total
246
+ const head = first + 1 === total ? `Slides ${total}` : `Slides ${first + 1} to ${total}`
247
+ return `${head} and ${over === 1 ? "1" : `1 to ${over}`} of ${total}`
248
+ }
@@ -26,10 +26,21 @@
26
26
  // and nothing in the status line. A row already moved (the browser's own start, a
27
27
  // restored scroll, a #fragment inside it) is left where it is.
28
28
  //
29
- // A LOOPING row (loop:, data-carousel-loop) wraps: Next at the last stop goes back
30
- // to the first, Previous at the first to the last, and neither arrow is ever
31
- // aria-disabled. There are no cloned slides, so a screen reader reads each once:
32
- // the row glides back across itself.
29
+ // A LOOPING row (loop:, data-carousel-loop) of more than one slide is visually
30
+ // continuous. connect() copies the last slides before the first and the first slides
31
+ // after the last (cloneLayout), so the space beside either end shows the slides
32
+ // round the seam. Next at the last slide glides on to the clone of the first;
33
+ // once the row rests there (scrollend, or a short quiet spell where the browser has
34
+ // no scrollend) it jumps, with no glide and no transition, to the real first slide,
35
+ // whose neighbours look the same. Previous at the first does the same backwards, and
36
+ // a swipe that rests on a clone too. Under reduced motion the row goes straight to
37
+ // the real slide. Neither arrow is ever aria-disabled.
38
+ //
39
+ // A clone is aria-hidden and inert, keeps no id, name, test hook or Stimulus wiring
40
+ // (CLONE_STRIP), and is no slide target, so a screen reader and Tab meet each real
41
+ // slide once, a #fragment finds the real slide, and the dots and the status line
42
+ // count real slides. A click on a clone moves the row to it. disconnect() removes
43
+ // the clones, and connect() clears any a page snapshot kept.
33
44
  //
34
45
  // AUTOPLAY (autoplay: seconds, data-carousel-autoplay) moves the row one stop each
35
46
  // interval. It holds still while the pointer is over the slides, while keyboard focus
@@ -40,8 +51,9 @@
40
51
  // (APG: a rotating carousel does not announce). Without loop it stops at the end.
41
52
  import { Controller } from "@hotwired/stimulus"
42
53
  import {
43
- TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, currentStop, hiddenEnds, idleDelay,
44
- initialReasons, keyAction, ownsKey, perPage, positionText, startStop, stops, targetStop, toggleLabel, withReason
54
+ CLONE_STRIP, TOLERANCE, autoplayRunning, autoplaySeconds, autoplayStop, centerStops, cloneCount, cloneLayout,
55
+ currentStop, hiddenEnds, idleDelay, initialReasons, keyAction, loopPositionText, loopStop, nearestVirtual, ownsKey,
56
+ perPage, positionText, seamJump, startStop, stops, targetStop, toggleLabel, withReason, wrapIndex
45
57
  } from "studio/carousel"
46
58
 
47
59
  const REDUCE = "(prefers-reduced-motion: reduce)"
@@ -51,14 +63,22 @@ export default class extends Controller {
51
63
 
52
64
  connect() {
53
65
  this.schedule = this.schedule.bind(this)
66
+ this.settle = this.settle.bind(this)
67
+ this.clickClone = this.clickClone.bind(this)
54
68
  this.loop = this.element.hasAttribute("data-carousel-loop")
69
+ this.cloned = 0
70
+ this.removeClones()
71
+ this.scrollEnd = "onscrollend" in window
55
72
  this.trackTarget.addEventListener("scroll", this.schedule, { passive: true })
73
+ if (this.scrollEnd) this.trackTarget.addEventListener("scrollend", this.settle, { passive: true })
74
+ this.trackTarget.addEventListener("click", this.clickClone)
56
75
  if (typeof ResizeObserver === "function") {
57
76
  this.observer = new ResizeObserver(this.schedule)
58
77
  this.observer.observe(this.trackTarget)
59
78
  }
60
79
  this.element.setAttribute("data-carousel-ready", "")
61
80
  this.openAtStart()
81
+ this.addClones()
62
82
  this.sync()
63
83
  this.startAutoplay()
64
84
  }
@@ -82,10 +102,91 @@ export default class extends Controller {
82
102
 
83
103
  disconnect() {
84
104
  this.trackTarget.removeEventListener("scroll", this.schedule)
105
+ this.trackTarget.removeEventListener("scrollend", this.settle)
106
+ this.trackTarget.removeEventListener("click", this.clickClone)
85
107
  this.observer?.disconnect()
86
108
  if (this.frame) cancelAnimationFrame(this.frame)
87
109
  this.frame = null
110
+ clearTimeout(this.settleTimer)
111
+ this.settleTimer = null
88
112
  this.stopAutoplay()
113
+ this.removeClones()
114
+ }
115
+
116
+ // -- the loop's clones ------------------------------------------------------
117
+
118
+ // Copies the slides round the seam either side, and holds the row where it was:
119
+ // the scroll moves by the width the clones added before the first slide.
120
+ addClones() {
121
+ this.cloned = 0
122
+ if (!this.loop) return
123
+ const slides = this.slideTargets
124
+ const perView = parseFloat(getComputedStyle(this.element).getPropertyValue("--carousel-per-view"))
125
+ const { before, after } = cloneLayout(slides.length, cloneCount(perView, slides.length))
126
+ if (before.length === 0) return
127
+
128
+ const track = this.trackTarget
129
+ const first = slides[0]
130
+ const left = track.scrollLeft
131
+ const was = first.offsetLeft
132
+ first.before(...before.map((index) => this.cloneOf(slides[index])))
133
+ slides[slides.length - 1].after(...after.map((index) => this.cloneOf(slides[index])))
134
+ this.cloned = before.length
135
+ track.scrollTo({ left: left + first.offsetLeft - was, behavior: "instant" })
136
+ }
137
+
138
+ cloneOf(slide) {
139
+ const clone = slide.cloneNode(true)
140
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
141
+ for (const { name } of [...node.attributes]) if (CLONE_STRIP.test(name)) node.removeAttribute(name)
142
+ }
143
+ for (const name of ["role", "aria-roledescription", "aria-label"]) clone.removeAttribute(name)
144
+ clone.setAttribute("aria-hidden", "true")
145
+ clone.setAttribute("inert", "")
146
+ clone.setAttribute("data-carousel-clone", "")
147
+ return clone
148
+ }
149
+
150
+ removeClones() {
151
+ this.trackTarget.querySelectorAll(":scope > [data-carousel-clone]").forEach((clone) => clone.remove())
152
+ this.cloned = 0
153
+ }
154
+
155
+ // Every slide in row order, the clones included.
156
+ rowSlides() {
157
+ return this.cloned ? [...this.trackTarget.querySelectorAll(":scope > .carousel-slide")] : this.slideTargets
158
+ }
159
+
160
+ // The row came to rest. On a clone it jumps to the real slide the clone copies,
161
+ // at the same place in the row, so nothing on screen changes.
162
+ settle() {
163
+ this.settleTimer = null
164
+ if (!this.cloned) return
165
+ this.sync()
166
+ const real = seamJump(this.virtual, this.positions.length)
167
+ if (real === null) return
168
+ const at = this.full[this.virtual + this.cloned]
169
+ if (Math.abs(this.trackTarget.scrollLeft - at) > TOLERANCE) return
170
+ this.element.setAttribute("data-carousel-wrapping", "")
171
+ this.trackTarget.scrollTo({ left: this.positions[real], behavior: "instant" })
172
+ this.sync()
173
+ void this.trackTarget.offsetWidth
174
+ requestAnimationFrame(() => this.element.removeAttribute("data-carousel-wrapping"))
175
+ }
176
+
177
+ // An inert clone takes no click, so the click lands on the track: a click on a
178
+ // clone's box moves the row to it.
179
+ clickClone(event) {
180
+ if (!this.cloned || event.target !== this.trackTarget) return
181
+ const index = this.rowSlides().findIndex((slide) => {
182
+ if (!slide.hasAttribute("data-carousel-clone")) return false
183
+ const box = slide.getBoundingClientRect()
184
+ return event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom
185
+ })
186
+ if (index < 0) return
187
+ this.interacted()
188
+ this.sync()
189
+ this.scrollToStop(index - this.cloned)
89
190
  }
90
191
 
91
192
  // -- autoplay ---------------------------------------------------------------
@@ -183,7 +284,7 @@ export default class extends Controller {
183
284
  this.timer = null
184
285
  this.sync()
185
286
  const count = this.positions.length
186
- const to = autoplayStop(this.current, count, this.loop)
287
+ const to = this.cloned ? loopStop("next", this.virtual, 1, count, this.cloned) : autoplayStop(this.current, count, this.loop)
187
288
  if (to === null && count > 1) {
188
289
  // The end of a row that does not loop: autoplay is done until Play.
189
290
  this.pause("stopped", true)
@@ -194,6 +295,10 @@ export default class extends Controller {
194
295
  }
195
296
 
196
297
  schedule() {
298
+ if (this.cloned && !this.scrollEnd) {
299
+ clearTimeout(this.settleTimer)
300
+ this.settleTimer = setTimeout(this.settle, 150)
301
+ }
197
302
  if (this.frame) return
198
303
  this.frame = requestAnimationFrame(() => {
199
304
  this.frame = null
@@ -201,9 +306,11 @@ export default class extends Controller {
201
306
  })
202
307
  }
203
308
 
309
+ // The stops of every slide in the row. A cloned row measures its clones too, and
310
+ // every slide of it is a stop: the row never ends.
204
311
  measure() {
205
312
  const track = this.trackTarget
206
- const slides = this.slideTargets
313
+ const slides = this.rowSlides()
207
314
  if (this.element.classList.contains("carousel-spotlight")) {
208
315
  const max = Math.max(0, track.scrollWidth - track.clientWidth)
209
316
  const boxes = slides.map((slide) => ({ left: slide.offsetLeft, width: slide.offsetWidth }))
@@ -212,6 +319,12 @@ export default class extends Controller {
212
319
  const origin = slides[0]?.offsetLeft || 0
213
320
  const max = Math.max(0, track.scrollWidth - track.clientWidth)
214
321
  const gap = parseFloat(getComputedStyle(track).columnGap) || 0
322
+ if (this.cloned) {
323
+ return {
324
+ positions: slides.map((slide) => Math.min(max, Math.max(0, slide.offsetLeft - origin))),
325
+ page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
326
+ }
327
+ }
215
328
  return {
216
329
  positions: stops(slides.map((slide) => slide.offsetLeft - origin), max),
217
330
  page: perPage(track.clientWidth, slides[0]?.offsetWidth, gap)
@@ -223,22 +336,35 @@ export default class extends Controller {
223
336
  const { positions, page } = this.measure()
224
337
  const ends = hiddenEnds(track)
225
338
 
226
- this.positions = positions
227
339
  this.page = page
228
- this.current = currentStop(track.scrollLeft, positions)
340
+ if (this.cloned) {
341
+ // positions holds every slide, clones included; this.positions the real ones.
342
+ const total = this.slideTargets.length
343
+ this.full = positions
344
+ this.virtual = currentStop(track.scrollLeft, positions) - this.cloned
345
+ this.current = wrapIndex(this.virtual, total)
346
+ this.positions = positions.slice(this.cloned, this.cloned + total)
347
+ } else {
348
+ this.full = null
349
+ this.positions = positions
350
+ this.current = currentStop(track.scrollLeft, positions)
351
+ this.virtual = this.current
352
+ }
229
353
 
230
- this.element.toggleAttribute("data-carousel-static", positions.length < 2)
354
+ this.element.toggleAttribute("data-carousel-static", this.positions.length < 2)
231
355
  track.toggleAttribute("data-fade-start", ends.start)
232
356
  track.toggleAttribute("data-fade-end", ends.end)
233
357
  // A looping row never disables an arrow: each end leads round to the other.
234
- const wraps = this.loop && positions.length > 1
358
+ const wraps = this.loop && this.positions.length > 1
235
359
  if (this.hasPrevTarget) this.prevTarget.setAttribute("aria-disabled", String(!wraps && !ends.start))
236
360
  if (this.hasNextTarget) this.nextTarget.setAttribute("aria-disabled", String(!wraps && !ends.end))
237
361
  if (this.element.classList.contains("carousel-spotlight")) {
238
- this.slideTargets.forEach((slide, index) => slide.toggleAttribute("data-current", index === this.current))
362
+ // A clone is marked as a real slide is, so the fallback scales it the same.
363
+ const at = this.virtual + this.cloned
364
+ this.rowSlides().forEach((slide, index) => slide.toggleAttribute("data-current", index === at))
239
365
  }
240
366
  this.dotTargets.forEach((dot, index) => {
241
- dot.hidden = index >= positions.length
367
+ dot.hidden = index >= this.positions.length
242
368
  if (index === this.current) dot.setAttribute("aria-current", "true")
243
369
  else dot.removeAttribute("aria-current")
244
370
  })
@@ -251,7 +377,8 @@ export default class extends Controller {
251
377
  goTo(event) {
252
378
  this.interacted()
253
379
  this.sync()
254
- this.scrollToStop(Number(event.currentTarget.dataset.carouselIndex))
380
+ const index = Number(event.currentTarget.dataset.carouselIndex)
381
+ this.scrollToStop(this.cloned ? nearestVirtual(index, this.virtual, this.positions.length, this.cloned) : index)
255
382
  }
256
383
 
257
384
  key(event) {
@@ -270,17 +397,35 @@ export default class extends Controller {
270
397
  move(action) {
271
398
  this.interacted()
272
399
  this.sync()
400
+ if (this.cloned) {
401
+ const to = loopStop(action, this.virtual, this.page, this.positions.length, this.cloned)
402
+ if (to === this.virtual) return
403
+ this.scrollToStop(to)
404
+ return
405
+ }
273
406
  const to = targetStop(action, this.current, this.page, this.positions.length, this.loop)
274
407
  if (to === this.current) return
275
408
  this.scrollToStop(to)
276
409
  }
277
410
 
278
- // announce: false for autoplay, which moves the row without a word.
411
+ // announce: false for autoplay, which moves the row without a word. On a cloned
412
+ // row the index is virtual: a clone's place glides there and settle() jumps home,
413
+ // but under reduced motion the row goes straight to the real slide.
279
414
  scrollToStop(index, { announce = true } = {}) {
415
+ const reduce = window.matchMedia?.(REDUCE).matches
416
+ if (this.cloned) {
417
+ const total = this.positions.length
418
+ const real = wrapIndex(index, total)
419
+ const left = reduce ? this.positions[real] : this.full[index + this.cloned]
420
+ if (left === undefined) return
421
+ this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
422
+ if (announce && this.hasStatusTarget) this.statusTarget.textContent = loopPositionText(real, this.page, total)
423
+ return
424
+ }
425
+
280
426
  const left = this.positions[index]
281
427
  if (left === undefined) return
282
428
 
283
- const reduce = window.matchMedia?.(REDUCE).matches
284
429
  this.trackTarget.scrollTo({ left, behavior: reduce ? "auto" : "smooth" })
285
430
 
286
431
  if (!announce || !this.hasStatusTarget) return
@@ -25,15 +25,25 @@
25
25
  standard row shows a small line under every slide; the
26
26
  spotlight shows it under the centred slide only.
27
27
  start the slide the row opens on: an index from 0, or :middle (the
28
- slide at items.size / 2). Defaults to :middle for the spotlight,
29
- so both halves of the row hold a slide at load, and to 0 for a
30
- standard row. The browser sets it before the first paint where
28
+ slide at items.size / 2). Defaults to :middle for a spotlight
29
+ that does not loop, so both halves of the row hold a slide at
30
+ load, and to 0 for a standard row and for a looping spotlight
31
+ (its wrap already shows the last slide left of the first, so it
32
+ opens on the host's first item). An explicit start: wins. The browser sets it before the first paint where
31
33
  it has scroll-initial-target; elsewhere the controller sets it
32
34
  when it connects, with no glide and nothing announced. A link
33
35
  to an id inside the row (#fragment) wins over the start.
34
- loop true wraps the row: Next at the last slide goes to the first,
35
- Previous at the first to the last, and neither arrow is ever
36
- disabled. No slide is cloned, so a screen reader reads each once.
36
+ loop true wraps the row, and a row of more than one slide is
37
+ visually continuous: the controller copies the last slides
38
+ before the first and the first after the last, so the first
39
+ slide centred shows the last beside it. Next at the last slide
40
+ glides on to the first, Previous at the first back to the last,
41
+ then the row jumps silently onto the real slide (no glide under
42
+ reduced motion), and neither arrow is ever disabled. A copy is
43
+ aria-hidden and inert, with no id, name, test hook or controller,
44
+ so a screen reader and Tab meet each slide once, a #fragment
45
+ finds the real slide, and the dots and "slide N of M" count the
46
+ real slides. Every slide of a looping row is a stop.
37
47
  autoplay seconds between moves (a number above 0); nil, the default, is
38
48
  off. The row moves one slide each interval and holds still while
39
49
  the pointer is over the slides, while keyboard focus is in it,
@@ -75,7 +85,11 @@
75
85
  when "spotlight" then true
76
86
  else raise ArgumentError, "studio/carousel style: #{style.inspect} is not :standard or :spotlight"
77
87
  end
78
- start = spotlight ? :middle : 0 if start.nil?
88
+ loop = loop ? true : false
89
+ wraps = loop && total > 1
90
+ # A looping spotlight has a left neighbour at its first slide (the clones), so it
91
+ # opens there; one that does not loop opens mid-row so neither half is empty.
92
+ start = spotlight && !wraps ? :middle : 0 if start.nil?
79
93
  start_index = case start
80
94
  when :middle, "middle" then total / 2
81
95
  when Integer then start.clamp(0, [total - 1, 0].max)
@@ -86,8 +100,6 @@
86
100
  raise ArgumentError, "studio/carousel autoplay: #{autoplay.inspect} is not a number of seconds above 0 or nil"
87
101
  end
88
102
  autoplay = autoplay.to_i if autoplay && autoplay == autoplay.floor
89
- loop = loop ? true : false
90
- wraps = loop && total > 1
91
103
  arrow_noun = spotlight ? "slide" : "slides"
92
104
  track_id = id.presence || "carousel-#{label.to_s.parameterize.presence || 'slides'}"
93
105
  %>
@@ -0,0 +1,30 @@
1
+ <%# locals: (app_name:, home_path:, logo: nil, back_path: nil, heading: :h1) %>
2
+ <%# THE NOT-FOUND PAGE: what a lost visitor sees on an unknown address or a
3
+ record that is not there (docs/NOT_FOUND_PAGE.md). Studio::NotFoundController
4
+ renders it inside the app's own layout, from the app's Studio config:
5
+
6
+ app_name Studio.app_name: named in the button home.
7
+ home_path the app's root: the primary button.
8
+ logo the app's brand mark (Studio.navbar_brand's logo, else the
9
+ Navbar Logo theme image). nil draws no image.
10
+ back_path a page on this app the visitor came from: a second link back.
11
+ nil draws no second link.
12
+ heading the title's tag: :h1 on the page, a lower level where the page
13
+ is shown inside another (the style guide's card).
14
+
15
+ Theme tokens only, so it reads in light and dark. The static last resort,
16
+ public/404.html, carries the same words and the same data-test hooks. %>
17
+ <section class="mx-auto w-full max-w-md px-4 py-16 sm:py-24 text-center" data-studio-not-found data-test="not-found" aria-labelledby="studio-not-found-title">
18
+ <% if logo.present? %>
19
+ <img src="<%= logo %>" alt="<%= app_name %>" width="64" height="64" class="mx-auto mb-6 h-16 w-16 object-contain" data-studio-not-found-logo>
20
+ <% end %>
21
+ <p class="type-fine font-semibold uppercase tracking-wide text-primary-ink mb-2">404 &middot; Not found</p>
22
+ <%= content_tag heading, "This page isn’t here", id: "studio-not-found-title", class: "type-h2 text-heading mb-3" %>
23
+ <p class="type-body text-secondary mb-8">The address may be mistyped, or the page may have moved. Head back home and pick up from there.</p>
24
+ <div class="flex flex-col sm:flex-row items-center justify-center gap-3">
25
+ <a href="<%= home_path %>" class="btn btn-primary w-full sm:w-auto" data-test="not-found-home">Back to <%= app_name %></a>
26
+ <% if back_path.present? %>
27
+ <a href="<%= back_path %>" class="btn btn-outline w-full sm:w-auto" data-test="not-found-back">Go back</a>
28
+ <% end %>
29
+ </div>
30
+ </section>
@@ -0,0 +1,5 @@
1
+ <%# Studio::NotFoundController#show: the not-found page in the app's layout. %>
2
+ <% content_for(:title, "Page not found · #{Studio.app_name}") %>
3
+ <%# A 404's og:url is the app's home, never the broken address it was asked for. %>
4
+ <% link_preview(url: @studio_not_found[:home_path]) %>
5
+ <%= render "studio/not_found", **@studio_not_found %>
@@ -81,9 +81,32 @@
81
81
  .ftr-kit { margin: .75rem 0 0; }
82
82
  .ftr-socials { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; margin: 2rem 0 0; padding: 0; list-style: none; }
83
83
  .ftr-social { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem;
84
- border-radius: 9999px; background: var(--ftr-primary); color: #fff; transition: transform .15s ease, filter .15s ease; }
85
- a.ftr-social:hover { transform: translateY(-2px); filter: brightness(1.12); }
86
- .ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; }
84
+ border-radius: 9999px; background: var(--ftr-primary); color: #fff;
85
+ transition: transform .15s ease, filter .15s ease, box-shadow .15s ease; }
86
+ /* LIT, LIKE THE BUTTONS, IN EVERY APP. A linked disc wears the glass lit button's
87
+ family (engine.css, html[data-surface-style="glass"] .btn-primary): a top
88
+ highlight, a bottom edge, a contact shadow and a soft glow in its own fill.
89
+ Each light reads the button's --lit-* token, which only a glass page defines,
90
+ and falls back to the button's own value, so a flat-surface app shades the
91
+ same way. The gradient runs from the fill DOWN only (never lighter than
92
+ --ftr-primary), so the white glyph never loses contrast; a browser without
93
+ relative oklch keeps the flat fill and the shadows. The glow is drawn from
94
+ --ftr-primary-rgb, which every browser reads. */
95
+ .ftr { --ftr-lit-top: var(--lit-top, inset 0 1px 0 rgb(255 255 255 / .35));
96
+ --ftr-lit-bottom: var(--lit-bottom, inset 0 -2px 0 rgb(0 0 0 / .2));
97
+ --ftr-lit-contact: var(--lit-contact, 0 1px 2px rgb(0 0 0 / .4)); --ftr-lit-glow-alpha: var(--lit-glow-alpha, .6); }
98
+ .dark .ftr { --ftr-lit-bottom: var(--lit-bottom, inset 0 -2px 0 rgb(0 0 0 / .3));
99
+ --ftr-lit-contact: var(--lit-contact, 0 1px 2px rgb(0 0 0 / .65)); --ftr-lit-glow-alpha: var(--lit-glow-alpha, .8); }
100
+ a.ftr-social { background-image: linear-gradient(var(--ftr-primary), oklch(from var(--ftr-primary) calc(l - .08) c h));
101
+ box-shadow: var(--ftr-lit-top), var(--ftr-lit-bottom), var(--ftr-lit-contact),
102
+ 0 4px 12px -4px rgb(var(--ftr-primary-rgb) / var(--ftr-lit-glow-alpha)); }
103
+ /* Hover lifts and deepens, as the lit button darkens (brightness .94): never
104
+ brighter, which would fade the white glyph. */
105
+ a.ftr-social:hover { transform: translateY(-2px); filter: brightness(.94);
106
+ box-shadow: var(--ftr-lit-top), var(--ftr-lit-bottom), var(--ftr-lit-contact),
107
+ 0 8px 16px -6px rgb(var(--ftr-primary-rgb) / var(--ftr-lit-glow-alpha)); }
108
+ /* A pending handle stays a flat dashed outline: no light, no shadow. */
109
+ .ftr-social[data-pending] { background: transparent; color: inherit; opacity: .5; border: 1px dashed currentColor; box-shadow: none; }
87
110
  .ftr-social-letter { font-weight: 700; font-size: .95rem; line-height: 1; }
88
111
  .ftr-heading { margin: 0 0 1rem; font-size: 1rem; font-weight: 700; color: var(--ftr-ink); }
89
112
  .ftr-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
@@ -0,0 +1,19 @@
1
+ <%# The Pages group of the Tricks board (admin/style): whole pages an app
2
+ adopts with one line. Not Found is the friendly 404 (docs/NOT_FOUND_PAGE.md),
3
+ drawn here with THIS app's name and brand mark; the Go back link shows as it
4
+ does when the visitor came from a page on this app. %>
5
+ <section id="pages" class="section-subtarget space-y-5">
6
+ <div class="space-y-1">
7
+ <h3 class="type-h4 text-heading">Pages</h3>
8
+ <p class="text-muted text-sm">Whole pages an app adopts with one line in config/application.rb.</p>
9
+ </div>
10
+
11
+ <div class="grid gap-4 sm:grid-cols-2">
12
+ <%= render layout: "style/specimen", locals: { trick: "not_found" } do %>
13
+ <div class="card w-full">
14
+ <%= render "studio/not_found", app_name: Studio.app_name, logo: Studio.navbar_brand&.dig(:logo) || Studio.logo_for("Navbar Logo"),
15
+ home_path: "/", back_path: "#pages", heading: :h4 %>
16
+ </div>
17
+ <% end %>
18
+ </div>
19
+ </section>
@@ -527,4 +527,7 @@
527
527
 
528
528
  <!-- Site footer and booking -->
529
529
  <%= render "style/site_footer" %>
530
+
531
+ <!-- Pages -->
532
+ <%= render "style/pages" %>
530
533
  </section>
@@ -462,6 +462,13 @@ booking_frame:
462
462
  note: "Cropped to the slot picker at rest; bin/booking-crop-measure prints yours."
463
463
  resolve: [config:booking_crop]
464
464
 
465
+ not_found:
466
+ name: Not Found
467
+ group: Pages
468
+ token: "config.exceptions_app = Studio::ExceptionsApp.new"
469
+ note: "A lost visitor's 404 page, with the app's brand and a button home."
470
+ resolve: [partial:studio/not_found]
471
+
465
472
  board:
466
473
  name: Board
467
474
  group: Tasks
data/docs/SITE_FOOTER.md CHANGED
@@ -242,8 +242,9 @@ Rows may be tuples, as above, or hashes (`{ label:, href: }`, `{ heading:, links
242
242
  from 0.5 to 4; anything else is ignored. Without one, the first column is 1.5
243
243
  and the rest are 1 (the brand, from 1024px, is 1.7). Below 768px the two tracks
244
244
  are fixed at 1.2 and 1, and with more than four columns the hints are not used.
245
+ - **A linked social icon is lit like the buttons**, in every app, light and dark: a top highlight, an edge and a soft shadow in `--color-primary`.
245
246
  - **A social profile with a `nil` URL** renders its icon unlinked, with a dashed
246
- outline. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
247
+ outline and no shading. The engine draws `:linkedin`, `:instagram`, `:x`, `:facebook` and
247
248
  `:youtube`; any other icon falls back to the label's first letter.
248
249
 
249
250
  ### The address and the map
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module Studio
6
+ # The friendly not-found page, as an exceptions app. An app opts in with one
7
+ # line in config/application.rb (docs/NOT_FOUND_PAGE.md):
8
+ #
9
+ # config.exceptions_app = Studio::ExceptionsApp.new
10
+ #
11
+ # Rails hands this app every exception ShowExceptions renders, with PATH_INFO
12
+ # rewritten to "/<status>" and the method set to GET. That is the path a
13
+ # RoutingError takes (an unknown address) and the path a RecordNotFound takes
14
+ # (Studio::ErrorHandling#handle_not_found re-raises on purpose so it lands here).
15
+ #
16
+ # What it answers:
17
+ #
18
+ # 404, an HTML request Studio::NotFoundController#show: the app's name and
19
+ # brand mark, a short message and a button home,
20
+ # inside the app's own layout. Status 404.
21
+ # 404, a data request ActionDispatch::PublicExceptions, unchanged: a JSON
22
+ # request gets {"status":404,"error":"Not Found"} with
23
+ # status 404, never the HTML page.
24
+ # any other status ActionDispatch::PublicExceptions, unchanged (500,
25
+ # 422 and the rest keep public/<status>.html).
26
+ #
27
+ # IT CANNOT LOOP AND IT CANNOT CHANGE THE STATUS. The page renders through the
28
+ # app's ApplicationController, so the app's before_actions, layout and database
29
+ # all run. If any of them raises, or answers anything but a 404 (a sign-in
30
+ # redirect from a wall that does not list the page, a 406 from allow_browser),
31
+ # the answer is thrown away and PublicExceptions serves public/404.html, the
32
+ # static last resort, with status 404. PublicExceptions only reads a file, so
33
+ # the fallback has nothing left that can fail into this app again.
34
+ #
35
+ # Which requests are HTML is PublicExceptions' own rule, so the split never
36
+ # drifts from Rails': a format whose body PublicExceptions can serialize (JSON,
37
+ # XML) is data, and everything else, `Accept: */*` included, is the page.
38
+ class ExceptionsApp
39
+ NOT_FOUND = 404
40
+ PAGE_PARAMS = { controller: "studio/not_found", action: "show" }.freeze
41
+ # Cached by the failed request; the page's request must not read them.
42
+ STALE_KEYS = %w[action_dispatch.request.path_parameters
43
+ action_dispatch.request.parameters
44
+ action_dispatch.request.query_parameters
45
+ action_dispatch.request.request_parameters
46
+ action_controller.instance].freeze
47
+
48
+ # public_path: where the static pages live. Defaults to Rails.public_path,
49
+ # read on each call, so the line works before the app finishes booting.
50
+ # page: the Rack endpoint that draws the page (a seam for tests).
51
+ def initialize(public_path = nil, page: nil)
52
+ @public_path = public_path
53
+ @page = page
54
+ end
55
+
56
+ def call(env)
57
+ return static(env) unless page_request?(env)
58
+
59
+ response = draw_page(env)
60
+ return response if response
61
+
62
+ static(env)
63
+ end
64
+
65
+ private
66
+
67
+ def page_request?(env)
68
+ request = ActionDispatch::Request.new(env)
69
+ return false unless request.path_info.delete_prefix("/").to_i == NOT_FOUND
70
+
71
+ html?(request)
72
+ end
73
+
74
+ # PublicExceptions renders a body in a format the error hash serializes to
75
+ # (to_json, to_xml) and the HTML file for anything else.
76
+ def html?(request)
77
+ content_type = begin
78
+ request.formats.first
79
+ rescue ActionDispatch::Http::MimeNegotiation::InvalidType
80
+ Mime[:text]
81
+ end
82
+ return true unless content_type
83
+
84
+ !{}.respond_to?("to_#{content_type.to_sym}")
85
+ end
86
+
87
+ # The page's answer, or nil when it raised or came back as anything but a
88
+ # 404 HTML page.
89
+ def draw_page(env)
90
+ page_env = env.except(*STALE_KEYS)
91
+ page_env["action_dispatch.request.path_parameters"] = PAGE_PARAMS.dup
92
+ status, headers, body = page.call(page_env)
93
+ return [status, headers, body] if status.to_i == NOT_FOUND && html_body?(headers)
94
+
95
+ body.close if body.respond_to?(:close)
96
+ log("answered #{status}, not 404")
97
+ nil
98
+ rescue StandardError, ScriptError => e
99
+ log("#{e.class}: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ def html_body?(headers)
104
+ type = headers["content-type"] || headers["Content-Type"]
105
+ type.to_s.start_with?("text/html")
106
+ end
107
+
108
+ def page
109
+ @page || Studio::NotFoundController.action(:show)
110
+ end
111
+
112
+ def static(env)
113
+ ActionDispatch::PublicExceptions.new(public_path).call(env)
114
+ end
115
+
116
+ def public_path
117
+ (@public_path || Rails.public_path).to_s
118
+ end
119
+
120
+ def log(reason)
121
+ Rails.logger&.warn("[studio.not_found] the page fell back to public/404.html: #{reason}")
122
+ end
123
+ end
124
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.133.2"
2
+ VERSION = "0.135.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -80,6 +80,7 @@ require "studio/survey/breakdown"
80
80
  require "studio/survey/export"
81
81
  require "studio/crumbs"
82
82
  require "studio/page_header"
83
+ require "studio/exceptions_app"
83
84
 
84
85
  module Studio
85
86
  mattr_accessor :app_name, default: "Studio"
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.133.2
4
+ version: 0.135.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -470,6 +470,7 @@ files:
470
470
  - app/controllers/studio/local_reviews_controller.rb
471
471
  - app/controllers/studio/marketing_kit_controller.rb
472
472
  - app/controllers/studio/models_controller.rb
473
+ - app/controllers/studio/not_found_controller.rb
473
474
  - app/controllers/studio/onboarding_controller.rb
474
475
  - app/controllers/studio/profiles_controller.rb
475
476
  - app/controllers/studio/public_users_controller.rb
@@ -656,6 +657,7 @@ files:
656
657
  - app/views/studio/_hold_button.html.erb
657
658
  - app/views/studio/_hold_button_guard.html.erb
658
659
  - app/views/studio/_leveling_activity_assets.html.erb
660
+ - app/views/studio/_not_found.html.erb
659
661
  - app/views/studio/_page_header.html.erb
660
662
  - app/views/studio/_section_header.html.erb
661
663
  - app/views/studio/_session_stamp.html.erb
@@ -771,6 +773,7 @@ files:
771
773
  - app/views/studio/models/show.html.erb
772
774
  - app/views/studio/newsletter_mailer/subscribed.html.erb
773
775
  - app/views/studio/newsletter_mailer/subscribed.text.erb
776
+ - app/views/studio/not_found/show.html.erb
774
777
  - app/views/studio/profile_mailer/email_change_notification.html.erb
775
778
  - app/views/studio/profile_mailer/email_change_notification.text.erb
776
779
  - app/views/studio/profiles/_birthday_fields.html.erb
@@ -817,6 +820,7 @@ files:
817
820
  - app/views/style/_loaders_feed_page.html.erb
818
821
  - app/views/style/_modal_specimen.html.erb
819
822
  - app/views/style/_modals.html.erb
823
+ - app/views/style/_pages.html.erb
820
824
  - app/views/style/_scrims.html.erb
821
825
  - app/views/style/_site_footer.html.erb
822
826
  - app/views/style/_specimen.html.erb
@@ -884,6 +888,7 @@ files:
884
888
  - lib/studio/email_smoke.rb
885
889
  - lib/studio/engine.rb
886
890
  - lib/studio/environment_banner.rb
891
+ - lib/studio/exceptions_app.rb
887
892
  - lib/studio/geo.rb
888
893
  - lib/studio/geo/countries.rb
889
894
  - lib/studio/geo/lookup.rb