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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +15 -6
- data/app/assets/tailwind/studio_engine/engine.css +9 -0
- data/app/controllers/studio/not_found_controller.rb +54 -0
- data/app/helpers/studio/link_preview_helper.rb +16 -3
- data/app/javascript/studio/carousel.js +76 -0
- data/app/javascript/studio/controllers/carousel_controller.js +162 -17
- data/app/views/studio/_carousel.html.erb +21 -9
- data/app/views/studio/_not_found.html.erb +30 -0
- data/app/views/studio/not_found/show.html.erb +5 -0
- data/app/views/studio/site_footer/_assets.html.erb +26 -3
- data/app/views/style/_pages.html.erb +19 -0
- data/app/views/style/_tricks.html.erb +3 -0
- data/config/studio_tricks.yml +7 -0
- data/docs/SITE_FOOTER.md +2 -1
- data/lib/studio/exceptions_app.rb +124 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +1 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b8bd497e5ba5d6c113a0e6c221b03200f395b1c3d4032aa1327691b7c584c34
|
|
4
|
+
data.tar.gz: 0a64eb7f3a70bcbe03a50550d36a1d8da938498d91d5cf0a1fcb257845a76759
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
347
|
-
on its middle slide by default, so neither half of the row is empty at
|
|
348
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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)
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// the
|
|
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,
|
|
44
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
so both halves of the row hold a slide at
|
|
30
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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 · 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;
|
|
85
|
-
|
|
86
|
-
|
|
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>
|
data/config/studio_tricks.yml
CHANGED
|
@@ -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
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
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.
|
|
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
|