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.
@@ -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 (default: true).
15
- Pass false on a page that carries its own h1: the brand is
16
- drawn as a div with the same classes and contents.
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
- <% brand_tag = local_assigns.fetch(:brand_heading, true) ? "h1" : "div" %>
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
- Both tags are pinned with Subresource Integrity, so a changed file on the CDN
13
- fails to load rather than running. The hashes are sha384 over the exact
14
- 1.6.2 files; a version bump recomputes both (openssl dgst -sha384 -binary
15
- FILE | openssl base64 -A), replaces the byte-exact copies in
16
- test/fixtures/files/cropperjs-<version> that the browser lane serves, and
17
- runs bin/cropper-sri-live. Keep the URLs exact: consumer CSPs name the
18
- script by file. %>
19
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css"
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="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js"
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
- <div class="page-header-sentinel" data-studio-controller="page-header" aria-hidden="true"></div>
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()), and studio/session.js, the one classic
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
 
@@ -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: :error)
190
+ ::Sentry.capture_message(text, level: level)
140
191
  rescue StandardError
141
192
  nil
142
193
  end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Studio
4
- # studio/_page_header's one piece of Ruby: the deprecation note for its old free-HTML
5
- # slot (docs/PAGE_HEADER.md, "The four kinds of control").
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)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.135.2"
2
+ VERSION = "0.136.0"
3
3
  end
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.135.2
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