studio-engine 0.123.0 → 0.125.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +16 -1
  4. data/app/assets/javascripts/studio/page_transition_reveal.js +75 -0
  5. data/app/assets/stylesheets/studio/page_transition_full_load.css +10 -0
  6. data/app/assets/tailwind/studio_engine/engine.css +216 -11
  7. data/app/controllers/studio/marketing_kit_controller.rb +24 -0
  8. data/app/helpers/studio/site_footer_helper.rb +15 -2
  9. data/app/javascript/studio/controllers/copy_text_controller.js +63 -0
  10. data/app/javascript/studio/controllers/page_header_controller.js +60 -0
  11. data/app/javascript/studio/page_header.js +24 -0
  12. data/app/javascript/studio/page_transitions.js +263 -0
  13. data/app/javascript/studio/pinned_stack.js +12 -1
  14. data/app/javascript/studio/stimulus.js +6 -1
  15. data/app/views/components/_link_sidebar.html.erb +6 -3
  16. data/app/views/components/_user_nav.html.erb +5 -4
  17. data/app/views/layouts/_navbar.html.erb +7 -4
  18. data/app/views/layouts/studio/_head.html.erb +17 -2
  19. data/app/views/layouts/studio/_smooth_load.html.erb +11 -2
  20. data/app/views/studio/_page_header.html.erb +65 -0
  21. data/app/views/studio/admin_models/show.html.erb +10 -18
  22. data/app/views/studio/admin_surveys/index.html.erb +2 -2
  23. data/app/views/studio/agent_chat/_form.html.erb +1 -1
  24. data/app/views/studio/agent_chat/_header.html.erb +1 -1
  25. data/app/views/studio/marketing_kit/_assets.html.erb +44 -0
  26. data/app/views/studio/marketing_kit/_kit.html.erb +84 -0
  27. data/app/views/studio/marketing_kit/show.html.erb +6 -0
  28. data/app/views/studio/site_footer/_assets.html.erb +1 -0
  29. data/app/views/studio/site_footer/_footer.html.erb +5 -0
  30. data/app/views/style/_tricks.html.erb +35 -0
  31. data/app/views/style/index.html.erb +17 -28
  32. data/docs/SITE_FOOTER.md +113 -2
  33. data/lib/studio/engine.rb +5 -2
  34. data/lib/studio/marketing_kit.rb +304 -0
  35. data/lib/studio/site_footer.rb +22 -6
  36. data/lib/studio/version.rb +1 -1
  37. data/lib/studio.rb +83 -1
  38. metadata +13 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b205f173a74b9bb8df878abf6b1e991cdc68b4a24d149ee0dbf5ea98d5d49ead
4
- data.tar.gz: 01b80bee0be70951eb51e4e151639ab65c096972374b12ef5d2941b58411d2c0
3
+ metadata.gz: 13bb9cfe1f6491a4dd2a199c16f734f466edad8fb38beae903033305e8fd91da
4
+ data.tar.gz: 20d1fec190a9bfe2391e4e4121f2fa11dc35729abd54a8df47e0a19346611715
5
5
  SHA512:
