studio-engine 0.135.2 → 0.136.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 +58 -11
- data/README.md +7 -0
- data/app/assets/javascripts/studio/cropper-1.6.2.min.js +10 -0
- data/app/assets/javascripts/studio/page_transition_reveal.js +3 -0
- data/app/assets/stylesheets/studio/cropper-1.6.2.min.css +9 -0
- data/app/controllers/concerns/studio/error_handling.rb +58 -1
- data/app/helpers/studio/page_header_helper.rb +26 -0
- data/app/helpers/studio_navbar_helper.rb +22 -0
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/cropper.js +2 -2
- data/app/javascript/studio/page_header_self_styled.js +194 -0
- data/app/javascript/studio/page_transitions.js +42 -11
- data/app/javascript/studio/reload_visits.js +93 -0
- data/app/models/studio/user_session.rb +216 -21
- data/app/views/layouts/_navbar.html.erb +11 -4
- data/app/views/studio/_cropper_assets.html.erb +14 -9
- data/app/views/studio/_legal_page.html.erb +52 -0
- data/app/views/studio/_page_header.html.erb +12 -2
- data/app/views/studio/page_header/_assets.html.erb +151 -0
- data/db/migrate/20261108120000_add_device_revocation_to_studio_user_sessions.rb +28 -0
- data/lib/studio/engine.rb +6 -1
- data/lib/studio/host_schema.rb +53 -2
- data/lib/studio/page_header.rb +11 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +10 -0
- metadata +9 -1
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
does. extra_icons_slot is rendered here, so the link
|
|
12
12
|
sidebar's trigger still follows it; like the user nav,
|
|
13
13
|
a slot wins over its legacy _html string.
|
|
14
|
-
brand_heading — boolean, draw the brand title as the page's h1
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
brand_heading — boolean, draw the brand title as the page's h1. Default:
|
|
15
|
+
the engine decides. The brand is the h1 unless the page
|
|
16
|
+
has its own (studio_page_has_h1?: studio/_page_header
|
|
17
|
+
recorded one, or the page body holds an h1 tag); then it
|
|
18
|
+
is a div with the same classes and contents. A preview
|
|
19
|
+
keeps the h1. Pass true or false and it wins.
|
|
17
20
|
%>
|
|
18
21
|
<% show_user = local_assigns.fetch(:show_logged_in, logged_in?) %>
|
|
19
22
|
<% is_preview = local_assigns.fetch(:preview, false) %>
|
|
@@ -35,7 +38,11 @@
|
|
|
35
38
|
<% if extra_icons_slot.present? %>
|
|
36
39
|
<% extra_icons_html = extra_icons_slot.is_a?(Hash) ? render(extra_icons_slot[:partial], **extra_icons_slot.fetch(:locals, {})) : render(extra_icons_slot.to_s) %>
|
|
37
40
|
<% end %>
|
|
38
|
-
|
|
41
|
+
<%# One h1 per page: the brand is the h1 only where the page has none of its own.
|
|
42
|
+
The class (.nav-title, or .nav-wordmark-brand for a lockup) is the stable
|
|
43
|
+
selector; the tag is not. %>
|
|
44
|
+
<% brand_heading = local_assigns.fetch(:brand_heading) { is_preview || !(respond_to?(:studio_page_has_h1?) && studio_page_has_h1?) } %>
|
|
45
|
+
<% brand_tag = brand_heading ? "h1" : "div" %>
|
|
39
46
|
<%# Out-of-the-box navigation (engine 0.30): when the host declares
|
|
40
47
|
Studio.sidebar_sections, the navbar mounts the link-sidebar trigger in its
|
|
41
48
|
icon rails and renders the slide-out panels after the header. With no
|
|
@@ -9,16 +9,21 @@
|
|
|
9
9
|
Pair with: the crop-photo and saving modal registrations in your modal-host
|
|
10
10
|
block (see the README and CHANGELOG), and x-data="imageUploadHost({...})".
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
SERVED BY THE HOST'S ASSET PIPELINE, since engine-vendors-cropper-assets.
|
|
13
|
+
The two files were cdnjs tags, and a cdnjs connection that never answered
|
|
14
|
+
held the page's load event (the deferred script) on every page that renders
|
|
15
|
+
this partial. They are the cropperjs 1.6.2 npm package's dist files, byte for
|
|
16
|
+
byte (app/assets/javascripts/studio/cropper-1.6.2.min.js and
|
|
17
|
+
app/assets/stylesheets/studio/cropper-1.6.2.min.css); bin/cropper-sri-live
|
|
18
|
+
checks them against the npm tarball and cdnjs's published SRI.
|
|
19
|
+
|
|
20
|
+
Both tags keep Subresource Integrity, so a changed file fails to load
|
|
21
|
+
rather than running. The hashes are sha384 over the exact files. A version
|
|
22
|
+
bump replaces both files (new names), recomputes both hashes (openssl dgst
|
|
23
|
+
-sha384 -binary FILE | openssl base64 -A) and runs bin/cropper-sri-live. %>
|
|
24
|
+
<link rel="stylesheet" href="<%= asset_path("studio/cropper-1.6.2.min.css") %>"
|
|
20
25
|
integrity="sha384-6LFfkTKLRlzFtgx8xsWyBdKGpcMMQTkv+dB7rAbugeJAu1Ym2q1Aji1cjHBG12Xh"
|
|
21
26
|
crossorigin="anonymous">
|
|
22
|
-
<script defer src="
|
|
27
|
+
<script defer src="<%= asset_path("studio/cropper-1.6.2.min.js") %>"
|
|
23
28
|
integrity="sha384-jrOgQzBlDeUNdmQn3rUt/PZD+pdcRBdWd/HWRqRo+n2OR2QtGyjSaJC0GiCeH+ir"
|
|
24
29
|
crossorigin="anonymous"></script>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%# locals: (title:, updated: nil, crumbs: [], links: [], data: {}) %>
|
|
2
|
+
<%# THE LEGAL PAGE: the standard page header and a readable column, for an app's
|
|
3
|
+
privacy policy and terms (docs/PAGE_HEADER.md, "The legal page"). Render it as a
|
|
4
|
+
layout around the page's sections:
|
|
5
|
+
|
|
6
|
+
render layout: "studio/legal_page", locals: { title: "Privacy Policy", updated: "November 8, 2026" }
|
|
7
|
+
...the sections, each an h2 and its paragraphs...
|
|
8
|
+
|
|
9
|
+
title the page's one h1, in studio/_page_header.
|
|
10
|
+
updated the date the text last changed, as the reader should see it. It is
|
|
11
|
+
printed as "Last updated" and the date, under the row.
|
|
12
|
+
crumbs the row's trail, as studio/_page_header takes it.
|
|
13
|
+
links the row's links line, such as the other legal page.
|
|
14
|
+
data data attributes for the column, such as data-legal-page.
|
|
15
|
+
|
|
16
|
+
SCOPED CSS, NOT UTILITIES, as studio/site_footer/_assets: the column reads the
|
|
17
|
+
theme's custom properties with a fallback beside each, so it reads the same in a
|
|
18
|
+
Tailwind app and in a plain-CSS one. A plain-CSS app also sets
|
|
19
|
+
config.page_header_self_styled, so the row brings its own styles too.
|
|
20
|
+
|
|
21
|
+
The column has a 1rem gutter, which the row's default bleed fits. %>
|
|
22
|
+
<% unless @_studio_legal_page_styled %>
|
|
23
|
+
<% @_studio_legal_page_styled = true %>
|
|
24
|
+
<style <%= tag.attributes(nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))) %>>
|
|
25
|
+
.legal-page { box-sizing: border-box; max-width: 46rem; margin-inline: auto; padding: 1.5rem 1rem 3rem; }
|
|
26
|
+
.legal-page-updated { margin: .25rem 0 0; font-size: .875rem; line-height: 1.25rem;
|
|
27
|
+
color: var(--color-text-muted, var(--color-text-secondary, currentColor)); }
|
|
28
|
+
.legal-page-body { margin-top: 1.5rem; font-size: 1rem; line-height: 1.7; color: var(--color-text-body, currentColor);
|
|
29
|
+
overflow-wrap: break-word; }
|
|
30
|
+
.legal-page-body > :first-child { margin-top: 0; }
|
|
31
|
+
.legal-page-body :is(h2, h3) { color: var(--color-text, currentColor); line-height: 1.3; letter-spacing: -.01em; }
|
|
32
|
+
.legal-page-body h2 { margin: 2.25rem 0 0; font-size: 1.25rem; font-weight: 700; }
|
|
33
|
+
.legal-page-body h3 { margin: 1.5rem 0 0; font-size: 1.0625rem; font-weight: 700; }
|
|
34
|
+
.legal-page-body :is(p, ul, ol) { margin: .75rem 0 0; }
|
|
35
|
+
.legal-page-body :is(ul, ol) { padding-left: 1.5rem; }
|
|
36
|
+
.legal-page-body ul { list-style: disc; }
|
|
37
|
+
.legal-page-body ol { list-style: decimal; }
|
|
38
|
+
.legal-page-body li + li { margin-top: .375rem; }
|
|
39
|
+
.legal-page-body strong { color: var(--color-text, currentColor); font-weight: 700; }
|
|
40
|
+
.legal-page-body a { color: var(--color-primary-ink, var(--color-primary, currentColor)); text-decoration: underline;
|
|
41
|
+
text-underline-offset: 3px; }
|
|
42
|
+
.legal-page-body a:hover { text-decoration-thickness: 2px; }
|
|
43
|
+
.legal-page-body section + section { margin-top: 0; }
|
|
44
|
+
</style>
|
|
45
|
+
<% end %>
|
|
46
|
+
<%= tag.div(class: "legal-page", data: { studio_legal_page: "" }.merge(data)) do %>
|
|
47
|
+
<%= render "studio/page_header", title: title, crumbs: crumbs, links: links %>
|
|
48
|
+
<% if updated.present? %>
|
|
49
|
+
<p class="legal-page-updated">Last updated <%= updated %></p>
|
|
50
|
+
<% end %>
|
|
51
|
+
<article class="legal-page-body"><%= yield %></article>
|
|
52
|
+
<% end %>
|
|
@@ -66,8 +66,18 @@
|
|
|
66
66
|
parent's start, so the controller measures the row's place from the row. It
|
|
67
67
|
carries the controller, so the row keeps its data-studio-controller for the
|
|
68
68
|
page's own. So the row carries no top margin: the space above it belongs to its
|
|
69
|
-
container.
|
|
70
|
-
|
|
69
|
+
container.
|
|
70
|
+
|
|
71
|
+
SELF-STYLED, IN A PLAIN-CSS APP. studio_page_header_assets renders the row's own
|
|
72
|
+
CSS and script once per page, only where config.page_header_self_styled asks
|
|
73
|
+
(Studio::PageHeaderHelper). Everywhere else it is nil, and it sits on the
|
|
74
|
+
sentinel's line, so a Tailwind app's row is the same to the byte.
|
|
75
|
+
|
|
76
|
+
THE PAGE'S ONE H1 is this row's title, so it records one, and the navbar draws its
|
|
77
|
+
brand as a div (StudioNavbarHelper#studio_page_has_h1?). content_for, not the helper:
|
|
78
|
+
a host renders this partial from views that may not include the engine's helpers. It
|
|
79
|
+
sits on the same line and renders nothing. %>
|
|
80
|
+
<% content_for(:studio_page_h1, "1") unless content_for?(:studio_page_h1) %><%= studio_page_header_assets if respond_to?(:studio_page_header_assets) %><div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
|
|
71
81
|
<%= tag.div(class: "page-header sticky #{row_class}".strip,
|
|
72
82
|
style: "top: var(--pin-page-top, var(--nav-bottom, var(--nav-h, 0px))); z-index: var(--z-sticky, 30)",
|
|
73
83
|
data: data.merge(pin: "page")) do %>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<%# The self-styled page header's CSS and script. Rendered once per page, by
|
|
2
|
+
studio_page_header_assets, and only where config.page_header_self_styled asks
|
|
3
|
+
(docs/PAGE_HEADER.md, "In a plain-CSS app").
|
|
4
|
+
|
|
5
|
+
SCOPED CSS, NOT UTILITIES, as studio/site_footer/_assets. Everything the row needs
|
|
6
|
+
is here, under .page-header, reading the theme's custom properties with a fallback
|
|
7
|
+
beside each, so the row renders whole in an app whose stylesheet has never seen
|
|
8
|
+
the engine's classes. The rules are the ones engine.css gives the row (its "page
|
|
9
|
+
header" block, the .btn family at .btn-sm, and .label-upper), so the two looks
|
|
10
|
+
are one. Change one, change the other.
|
|
11
|
+
|
|
12
|
+
The default bleed (row_class "-mx-4 px-4") is a pair of Tailwind classes. This
|
|
13
|
+
file defines them on the row alone, so the default fits a plain container with a
|
|
14
|
+
1rem gutter as it fits a Tailwind px-4.
|
|
15
|
+
|
|
16
|
+
THE SCRIPT is studio/page_header_self_styled, a module with no imports, loaded by
|
|
17
|
+
its own tag because a plain app may have no import map and no Stimulus. It is the
|
|
18
|
+
page-header controller's work: the stuck bar, the tab fades, the open tab on a
|
|
19
|
+
phone, each Links menu's aria-expanded, and the menu's place where the browser
|
|
20
|
+
has no anchor positioning. It also publishes --nav-bottom from the app's own
|
|
21
|
+
sticky masthead. The tag carries the request's CSP nonce when the host sets one.
|
|
22
|
+
Without the script the row keeps its bar at all times (the noscript rule), and
|
|
23
|
+
every control is still a link or a popover. %>
|
|
24
|
+
<style <%= tag.attributes(nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))) %>>
|
|
25
|
+
.page-header { --ph-ink: var(--color-text, currentColor); --ph-body: var(--color-text-body, currentColor);
|
|
26
|
+
--ph-muted: var(--color-text-muted, var(--color-text-secondary, currentColor));
|
|
27
|
+
--ph-surface: var(--color-surface, Canvas); --ph-surface-alt: var(--color-surface-alt, var(--color-surface, Canvas));
|
|
28
|
+
--ph-line: var(--color-border, rgb(127 127 127 / .25));
|
|
29
|
+
--ph-line-strong: var(--color-border-strong, var(--color-border, rgb(127 127 127 / .4)));
|
|
30
|
+
--ph-primary: var(--color-cta-fill, var(--color-cta, var(--color-primary, #6558e5)));
|
|
31
|
+
--ph-primary-hover: var(--color-cta-hover, var(--ph-primary));
|
|
32
|
+
--ph-primary-fg: var(--btn-primary-fg, #fff);
|
|
33
|
+
--ph-focus: var(--color-primary-ink, var(--color-primary, #6558e5));
|
|
34
|
+
--ph-base: var(--type-base, 16px);
|
|
35
|
+
position: sticky; box-sizing: border-box;
|
|
36
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; min-width: 0;
|
|
37
|
+
padding-block: .5rem; border-block: 1px solid transparent; background-color: transparent;
|
|
38
|
+
transition: background-color 150ms ease, border-color 150ms ease; }
|
|
39
|
+
.page-header *, .page-header *::before, .page-header *::after { box-sizing: border-box; }
|
|
40
|
+
.page-header[data-stuck] { background-color: var(--ph-surface); border-color: var(--ph-line); }
|
|
41
|
+
.page-header.-mx-4 { margin-inline: -1rem; }
|
|
42
|
+
.page-header.px-4 { padding-inline: 1rem; }
|
|
43
|
+
.page-header-sentinel { position: absolute; width: 0; height: 0; }
|
|
44
|
+
.page-header a { text-decoration: none; }
|
|
45
|
+
|
|
46
|
+
/* The title and the meta. */
|
|
47
|
+
.page-header-lead { display: flex; align-items: baseline; gap: .5rem; flex: 0 1 auto; min-width: 0; }
|
|
48
|
+
.page-header .page-header-title { margin: 0; color: var(--ph-ink); font-size: calc(var(--ph-base) * 1.5);
|
|
49
|
+
font-weight: var(--type-display-weight, 800); line-height: 1.25; min-width: 0; overflow-wrap: anywhere;
|
|
50
|
+
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow-x: visible; overflow-y: clip; }
|
|
51
|
+
@media (width >= 40rem) { .page-header .page-header-title { font-size: 30px; line-height: 1.2; } }
|
|
52
|
+
.page-header :is(.page-header-meta, .label-upper) { margin: 0; white-space: nowrap; color: var(--ph-muted);
|
|
53
|
+
font-size: .75rem; line-height: 1rem; text-transform: uppercase; letter-spacing: .05em; }
|
|
54
|
+
@media (width < 40rem) { .page-header .page-header-meta { display: none; } }
|
|
55
|
+
|
|
56
|
+
/* The buttons, at .btn-sm: every control on the right stands at one height. */
|
|
57
|
+
.page-header .btn { display: inline-flex; align-items: center; justify-content: center; margin: 0;
|
|
58
|
+
font-family: inherit; font-weight: 700; border: 1px solid transparent; border-radius: .5rem;
|
|
59
|
+
white-space: nowrap; cursor: pointer; text-decoration: none;
|
|
60
|
+
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, filter 150ms ease; }
|
|
61
|
+
.page-header .btn-sm { padding: .375rem .75rem; font-size: .75rem; line-height: 1rem; }
|
|
62
|
+
.page-header .btn-primary { color: var(--ph-primary-fg); background-color: var(--ph-primary); }
|
|
63
|
+
.page-header .btn-primary:hover { color: var(--btn-primary-fg-hover, var(--ph-primary-fg));
|
|
64
|
+
background-color: var(--ph-primary-hover); filter: brightness(.94); }
|
|
65
|
+
.page-header .btn-neutral { color: var(--ph-ink); background-color: var(--btn-neutral-bg, var(--ph-surface-alt));
|
|
66
|
+
border-color: var(--ph-line-strong); }
|
|
67
|
+
.page-header .btn-neutral:hover { color: var(--ph-ink); background-color: var(--btn-neutral-bg-hover, var(--ph-surface));
|
|
68
|
+
filter: var(--btn-neutral-hover-filter, none); }
|
|
69
|
+
.page-header .btn:focus-visible { outline: 2px solid var(--ph-focus); outline-offset: 2px; border-radius: .5rem; }
|
|
70
|
+
|
|
71
|
+
/* The pill group: it scrolls sideways inside the row on a phone, with a fade at an end
|
|
72
|
+
that hides a tab. */
|
|
73
|
+
.page-header .page-header-tabs { --page-header-fade-start: 0px; --page-header-fade-end: 0px;
|
|
74
|
+
flex: 1 1 6rem; min-width: 0; overflow-x: auto; scrollbar-width: none; padding: .25rem; margin: -.25rem;
|
|
75
|
+
-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%);
|
|
76
|
+
mask-image: linear-gradient(to right, transparent 0, #000 var(--page-header-fade-start), #000 calc(100% - var(--page-header-fade-end)), transparent 100%); }
|
|
77
|
+
.page-header .page-header-tabs::-webkit-scrollbar { display: none; }
|
|
78
|
+
.page-header .page-header-tabs[data-fade-start] { --page-header-fade-start: 2rem; }
|
|
79
|
+
.page-header .page-header-tabs[data-fade-end] { --page-header-fade-end: 2rem; }
|
|
80
|
+
.page-header-track { display: flex; align-items: center; gap: .5rem; width: max-content; margin-left: auto; }
|
|
81
|
+
.page-header-tab-label { position: relative; z-index: 2; }
|
|
82
|
+
.page-header .page-header-glide { display: none; }
|
|
83
|
+
|
|
84
|
+
/* The cluster: the menu, the action, the tools. */
|
|
85
|
+
.page-header-actions { flex-shrink: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end;
|
|
86
|
+
gap: .5rem; max-width: 100%; margin-left: auto; }
|
|
87
|
+
.page-header-tabs + .page-header-actions { margin-left: 0; }
|
|
88
|
+
.page-header-menu { position: relative; anchor-scope: --page-header-menu; }
|
|
89
|
+
.page-header .page-header-menu-button { gap: .375rem; anchor-name: --page-header-menu; }
|
|
90
|
+
.page-header-menu-caret { width: .375rem; height: .375rem; border-right: 1.5px solid currentColor;
|
|
91
|
+
border-bottom: 1.5px solid currentColor; transform: translateY(-.125rem) rotate(45deg); transition: transform 150ms ease; }
|
|
92
|
+
.page-header-menu:has(> .page-header-menu-list:popover-open) .page-header-menu-caret { transform: translateY(.125rem) rotate(225deg); }
|
|
93
|
+
.page-header .page-header-menu-list { position: fixed; inset: auto; margin: 0; min-width: 10rem;
|
|
94
|
+
max-width: min(20rem, calc(100vw - 2rem)); padding: .25rem; border: 1px solid var(--ph-line); border-radius: .5rem;
|
|
95
|
+
background-color: var(--ph-surface); color: var(--ph-body);
|
|
96
|
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1); }
|
|
97
|
+
.page-header .page-header-menu-list ul { margin: 0; padding: 0; list-style: none; }
|
|
98
|
+
@supports (anchor-name: --a) and (position-anchor: --a) {
|
|
99
|
+
.page-header .page-header-menu-list { position-anchor: --page-header-menu; top: anchor(bottom); right: anchor(right);
|
|
100
|
+
margin-top: .25rem; position-try-fallbacks: flip-block, flip-inline; }
|
|
101
|
+
}
|
|
102
|
+
.page-header .page-header-menu-item { display: block; padding: .5rem .75rem; border-radius: .375rem;
|
|
103
|
+
font-size: calc(var(--ph-base) * .875); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
104
|
+
color: var(--ph-body); }
|
|
105
|
+
.page-header .page-header-menu-item:is(:hover, :focus-visible) { background-color: var(--ph-surface-alt); color: var(--ph-ink); }
|
|
106
|
+
.page-header .page-header-menu-item:focus-visible { outline: 2px solid var(--ph-focus); outline-offset: -2px; }
|
|
107
|
+
.page-header-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; padding-left: .5rem;
|
|
108
|
+
border-left: 1px dashed var(--ph-line); opacity: .7; transition: opacity 150ms ease; }
|
|
109
|
+
.page-header-tools:is(:hover, :focus-within) { opacity: 1; }
|
|
110
|
+
|
|
111
|
+
/* The trail above the title, and the one parent link that stands for it on a phone. */
|
|
112
|
+
.page-header-trail { flex: 1 0 100%; min-width: 0; margin-bottom: -.5rem;
|
|
113
|
+
font-size: calc(var(--ph-base) * .8125); line-height: 1.25; }
|
|
114
|
+
.page-header-crumbs { min-width: 0; }
|
|
115
|
+
.page-header-crumbs ol { display: flex; flex-wrap: wrap; align-items: center; column-gap: .5rem; row-gap: .125rem;
|
|
116
|
+
margin: 0; padding: 0; list-style: none; }
|
|
117
|
+
.page-header-crumbs li { display: inline-flex; align-items: center; gap: .5rem; min-width: 0; }
|
|
118
|
+
.page-header-crumbs li + li::before { content: "\203A"; content: "\203A" / ""; color: var(--ph-muted); opacity: .6; }
|
|
119
|
+
@media (width < 40rem) { .page-header-crumbs { display: none; } }
|
|
120
|
+
.page-header .page-header-crumb { color: var(--ph-muted); transition: color 150ms ease; }
|
|
121
|
+
.page-header .page-header-crumb-current { color: var(--ph-ink); font-weight: 600; overflow-wrap: anywhere; }
|
|
122
|
+
.page-header .page-header-parent { display: none; color: var(--ph-muted); font-size: calc(var(--ph-base) * .875); }
|
|
123
|
+
@media (width < 40rem) { .page-header .page-header-parent { display: inline-flex; gap: .25rem; align-items: baseline; } }
|
|
124
|
+
|
|
125
|
+
/* The links line at the foot of the row. */
|
|
126
|
+
.page-header-links { flex: 1 0 100%; display: flex; align-items: center; gap: 1rem; min-width: 0; margin-top: -.25rem;
|
|
127
|
+
overflow-x: auto; scrollbar-width: none; white-space: nowrap; font-size: calc(var(--ph-base) * .8125); }
|
|
128
|
+
.page-header .page-header-link { color: var(--ph-muted); transition: color 150ms ease; }
|
|
129
|
+
.page-header .page-header-link[aria-current] { font-weight: 600; }
|
|
130
|
+
.page-header :is(.page-header-crumb, .page-header-parent, .page-header-link):is(:hover, :focus-visible),
|
|
131
|
+
.page-header .page-header-link[aria-current] { color: var(--ph-ink); }
|
|
132
|
+
|
|
133
|
+
/* The gliding pill, where the browser can anchor it (engine.css, "The gliding pill"). */
|
|
134
|
+
@supports (anchor-name: --a) and (position-anchor: --a) and selector(:has(a)) {
|
|
135
|
+
.page-header .page-header-track { position: relative; anchor-scope: --page-header-current; }
|
|
136
|
+
.page-header .page-header-track > [aria-current] { anchor-name: --page-header-current; }
|
|
137
|
+
.page-header .page-header-track:has(> .page-header-tab[aria-current]) > .page-header-glide {
|
|
138
|
+
display: block; position: absolute; position-anchor: --page-header-current;
|
|
139
|
+
top: anchor(top); right: anchor(right); bottom: anchor(bottom); left: anchor(left);
|
|
140
|
+
z-index: 1; margin: 0; padding: 0; pointer-events: none;
|
|
141
|
+
transition: top 320ms cubic-bezier(.2, .8, .2, 1), right 320ms cubic-bezier(.2, .8, .2, 1),
|
|
142
|
+
bottom 320ms cubic-bezier(.2, .8, .2, 1), left 320ms cubic-bezier(.2, .8, .2, 1); }
|
|
143
|
+
.page-header .page-header-track:has(> .page-header-glide) > .page-header-tab[aria-current] {
|
|
144
|
+
background-color: transparent; background-image: none; border-color: transparent; box-shadow: none; filter: none; }
|
|
145
|
+
}
|
|
146
|
+
@media (prefers-reduced-motion: reduce) {
|
|
147
|
+
.page-header, .page-header *, .page-header-menu-caret { transition: none !important; }
|
|
148
|
+
}
|
|
149
|
+
</style>
|
|
150
|
+
<noscript><style>.page-header { background-color: var(--ph-surface); border-color: var(--ph-line); }</style></noscript>
|
|
151
|
+
<script type="module" src="<%= asset_path("studio/page_header_self_styled.js") %>" <%= tag.attributes(nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))) %>></script>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Lets a logout end one device's session on the server (Studio::UserSession,
|
|
2
|
+
# docs/DEVICE_SESSIONS.md). `sid` is the random id the device's cookie carries in
|
|
3
|
+
# its mark; every row of that device session carries it. `revoked_at` is stamped
|
|
4
|
+
# on logout, and a request whose sid is revoked is signed out.
|
|
5
|
+
#
|
|
6
|
+
# Both columns are nullable with no default, so the add is a catalog-only change
|
|
7
|
+
# and no existing row is rewritten. Rows from before this migration have no sid;
|
|
8
|
+
# their cookies get one on their next request (Studio::UserSession.track).
|
|
9
|
+
#
|
|
10
|
+
# A host without the table (it has not installed CreateStudioUserSessions) is a
|
|
11
|
+
# no-op here; the model checks for these columns and stays off until they exist.
|
|
12
|
+
class AddDeviceRevocationToStudioUserSessions < ActiveRecord::Migration[7.2]
|
|
13
|
+
def up
|
|
14
|
+
return unless table_exists?(:studio_user_sessions)
|
|
15
|
+
|
|
16
|
+
add_column :studio_user_sessions, :sid, :string, if_not_exists: true
|
|
17
|
+
add_column :studio_user_sessions, :revoked_at, :datetime, if_not_exists: true
|
|
18
|
+
add_index :studio_user_sessions, :sid, if_not_exists: true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def down
|
|
22
|
+
return unless table_exists?(:studio_user_sessions)
|
|
23
|
+
|
|
24
|
+
remove_index :studio_user_sessions, :sid, if_exists: true
|
|
25
|
+
remove_column :studio_user_sessions, :revoked_at, if_exists: true
|
|
26
|
+
remove_column :studio_user_sessions, :sid, if_exists: true
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/studio/engine.rb
CHANGED
|
@@ -88,7 +88,8 @@ module Studio
|
|
|
88
88
|
# app/assets/javascripts/studio: the vendored third-party builds, each
|
|
89
89
|
# loaded as an ES module (Alpine by the head's type="module" tag,
|
|
90
90
|
# canvas-confetti and SortableJS through their importmap pins, Leaflet by
|
|
91
|
-
# studio/footer_map's import()
|
|
91
|
+
# studio/footer_map's import(), Cropper.js by studio/_cropper_assets'
|
|
92
|
+
# two tags), and studio/session.js, the one classic
|
|
92
93
|
# script (layouts/studio/_head says why), with studio/page_transition_reveal.js,
|
|
93
94
|
# the other classic one. The engine's own modules are under app/javascript,
|
|
94
95
|
# enumerated below.
|
|
@@ -99,6 +100,8 @@ module Studio
|
|
|
99
100
|
studio/sortable.js
|
|
100
101
|
studio/leaflet.js
|
|
101
102
|
studio/leaflet.css
|
|
103
|
+
studio/cropper-1.6.2.min.js
|
|
104
|
+
studio/cropper-1.6.2.min.css
|
|
102
105
|
studio/session.js
|
|
103
106
|
studio/page_transition_full_load.css
|
|
104
107
|
studio/page_transition_reveal.js
|
|
@@ -429,8 +432,10 @@ module Studio
|
|
|
429
432
|
# The host's error_logs, theme_settings and image_caches carry every
|
|
430
433
|
# column the engine's models write (Studio::HostSchema). Skipped in a
|
|
431
434
|
# rake task, which boots to migrate rather than to serve.
|
|
435
|
+
# Then the device-session columns, which only warn (docs/DEVICE_SESSIONS.md).
|
|
432
436
|
if Studio.active_record? && !Studio::HostSchema.inside_rake_task?
|
|
433
437
|
Studio::HostSchema.check!
|
|
438
|
+
Studio::HostSchema.device_session_check!
|
|
434
439
|
end
|
|
435
440
|
end
|
|
436
441
|
|
data/lib/studio/host_schema.rb
CHANGED
|
@@ -125,6 +125,57 @@ module Studio
|
|
|
125
125
|
missing
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
# DEVICE SESSIONS (docs/DEVICE_SESSIONS.md). Studio::UserSession checks a
|
|
129
|
+
# logged-out cookie only when studio_user_sessions carries sid and
|
|
130
|
+
# revoked_at; without them it checks nothing and serves as before. That
|
|
131
|
+
# off switch is right for an app that has not installed the migration yet,
|
|
132
|
+
# but silent: a replayed cookie would sign in and nobody would know why.
|
|
133
|
+
# So the boot names the gap. It is a WARNING in every environment and never
|
|
134
|
+
# raises: the release sweep installs the migration, and a host's CI must not
|
|
135
|
+
# go red over a column the next sweep adds.
|
|
136
|
+
DEVICE_SESSION_TABLE = "studio_user_sessions"
|
|
137
|
+
DEVICE_SESSION_COLUMNS = %w[sid revoked_at].freeze
|
|
138
|
+
|
|
139
|
+
# The device-session columns the host lacks: every one of them when the
|
|
140
|
+
# table is absent, else the ones it is short of. Empty when the host checks.
|
|
141
|
+
def missing_device_session_columns(connection)
|
|
142
|
+
return DEVICE_SESSION_COLUMNS.dup unless connection.table_exists?(DEVICE_SESSION_TABLE)
|
|
143
|
+
|
|
144
|
+
DEVICE_SESSION_COLUMNS - connection.columns(DEVICE_SESSION_TABLE).map(&:name)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def device_session_message(missing)
|
|
148
|
+
gap = missing == DEVICE_SESSION_COLUMNS ? "has no #{DEVICE_SESSION_TABLE} sid/revoked_at columns" : "#{DEVICE_SESSION_TABLE} is missing #{missing.join(', ')}"
|
|
149
|
+
"studio-engine: this app #{gap}, so a logged-out device's cookie is NOT " \
|
|
150
|
+
"checked and a copy of it still signs in (docs/DEVICE_SESSIONS.md). Run " \
|
|
151
|
+
"bin/rails studio_engine:install:migrations && bin/rails db:migrate."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The boot hook for device sessions. Returns the missing columns (empty when
|
|
155
|
+
# whole, or skipped) and logs a warning, plus a Sentry warning, when any are
|
|
156
|
+
# missing. Never raises. Off when Studio.host_schema_check is false.
|
|
157
|
+
def device_session_check!(connection: nil, mode: self.mode, logger: Rails.logger)
|
|
158
|
+
return [] if mode == false
|
|
159
|
+
|
|
160
|
+
missing =
|
|
161
|
+
begin
|
|
162
|
+
if connection
|
|
163
|
+
missing_device_session_columns(connection)
|
|
164
|
+
else
|
|
165
|
+
ActiveRecord::Base.connection_pool.with_connection { |conn| missing_device_session_columns(conn) }
|
|
166
|
+
end
|
|
167
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
168
|
+
logger&.debug("[studio-engine] device session schema check skipped: #{e.class}: #{e.message}")
|
|
169
|
+
return []
|
|
170
|
+
end
|
|
171
|
+
return missing if missing.empty?
|
|
172
|
+
|
|
173
|
+
text = device_session_message(missing)
|
|
174
|
+
logger&.warn("[studio-engine] #{text}")
|
|
175
|
+
report_to_sentry(text, level: :warning)
|
|
176
|
+
missing
|
|
177
|
+
end
|
|
178
|
+
|
|
128
179
|
# True while a rake task is running: the app booted to migrate, install or
|
|
129
180
|
# precompile, not to serve.
|
|
130
181
|
def inside_rake_task?
|
|
@@ -133,10 +184,10 @@ module Studio
|
|
|
133
184
|
::Rake.application.top_level_tasks.any?
|
|
134
185
|
end
|
|
135
186
|
|
|
136
|
-
def report_to_sentry(text)
|
|
187
|
+
def report_to_sentry(text, level: :error)
|
|
137
188
|
return unless defined?(::Sentry) && ::Sentry.respond_to?(:capture_message)
|
|
138
189
|
|
|
139
|
-
::Sentry.capture_message(text, level:
|
|
190
|
+
::Sentry.capture_message(text, level: level)
|
|
140
191
|
rescue StandardError
|
|
141
192
|
nil
|
|
142
193
|
end
|
data/lib/studio/page_header.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
|
-
# studio/_page_header's
|
|
5
|
-
# slot (docs/PAGE_HEADER.md, "The four kinds of
|
|
4
|
+
# studio/_page_header's Ruby: whether a row is self-styled (self_styled?), and the
|
|
5
|
+
# deprecation note for its old free-HTML slot (docs/PAGE_HEADER.md, "The four kinds of
|
|
6
|
+
# control").
|
|
6
7
|
#
|
|
7
8
|
# actions_html still renders for one release. In development only, the first render
|
|
8
9
|
# that passes it writes one warning to the log per call site, so a host sees each
|
|
@@ -16,6 +17,14 @@ module Studio
|
|
|
16
17
|
@mutex = Mutex.new
|
|
17
18
|
|
|
18
19
|
class << self
|
|
20
|
+
# Whether this view's rows bring their own CSS and script (config.page_header_self_styled,
|
|
21
|
+
# docs/PAGE_HEADER.md, "In a plain-CSS app"): true, false, or a callable that receives
|
|
22
|
+
# the view. Anything truthy is true.
|
|
23
|
+
def self_styled?(view, setting = Studio.page_header_self_styled)
|
|
24
|
+
setting = setting.call(view) if setting.respond_to?(:call)
|
|
25
|
+
setting ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
# site: the caller's "path:line", so each page warns once. env and logger default to
|
|
20
29
|
# Rails' and are injectable for a test.
|
|
21
30
|
def deprecate_actions_html(site: nil, env: rails_env, logger: rails_logger)
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -157,6 +157,16 @@ module Studio
|
|
|
157
157
|
mattr_accessor :theme_logos, default: []
|
|
158
158
|
mattr_accessor :sticky_table_headers, default: false
|
|
159
159
|
|
|
160
|
+
# ---- The self-styled page header ------------------------------------------
|
|
161
|
+
# For an app that does not build the engine's Tailwind layer (a plain-CSS app,
|
|
162
|
+
# such as rantly): studio/_page_header then brings its own scoped CSS and the
|
|
163
|
+
# small script for its stuck bar and its Links menu, once per page, as the site
|
|
164
|
+
# footer does (docs/PAGE_HEADER.md, "In a plain-CSS app"). true, false (the
|
|
165
|
+
# default: the row renders exactly as it always has, for a Tailwind app), or a
|
|
166
|
+
# callable that receives the view.
|
|
167
|
+
# Studio::PageHeader.self_styled? reads it.
|
|
168
|
+
mattr_accessor :page_header_self_styled, default: false
|
|
169
|
+
|
|
160
170
|
# ---- Smooth-load convention ---------------------------------------------
|
|
161
171
|
# Opt-in per app: renders the view-transition + no-preview metas so Turbo 8
|
|
162
172
|
# page swaps materialize behind the current page and present with a view
|
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.136.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -423,10 +423,12 @@ files:
|
|
|
423
423
|
- app/assets/images/state-flags/wy.svg
|
|
424
424
|
- app/assets/javascripts/studio/alpine.js
|
|
425
425
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
426
|
+
- app/assets/javascripts/studio/cropper-1.6.2.min.js
|
|
426
427
|
- app/assets/javascripts/studio/leaflet.js
|
|
427
428
|
- app/assets/javascripts/studio/page_transition_reveal.js
|
|
428
429
|
- app/assets/javascripts/studio/session.js
|
|
429
430
|
- app/assets/javascripts/studio/sortable.js
|
|
431
|
+
- app/assets/stylesheets/studio/cropper-1.6.2.min.css
|
|
430
432
|
- app/assets/stylesheets/studio/leaflet.css
|
|
431
433
|
- app/assets/stylesheets/studio/page_transition_full_load.css
|
|
432
434
|
- app/assets/stylesheets/studio/sticky_table_header.css
|
|
@@ -488,6 +490,7 @@ files:
|
|
|
488
490
|
- app/helpers/studio/geo_helper.rb
|
|
489
491
|
- app/helpers/studio/link_preview_helper.rb
|
|
490
492
|
- app/helpers/studio/loader_helper.rb
|
|
493
|
+
- app/helpers/studio/page_header_helper.rb
|
|
491
494
|
- app/helpers/studio/public_user_helper.rb
|
|
492
495
|
- app/helpers/studio/site_footer_helper.rb
|
|
493
496
|
- app/helpers/studio_email_delivery_helper.rb
|
|
@@ -561,10 +564,12 @@ files:
|
|
|
561
564
|
- app/javascript/studio/nav_collapse.js
|
|
562
565
|
- app/javascript/studio/newsletter_modal.js
|
|
563
566
|
- app/javascript/studio/page_header.js
|
|
567
|
+
- app/javascript/studio/page_header_self_styled.js
|
|
564
568
|
- app/javascript/studio/page_transitions.js
|
|
565
569
|
- app/javascript/studio/pinned_stack.js
|
|
566
570
|
- app/javascript/studio/profile_form.js
|
|
567
571
|
- app/javascript/studio/pull_to_refresh.js
|
|
572
|
+
- app/javascript/studio/reload_visits.js
|
|
568
573
|
- app/javascript/studio/sticky_table_header.js
|
|
569
574
|
- app/javascript/studio/stimulus.js
|
|
570
575
|
- app/javascript/studio/studio_confetti.js
|
|
@@ -656,6 +661,7 @@ files:
|
|
|
656
661
|
- app/views/studio/_fizz_layer.html.erb
|
|
657
662
|
- app/views/studio/_hold_button.html.erb
|
|
658
663
|
- app/views/studio/_hold_button_guard.html.erb
|
|
664
|
+
- app/views/studio/_legal_page.html.erb
|
|
659
665
|
- app/views/studio/_leveling_activity_assets.html.erb
|
|
660
666
|
- app/views/studio/_not_found.html.erb
|
|
661
667
|
- app/views/studio/_page_header.html.erb
|
|
@@ -774,6 +780,7 @@ files:
|
|
|
774
780
|
- app/views/studio/newsletter_mailer/subscribed.html.erb
|
|
775
781
|
- app/views/studio/newsletter_mailer/subscribed.text.erb
|
|
776
782
|
- app/views/studio/not_found/show.html.erb
|
|
783
|
+
- app/views/studio/page_header/_assets.html.erb
|
|
777
784
|
- app/views/studio/profile_mailer/email_change_notification.html.erb
|
|
778
785
|
- app/views/studio/profile_mailer/email_change_notification.text.erb
|
|
779
786
|
- app/views/studio/profiles/_birthday_fields.html.erb
|
|
@@ -870,6 +877,7 @@ files:
|
|
|
870
877
|
- db/migrate/20261010120000_add_stored_in_to_image_caches.rb
|
|
871
878
|
- db/migrate/20261029120000_add_voice_profile_to_users.rb
|
|
872
879
|
- db/migrate/20261103120000_create_studio_user_sessions.rb
|
|
880
|
+
- db/migrate/20261108120000_add_device_revocation_to_studio_user_sessions.rb
|
|
873
881
|
- docs/BOOKING.md
|
|
874
882
|
- docs/SITE_FOOTER.md
|
|
875
883
|
- lib/active_storage/service/studio_trash_s3_service.rb
|