6
- metadata.gz: 94aae578a81075f61b21504530ef3998b0b5db37b90876f7038445e3a90938645130833c7ce9c163ec247870843afc99a4e1d24d59deeeff27db1bf6bbcbb428
7
- data.tar.gz: 13d649f6aa168b162fa6b18b6c098cd97e5c2b385964337f127f09de9f323773803c22d363ee7c9b159801b894e95c9e13d7385fec2ec83133ac4c0191e0af88
6
+ metadata.gz: 3687dd8d85fffecbb4c1ab84177bcdb9dee0bfd0aa87dac7dbad61a81a4921d920f9c2f1cda8cfc3bff378c4a12d3528ab015e14085b447204c745e69c84155e
7
+ data.tar.gz: 2d135f12456ce99328d5422df362a83271cd9075b9f618c80b0cf3053edccb3a383164a0c23fdc6cced5e5e4514467e90b637d5beb976f052b2b742cfd7a8b18
data/CHANGELOG.md CHANGED
@@ -4,6 +4,101 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.125.0 — 2026-11-04
8
+
9
+ ### Added
10
+
11
+ - **Page transitions across full page loads.** With `Studio.page_transition_direction` set,
12
+ a same-origin full load plays the app's direction through the browser's cross-document
13
+ view transition: the same pair of styles, keyframes and timing as a Turbo visit, with the
14
+ navbar and banners held still. This reaches strict-policy pages, which must stay their
15
+ own documents. New files, linked only with a direction: `studio/page_transition_full_load.css`
16
+ and the classic `studio/page_transition_reveal.js`, which carries the request's nonce. A
17
+ reload, a same-path load, reduced motion and a browser without support load plainly. A full
18
+ load has no morph, and it cannot wait for images, so the 500ms image cap does not apply.
19
+ With no direction, a host renders no new tag.
20
+ [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
21
+
22
+ ### Fixed
23
+
24
+ - **A Turbo visit answered by `turbo-visit-control: reload` no longer plays the engine's fade**
25
+ before the full load. Turbo opens its view transition before it reads the meta, so that
26
+ transition now draws nothing.
27
+ - **The navbar no longer draws twice during a page transition** when the old header is taller
28
+ than the new one, as on Back to a scrolled page. This changes Turbo visits too: only the new
29
+ navbar draws.
30
+
31
+ - Link sidebar: a one-line row centers its icon on its label. It sat 3px low. A row with a
32
+ description keeps its icon at the top. The navbar baseline is regenerated for it.
33
+ - Page transitions: an advance visit to the same path keeps a shared `data-vt-morph` name, so
34
+ a launch panel still morphs into the chat at its own address. A refresh stays silent.
35
+ - Page transitions: a GET that redirects moves the page once. It played the move twice, because
36
+ Turbo renders the response and then visits the final address with that same response.
37
+
38
+ ## 0.124.0 — 2026-11-04
39
+
40
+ ### Added
41
+
42
+ - **App-to-app page transitions: `Studio.page_transition_direction`** (`:left`, `:right`,
43
+ `:top`, `:bottom`, default nil). Each app moves its pages one way, and each page change
44
+ draws that direction's fade or unfold at random, at the Codrops demo's values. It is the
45
+ one switch: a set direction turns smooth-load on, metas and navbar pin included. The
46
+ navbar and banner stack hold still; eager images load before the swap, capped at 500ms;
47
+ a same-page visit, reduced motion and a browser without view transitions play nothing.
48
+ With nil, the HTML is unchanged except one more import map entry that nothing fetches.
49
+ New: `studio/page_transitions`, loaded by its own tag only with the setting on, and the
50
+ `data-vt-morph` marker for a named element (the `agent_chat` portrait and composer carry
51
+ it; a host marks its own when it sets a direction). No host changes here. The doc holds
52
+ a proposed assignment. [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
53
+ - **The marketing kit: every app has a public `/marketing-kit`, and its footer links
54
+ it, with no setup.** `Studio.routes` draws the page (`Studio.draw_marketing_kit_routes`,
55
+ default true; helper `studio_marketing_kit_path`) with no sign-in, and a host's own
56
+ route of that path wins from either side of `Studio.routes`. `Studio.marketing_kit`
57
+ (a Hash or a callable taking the view) declares `logos`, `palette`, `typefaces` and
58
+ `tagline`; unset, the logos derive from `Studio.navbar_wordmark`, the footer's lockup
59
+ and `Studio.theme_logos`, and the colours from what the app paints. Logos read "Download Light" and "Download Dark", the PNG when
60
+ there is one, else the SVG; a missing file shows no link and no broken image. One
61
+ click on a colour copies its hex and confirms (new: the lazy `copy-text` controller).
62
+ An app with no database can draw the page alone with `Studio.marketing_kit_routes(self)`.
63
+ - **The footer's Marketing Kit link**, in the brand block (`.ftr-kit`), on every app
64
+ with the page, signed in or out. An app that links the page in a column or on the
65
+ legal line gets no second link, and `marketing_kit: false` in the facts shows none.
66
+ Its columns and legal line are unchanged. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md),
67
+ *The marketing kit*.
68
+ - **`studio/_page_header`, and the triple stack as a standard.** A page title,
69
+ optional meta beside it and optional tabs on the right. At rest it has no
70
+ background and no border; once stuck under the navbar it takes the bar. It joins
71
+ the pinned stack (`data-pin="page"`), so a sticky table header sits under it:
72
+ navbar, page row, table header, with no overlap. `/admin/style`, `/admin/models`
73
+ and `/admin/surveys` use it.
74
+ [`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md) carries the rule and the locals. New:
75
+ `studio/page_header` and the lazy `page-header` controller.
76
+
77
+ ### Changed
78
+
79
+ - **A signed-out visitor's link sidebar has no "Links" title.** Signed-in and admin titles
80
+ are unchanged, and the panel keeps its accessible name.
81
+
82
+ ### Fixed
83
+
84
+ - **A tap no longer leaves the navbar's account group lit.** On a touch screen a tap
85
+ leaves `:hover` on what was tapped, so the gear, the name and the avatar (THE ACCOUNT
86
+ GROUP in `engine.css`) stayed lit after the sidebar closed. Every `:hover` of the
87
+ group now sits inside `@media (hover: hover)`; `:focus-visible` stays outside it, so
88
+ a keyboard still lights the group. A mouse sees no change.
89
+ - **The theme toggle draws left of the gear at every width, signed in or out.** The
90
+ signed-in desktop rail already did. The signed-out rail (`layouts/_navbar` and the
91
+ signed-out branch of `components/_user_nav`) and the phone row drew the gear first.
92
+ The navbar baseline (`test/fixtures/files/navbar_unset_baseline`) is regenerated for
93
+ this order: 14 of its 19 files move, and only the order of their lines changes. A
94
+ host that draws its own pair keeps its own order: the hub's
95
+ `layouts/_navbar_phone_account` still draws gear, then toggle.
96
+ - **A table's sticky header no longer sits on top of the page row on `/admin/models`.**
97
+ - **The open tab on `/admin/models` is scrolled into view at phone width.**
98
+ - **`studio/pinned_stack` places a header with no `data-pin` at its place in the
99
+ document.** It was appended, so it read as below every other layer, and a layer
100
+ under it composed a top of 0.
101
+
7
102
  ## 0.123.0 — 2026-11-03
8
103
 
9
104
  ### Added
data/README.md CHANGED
@@ -41,6 +41,7 @@ resolved.
41
41
  - **Geo**: `Studio::GeoDetection` places every visitor (IP → country + subdivision, session-cached), `Studio::GeoSetting` stores the operator's blocked countries and regions, `require_geo_allowed` locks whichever surfaces an app chooses, and the shared badge + `/admin/geo` manager ship with it. See [`docs/GEO.md`](docs/GEO.md).
42
42
  - **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
43
43
  - **Branded loader**: `studio_loader`, `studio_loader_pending` and `studio_loader_overlay` draw the app's own icon in its theme colours for page loads, long waits and pending buttons, in the style `config.loader` names (Breathe by default). See [`docs/LOADER.md`](docs/LOADER.md).
44
+ - **App-to-app page transitions**: `config.page_transition_direction` (`:left`, `:right`, `:top`, `:bottom`) moves an app's pages one way on every page change, full page loads included, with the navbar and banners held still. It turns smooth-load on. See [`docs/PAGE_TRANSITIONS.md`](docs/PAGE_TRANSITIONS.md).
44
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).
45
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).
46
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).
@@ -169,6 +170,7 @@ Studio.configure do |config|
169
170
  # multi-second pages BEFORE opting in — no-preview holds the old page until
170
171
  # the fresh response arrives.
171
172
  config.smooth_load = true
173
+ # config.page_transition_direction = :left # the app's page move; also turns smooth-load on
172
174
  # Nav spinner minimum display (default 2500). Smooth-load apps typically
173
175
  # drop to ~300; keep the high floor if multi-second ops ride the spinner.
174
176
  config.nav_spinner_min_ms = 300
@@ -285,6 +287,17 @@ actions; the engine supplies only the banner.
285
287
 
286
288
  ## UI Primitives
287
289
 
290
+ ### The page header — `studio/_page_header`
291
+
292
+ An admin page's title: a plain header at rest, a bar once stuck under the navbar,
293
+ in the pinned stack so a table's sticky header sits under it
294
+ ([`docs/PAGE_HEADER.md`](docs/PAGE_HEADER.md)).
295
+
296
+ ```erb
297
+ <%= render "studio/page_header", title: "Models", meta: "v1.2.3", label: "Models",
298
+ tabs: [{ label: "Users", href: users_path, current: "page" }, { label: "Teams", href: teams_path }] %>
299
+ ```
300
+
288
301
  ### The "at" time stamp — `at_time_tag`
289
302
 
290
303
  Stamps WHEN something happened, on the reader's own clock: `at 3:53p`, gaining a
@@ -1159,7 +1172,9 @@ pointing at or focusing any of the gear, the name or the avatar lights all three
1159
1172
  (the primary colour, a turn of the gear, a primary glow on the avatar). The
1160
1173
  same holds for a host sidebar: a host that sets both `navbar_menu_trigger` and
1161
1174
  `navbar_account_sidebar` gets its gear and account lit as one menu. An account
1162
- link is never grouped (`engine.css`, *THE ACCOUNT GROUP*).
1175
+ link is never grouped (`engine.css`, *THE ACCOUNT GROUP*). Pointing needs a
1176
+ pointer: the hover half sits inside `@media (hover: hover)`, so a tap on a touch
1177
+ screen lights nothing, and a keyboard still does.
1163
1178
 
1164
1179
  ## Transactional emails
1165
1180
 
@@ -0,0 +1,75 @@
1
+ // studio/page_transition_reveal: the arriving half of a full-load page transition
2
+ // (docs/PAGE_TRANSITIONS.md, "Full page loads"). Loaded only with
3
+ // Studio.page_transition_direction set.
4
+ //
5
+ // CLASSIC AND SYNCHRONOUS ON PURPOSE. pagereveal fires before the first render, and a listener
6
+ // bound after that misses it for good. A module is deferred, so it can be too late.
7
+ //
8
+ // It holds no table of styles. The leaving page drew the style with studio/page_transitions
9
+ // at pageswap and left it here. No style means the leaving page did not plan this one (its
10
+ // module had not run, or it was the same path), so the load is plain, not the browser's fade.
11
+ (function () {
12
+ var STYLE_KEY = "studio-page-transition-style"
13
+ var HOLD_CAP_MS = 3000
14
+
15
+ // Holds the first render to the end of parsing, on an arrival only (nothing has this id). A
16
+ // render of half a body would give the new picture no navbar or stack to hold still. The
17
+ // browser sets no limit on this hold, so the timer does: a slow body paints at the cap. The
18
+ // cap sits inside the browser's own limit on the transition, so the motion still plays.
19
+ function holdForTheWholePage() {
20
+ try {
21
+ if (!window.sessionStorage.getItem(STYLE_KEY)) return
22
+ } catch (_error) {
23
+ return
24
+ }
25
+ var link = document.createElement("link")
26
+ link.rel = "expect"
27
+ link.href = "#studio-page-transition-end-of-parse"
28
+ link.setAttribute("blocking", "render")
29
+ document.head.appendChild(link)
30
+ window.setTimeout(function () { link.remove() }, HOLD_CAP_MS)
31
+ }
32
+
33
+ function takeStyle() {
34
+ try {
35
+ var style = window.sessionStorage.getItem(STYLE_KEY)
36
+ window.sessionStorage.removeItem(STYLE_KEY)
37
+ return style
38
+ } catch (_error) {
39
+ return null
40
+ }
41
+ }
42
+
43
+ holdForTheWholePage()
44
+
45
+ window.addEventListener("pagereveal", function (event) {
46
+ var root = document.documentElement
47
+ // A page back from the back/forward cache still wears what it had on when it left.
48
+ delete root.dataset.pageTransition
49
+ delete root.dataset.pageTransitionVisit
50
+
51
+ var style = takeStyle()
52
+ var transition = event.viewTransition
53
+ if (!transition) return
54
+ var direction = document.querySelector('meta[name="page-transition-direction"]')
55
+ if (!style || !direction) {
56
+ transition.skipTransition()
57
+ return
58
+ }
59
+
60
+ // engine.css reads these three. The new page's picture comes after this listener, so the
61
+ // held stack and every morph name are settled in time.
62
+ root.dataset.pageTransitionDirection = direction.content
63
+ root.dataset.pageTransition = style
64
+ root.dataset.pageTransitionVisit = ""
65
+ var stack = document.querySelector("[data-vt-held], .studio-bar-stack")
66
+ if (stack) stack.setAttribute("data-vt-held", "")
67
+
68
+ var done = function () {
69
+ if (root.dataset.pageTransition !== style) return
70
+ delete root.dataset.pageTransition
71
+ delete root.dataset.pageTransitionVisit
72
+ }
73
+ transition.finished.then(done, done)
74
+ })
75
+ })()
@@ -0,0 +1,10 @@
1
+ /* Opts a document in to the browser's cross-document view transition. A separate file,
2
+ because this at-rule takes no selector: in the engine's bundle it would act on every
3
+ host. layouts/studio/_head links it only with Studio.page_transition_direction set.
4
+ Both documents must carry it. Reduced motion gets no transition at all.
5
+ docs/PAGE_TRANSITIONS.md, "Full page loads". */
6
+ @media (prefers-reduced-motion: no-preference) {
7
+ @view-transition {
8
+ navigation: auto;
9
+ }
10
+ }
@@ -223,6 +223,93 @@ body.modal-open .studio-app-banner {
223
223
  line-height: 1.4;
224
224
  }
225
225
 
226
+ /* -- The page header (studio/_page_header, docs/PAGE_HEADER.md) ---------
227
+ A normal page header at rest and a bar once stuck. The border is there in
228
+ both states, in transparent at rest, so the row's height never changes and
229
+ the pinned stack has nothing to republish. The layout is owned here: the
230
+ engine's views depend on no responsive utility a host may not have emitted.
231
+ The fade is owned too, because .fade-edge-x is in the opt-in motion layer, and
232
+ it marks only an end that hides a tab (studio/controllers/page_header). */
233
+
234
+ @utility page-header {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 0.75rem;
238
+ padding-block: 0.5rem;
239
+ border-block: 1px solid transparent;
240
+ background-color: transparent;
241
+ transition: background-color 150ms ease, border-color 150ms ease;
242
+
243
+ &[data-stuck],
244
+ [data-studio-controller-failed~="page-header"] + & {
245
+ @apply bg-surface border-subtle;
246
+ }
247
+ }
248
+
249
+ @utility page-header-sentinel {
250
+ position: absolute;
251
+ width: 0;
252
+ height: 0;
253
+ }
254
+
255
+ @utility page-header-lead {
256
+ display: flex;
257
+ align-items: baseline;
258
+ gap: 0.5rem;
259
+ flex-shrink: 0;
260
+ min-width: 0;
261
+ }
262
+
263
+ @utility page-header-title {
264
+ @apply text-heading;
265
+ font-size: calc(var(--type-base, 16px) * 1.5);
266
+ font-weight: var(--type-display-weight, 800);
267
+ line-height: 1.25;
268
+ }
269
+
270
+ @utility page-header-meta {
271
+ white-space: nowrap;
272
+
273
+ @media (width < 40rem) {
274
+ display: none;
275
+ }
276
+ }
277
+
278
+ @utility page-header-tabs {
279
+ --page-header-fade-start: 0px;
280
+ --page-header-fade-end: 0px;
281
+ flex: 1 1 0%;
282
+ min-width: 0;
283
+ overflow-x: auto;
284
+ scrollbar-width: none;
285
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
286
+ mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%);
287
+
288
+ &[data-fade-start] {
289
+ --page-header-fade-start: 2rem;
290
+ }
291
+
292
+ &[data-fade-end] {
293
+ --page-header-fade-end: 2rem;
294
+ }
295
+ }
296
+
297
+ /* width: max-content, then an auto margin: the tabs float right while they fit,
298
+ and start at the left, all reachable, once they do not. justify-content would
299
+ push the overflow past the start, where no scroll can reach it. */
300
+ @utility page-header-track {
301
+ display: flex;
302
+ align-items: center;
303
+ gap: 0.5rem;
304
+ width: max-content;
305
+ margin-left: auto;
306
+ }
307
+
308
+ @utility page-header-actions {
309
+ flex-shrink: 0;
310
+ margin-left: auto;
311
+ }
312
+
226
313
  /* -- Scrims: tints and shades ----------------------------------------------
227
314
  A scrim is the wash laid over media (a photo, a video) so text reads on it.
228
315
  It comes in two TONES, and the tone is a design decision, not a colour pick:
@@ -565,6 +652,93 @@ body.modal-open .studio-app-banner {
565
652
  animation: none;
566
653
  }
567
654
 
655
+ /* -- App-to-app page transitions --------------------------------------------
656
+ Studio.page_transition_direction (studio/page_transitions, docs/PAGE_TRANSITIONS.md).
657
+ Every rule is gated on an attribute only that script sets, so an app without
658
+ the setting keeps the fade above. Attribute selectors outrank the bare
659
+ ::view-transition-*(root) rules, here and in a host. The effects are from the
660
+ Codrops Page Transitions demo, at its values; the keyframes were written fresh.
661
+
662
+ Black behind the pages gives the shading: a page at opacity 0.3 darkens as it
663
+ moves. */
664
+ html[data-page-transition]:not([data-page-transition="still"])::view-transition {
665
+ background: #000;
666
+ }
667
+
668
+ html[data-page-transition]::view-transition-old(root),
669
+ html[data-page-transition]::view-transition-new(root) {
670
+ mix-blend-mode: normal;
671
+ animation-duration: 700ms;
672
+ animation-timing-function: ease;
673
+ animation-fill-mode: both;
674
+ backface-visibility: hidden;
675
+ }
676
+
677
+ /* A same-page visit (a refresh, a poll, the second half of a redirect) draws nothing. */
678
+ html[data-page-transition="still"]::view-transition-old(root),
679
+ html[data-page-transition="still"]::view-transition-new(root) {
680
+ animation: none;
681
+ }
682
+
683
+ /* The banner stack holds still with the navbar. The name goes on the one element
684
+ the script marks, since a duplicate name silently cancels the transition. It
685
+ lasts for the visit alone: a name at rest makes the stack a stacking context,
686
+ and its tooltip then paints under the navbar. No group animation either: a page
687
+ left scrolled has the stack above the viewport, and it would slide down. */
688
+ html[data-page-transition-direction][data-page-transition-visit] [data-vt-held] {
689
+ view-transition-name: studio-bar-stack;
690
+ }
691
+
692
+ ::view-transition-group(studio-bar-stack),
693
+ ::view-transition-old(studio-bar-stack),
694
+ ::view-transition-new(studio-bar-stack) {
695
+ animation: none;
696
+ }
697
+
698
+ /* A taller old navbar would show under the new one for the whole motion, then pop off.
699
+ Only the new navbar draws. Where the two match, the new one covers the same pixels. */
700
+ html[data-page-transition]:not([data-page-transition="still"])::view-transition-old(studio-header) {
701
+ opacity: 0;
702
+ }
703
+
704
+ /* A named element moves with the page, unless both pages share its name. The
705
+ script decides that before the old page's picture, and only then is the name let
706
+ through, so the morph stands. data-vt-morph carries the name it marks. */
707
+ html[data-page-transition-direction]:not([data-page-transition-morph]) [data-vt-morph] {
708
+ view-transition-name: none !important;
709
+ }
710
+
711
+ html[data-page-transition="fade-left"]::view-transition-old(root) { animation-name: pt-out-left; }
712
+ html[data-page-transition="fade-left"]::view-transition-new(root) { animation-name: pt-in-from-right; }
713
+ html[data-page-transition="fade-right"]::view-transition-old(root) { animation-name: pt-out-right; }
714
+ html[data-page-transition="fade-right"]::view-transition-new(root) { animation-name: pt-in-from-left; }
715
+ html[data-page-transition="fade-top"]::view-transition-old(root) { animation-name: pt-out-top; }
716
+ html[data-page-transition="fade-top"]::view-transition-new(root) { animation-name: pt-in-from-bottom; }
717
+ html[data-page-transition="fade-bottom"]::view-transition-old(root) { animation-name: pt-out-bottom; }
718
+ html[data-page-transition="fade-bottom"]::view-transition-new(root) { animation-name: pt-in-from-top; }
719
+
720
+ html[data-page-transition="unfold-right"]::view-transition-old(root) { animation-name: pt-out-left; }
721
+ html[data-page-transition="unfold-right"]::view-transition-new(root) { animation-name: pt-unfold-right; transform-origin: 0% 50%; }
722
+ html[data-page-transition="unfold-left"]::view-transition-old(root) { animation-name: pt-out-right; }
723
+ html[data-page-transition="unfold-left"]::view-transition-new(root) { animation-name: pt-unfold-left; transform-origin: 100% 50%; }
724
+ html[data-page-transition="unfold-bottom"]::view-transition-old(root) { animation-name: pt-out-top; }
725
+ html[data-page-transition="unfold-bottom"]::view-transition-new(root) { animation-name: pt-unfold-bottom; transform-origin: 50% 0%; }
726
+ html[data-page-transition="unfold-top"]::view-transition-old(root) { animation-name: pt-out-bottom; }
727
+ html[data-page-transition="unfold-top"]::view-transition-new(root) { animation-name: pt-unfold-top; transform-origin: 50% 100%; }
728
+
729
+ @keyframes pt-out-left { to { opacity: 0.3; transform: translateX(-100%); } }
730
+ @keyframes pt-out-right { to { opacity: 0.3; transform: translateX(100%); } }
731
+ @keyframes pt-out-top { to { opacity: 0.3; transform: translateY(-100%); } }
732
+ @keyframes pt-out-bottom { to { opacity: 0.3; transform: translateY(100%); } }
733
+ @keyframes pt-in-from-right { from { opacity: 0.3; transform: translateX(100%); } }
734
+ @keyframes pt-in-from-left { from { opacity: 0.3; transform: translateX(-100%); } }
735
+ @keyframes pt-in-from-bottom { from { opacity: 0.3; transform: translateY(100%); } }
736
+ @keyframes pt-in-from-top { from { opacity: 0.3; transform: translateY(-100%); } }
737
+ @keyframes pt-unfold-right { from { opacity: 0; transform: perspective(1200px) translateX(100%) rotateY(90deg); } }
738
+ @keyframes pt-unfold-left { from { opacity: 0; transform: perspective(1200px) translateX(-100%) rotateY(-90deg); } }
739
+ @keyframes pt-unfold-bottom { from { opacity: 0; transform: perspective(1200px) translateY(100%) rotateX(-90deg); } }
740
+ @keyframes pt-unfold-top { from { opacity: 0; transform: perspective(1200px) translateY(-100%) rotateX(90deg); } }
741
+
568
742
  /* Turbo's progress bar is the slow-load feedback under no-preview (the old
569
743
  page holds until the fresh response). Theme it so every app's bar matches
570
744
  its brand instead of Turbo's default. The fallback (#0076ff, Turbo's own
@@ -784,36 +958,67 @@ body.modal-open .studio-app-banner {
784
958
  saying they are its one menu, as Turf Monster's gear sidebar is.
785
959
  With Studio.navbar_name_in_rows the name is its own control in row 1
786
960
  ([data-nav-name-trigger]), and pointing at it lights the group too.
787
- An account link is never grouped. */
961
+ An account link is never grouped.
962
+
963
+ POINTING NEEDS A POINTER. Every :hover below sits inside
964
+ @media (hover: hover), and each :focus-visible rule stays outside it. On
965
+ a touch screen a tap leaves :hover on what was tapped until the next tap
966
+ lands elsewhere, so the group stayed lit after its sidebar closed. Tailwind's
967
+ hover: and group-hover: variants already carry this guard; these
968
+ hand-written selectors do not get it for free. */
788
969
  :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger] svg,
789
970
  :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-nav-account] > [aria-hidden] > * {
790
971
  transition: transform 200ms ease, box-shadow 200ms ease;
791
972
  }
792
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
793
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger],
794
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
795
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
973
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger],
974
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
975
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
796
976
  box-shadow:
797
977
  0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
798
978
  0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
799
979
  }
800
980
  @media (prefers-reduced-motion: no-preference) {
801
- :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
981
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
982
+ }
983
+ @media (hover: hover) {
984
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]) [data-link-sidebar-trigger]:hover { background-color: transparent; }
985
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-link-sidebar-trigger],
986
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-name] { color: var(--color-primary); }
987
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-account] > [aria-hidden] > * {
988
+ box-shadow:
989
+ 0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
990
+ 0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
991
+ }
992
+ @media (prefers-reduced-motion: no-preference) {
993
+ :has(> [data-nav-tools]):has(> [data-nav-account][aria-controls~="studio-link-sidebar"]):has([data-link-sidebar-trigger]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-link-sidebar-trigger] svg { transform: rotate(45deg); }
994
+ }
802
995
  }
803
996
  :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls] svg,
804
997
  :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-account] > [aria-hidden] > * {
805
998
  transition: transform 200ms ease, box-shadow 200ms ease;
806
999
  }
807
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
808
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls],
809
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
810
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
1000
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls],
1001
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-name] { color: var(--color-primary); }
1002
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-account] > [aria-hidden] > * {
811
1003
  box-shadow:
812
1004
  0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
813
1005
  0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
814
1006
  }
815
1007
  @media (prefers-reduced-motion: no-preference) {
816
- :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover, [data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
1008
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:focus-visible, [data-nav-account]:focus-visible, [data-nav-name-trigger]:focus-visible) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
1009
+ }
1010
+ @media (hover: hover) {
1011
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])) [data-nav-menu-trigger] [aria-controls]:hover { background-color: transparent; }
1012
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-menu-trigger] [aria-controls],
1013
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-name] { color: var(--color-primary); }
1014
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-account] > [aria-hidden] > * {
1015
+ box-shadow:
1016
+ 0 0 0 2px rgb(var(--color-primary-rgb) / 0.85),
1017
+ 0 0 14px 2px rgb(var(--color-primary-rgb) / 0.45);
1018
+ }
1019
+ @media (prefers-reduced-motion: no-preference) {
1020
+ :has(> [data-nav-tools] [data-nav-menu-trigger]):has(> [data-nav-account][data-profile-image-toggle]:not([aria-controls~="studio-link-sidebar"])):has([data-nav-menu-trigger] [aria-controls]:hover, [data-nav-account]:hover, [data-nav-name-trigger]:hover) [data-nav-menu-trigger] [aria-controls] svg { transform: rotate(45deg); }
1021
+ }
817
1022
  }
818
1023
  /* THE ACCOUNT ROW (components/_user_nav), from 768px. Every app whose
819
1024
  navbar renders the engine's user nav gets this, Turf Monster included:
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /marketing-kit: the app's logos, colours and typefaces, for anyone to use.
5
+ # See Studio::MarketingKit and docs/SITE_FOOTER.md.
6
+ #
7
+ # Public by design: a journalist or a partner has no account. It inherits the
8
+ # host's ApplicationController so the page renders in the app's layout, navbar
9
+ # and theme, and it skips each gate a host is known to put there:
10
+ # Studio::ErrorHandling's sign-in gate (every adopter), the hub's admin wall,
11
+ # and turf-monster's profile-completion redirect. A host with a gate of another
12
+ # name skips it in a to_prepare block (docs/SITE_FOOTER.md).
13
+ #
14
+ # It needs no table and no session, so an app with neither can draw it. Where
15
+ # ThemeSetting exists, the colours read it (Studio.marketing_kit_colors).
16
+ class MarketingKitController < ::ApplicationController
17
+ HOST_GATES = %i[require_authentication require_admin_wall require_admin require_login
18
+ authenticate_user! require_profile_completion].freeze
19
+
20
+ HOST_GATES.each { |gate| skip_before_action gate, raise: false }
21
+
22
+ def show; end
23
+ end
24
+ end
@@ -11,8 +11,8 @@ module Studio
11
11
  # <%= studio_footer_map %> the map alone, e.g. on a contact page
12
12
  #
13
13
  # THE FOOTER IS ON BY DEFAULT: with Studio.site_footer unset, the layout line
14
- # renders the default footer (the site name, the logo, and the legal links the
15
- # host has routes for), and `config.site_footer = false` turns it off. The
14
+ # renders the default footer (the site name, the logo, the legal links the host
15
+ # has routes for, and the Marketing Kit link where the page is drawn), and `config.site_footer = false` turns it off. The
16
16
  # booking helpers render NOTHING without Studio.booking_url, and the map
17
17
  # nothing without an address, so a layout or a shared view can call any of
18
18
  # them before an app has declared anything.
@@ -70,6 +70,19 @@ module Studio
70
70
  zoom: options[:zoom], controls: options.fetch(:controls, true)
71
71
  end
72
72
 
73
+ # The kit /marketing-kit prints (Studio.marketing_kit_for). A callable that
74
+ # raises costs the app's own facts, not the page: in production the error is
75
+ # logged once and the page shows what derives from the theme. In development
76
+ # and test it raises.
77
+ def studio_marketing_kit
78
+ Studio.marketing_kit_for(self)
79
+ rescue StandardError => error
80
+ raise if studio_site_footer_raise?
81
+
82
+ studio_site_footer_report(error)
83
+ Studio.marketing_kit_for(self, declared: nil)
84
+ end
85
+
73
86
  # ---- booking ------------------------------------------------------------
74
87
 
75
88
  def studio_booking? = Studio.booking_url.present?
@@ -0,0 +1,63 @@
1
+ // copy-text: one click puts a value on the clipboard, and says so. It sits on the
2
+ // element around the control (studio/marketing_kit/_kit):
3
+ //
4
+ // <div data-studio-controller="copy-text" data-copy-text-text-value="#8E82FE">
5
+ // <button type="button" data-studio-action="click->copy-text#copy">...</button>
6
+ // <span role="status" data-copy-text-target="status"></span>
7
+ // </div>
8
+ //
9
+ // The status is written only after the browser answers, so "Copied" is never
10
+ // shown for a copy that failed. navigator.clipboard exists only in a secure
11
+ // context and may refuse; the older selection copy then gets one try.
12
+ import { Controller } from "@hotwired/stimulus"
13
+
14
+ const SHOWN_MS = 1600
15
+
16
+ // True when `text` reached the clipboard. It never rejects.
17
+ export async function copyToClipboard(text, nav = navigator, doc = document) {
18
+ try {
19
+ await nav.clipboard.writeText(text)
20
+ return true
21
+ } catch {
22
+ return selectionCopy(text, doc)
23
+ }
24
+ }
25
+
26
+ function selectionCopy(text, doc) {
27
+ const field = doc.createElement("textarea")
28
+ field.value = text
29
+ field.setAttribute("readonly", "")
30
+ field.style.position = "fixed"
31
+ field.style.opacity = "0"
32
+ doc.body.appendChild(field)
33
+ try {
34
+ field.select()
35
+ return doc.execCommand("copy")
36
+ } catch {
37
+ return false
38
+ } finally {
39
+ field.remove()
40
+ }
41
+ }
42
+
43
+ export default class extends Controller {
44
+ static targets = ["status"]
45
+ static values = {
46
+ text: String,
47
+ copied: { type: String, default: "Copied" },
48
+ failed: { type: String, default: "Could not copy" }
49
+ }
50
+
51
+ async copy() {
52
+ const copied = await copyToClipboard(this.textValue)
53
+ if (!this.hasStatusTarget) return
54
+
55
+ this.statusTarget.textContent = copied ? this.copiedValue : this.failedValue
56
+ clearTimeout(this.timer)
57
+ this.timer = setTimeout(() => { this.statusTarget.textContent = "" }, SHOWN_MS)
58
+ }
59
+
60
+ disconnect() {
61
+ clearTimeout(this.timer)
62
+ }
63
+ }