studio-engine 0.89.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eeb8af5c3521e31b4bda82538bc2053bf4ff364008f616454ce89aa267d30643
4
- data.tar.gz: b4374bb37c27468e3b20df3656973a1818df2445aa7b0a0b6c242dc2d67f08fd
3
+ metadata.gz: 0cf7b3c1fa31a2eb5f9746838c8c4976d026920c986504c674f760c57c73a33d
4
+ data.tar.gz: c39625c99d384e68eedfee1a31dde4816e6f3aa2f004f6ab2f88c4e10d447b00
5
5
  SHA512:
6
- metadata.gz: c107ea4e17a719e1612a243550c0427bc5868e5ebc0219ab913e12312d4580fee715788afd80cdb6617f7ef4b8df799e67e8507fd3851db2d2f71e85fcf07bd0
7
- data.tar.gz: f41f199936bd5bd48bf226b0df833679c956ca84671c86971f9a115b88c666885ebe4229814fd17b240cf8bed981da5660315dc75bdd8185ee9df4c51f185a1a
6
+ metadata.gz: bfeb555efeae9e981014cf5afb73c8e1eb7b01c90d92dc0c1ba6838d5c1acc950ddc908846be93d15d23ac07a9f5acf277c103c3d95b4611e85b758de5c940f0
7
+ data.tar.gz: 728932ef4510055ab9b8cd3d6f472abe228ee632febfe007251beb242aec114a1e11c77381a63e44167fb7817c79d8d6716d24687e4261b4c660968e23ffef8c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.91.0 — 2026-10-06
8
+
9
+ ### Added
10
+
11
+ - **`fizz_portal: true` on `studio/hold_button`** lifts the bubbles out of a card
12
+ that clips them: the layers move to `<body>` in a fixed box that tracks the
13
+ button through scroll and resize, paints one rung above the button's highest
14
+ ancestor z-index, and mirrors its state and palette. The default is off, so
15
+ every existing caller renders as before. The style guide's live hold button
16
+ uses it.
17
+
18
+ ## 0.90.0 — 2026-10-06
19
+
20
+ ### Removed
21
+
22
+ - **`components/card`, `components/input` and `components/progress_bar`.** No
23
+ app renders these partials. The `card` and `input-field` utilities they wrapped
24
+ still ship; write the classes on your own markup.
25
+ - **`Studio::Impersonation`** and its four settings,
26
+ `impersonation_target_session_key`, `impersonation_actor_session_key`,
27
+ `impersonation_started_at_session_key` and `impersonation_max_minutes`. No app
28
+ includes the concern; each owns its Act As session. The
29
+ `studio/banners/impersonation` banner stays.
30
+ - **`Studio::Broadcastable`.** No app includes it. Wrap a broadcast in
31
+ `Studio::Cable.safe_broadcast { broadcast_replace_to ... }` instead
32
+ (`docs/CABLE.md`).
33
+ - **`Studio::UserProfile`.** No app includes it; each `User` defines
34
+ `display_name`, `avatar_initials` and `avatar_color` itself. The initial-circle
35
+ palette moves to `Studio::AVATAR_COLORS`.
36
+ - **`/admin/email_images`**, `Studio::EmailImagesController`, its view and the
37
+ `admin_email_images_path` and `admin_email_image_path` helpers. `/admin/emails`
38
+ replaces the page, and no app links or tests it.
39
+
7
40
  ## 0.89.0 — 2026-10-05
8
41
 
9
42
  ### Changed
data/README.md CHANGED
@@ -34,7 +34,7 @@ resolved.
34
34
  - **Session drift**: every page carries a session stamp and loads `window.StudioSession`, which notices when another tab signs in or out, the session expires, or the server revokes it; `session:changed` / `session:mismatch` events, cross-tab sync, an identity-source plug-in interface, and `expectChange` holds for deliberate switches. The rehydrate endpoint (`GET /session/state`) is opt-in. See [`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md).
35
35
  - **Theme system**: Dynamic CSS custom properties generated from 7 role colors (primary, dark, light, success, accent, warning, danger). Dark/light mode toggle. Admin theme editor at `/admin/theme`.
36
36
  - **UI primitives**: Shared component partials and CSS primitives such as `components/emoji_swap` for nav/sidebar emoji hover transitions.
37
- - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
37
+ - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls and `studio/banners/impersonation`.
38
38
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
39
39
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
40
40
  - **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).
@@ -169,22 +169,8 @@ with their own users and return route:
169
169
  stop_path: admin_stop_impersonating_path %>
170
170
  ```
171
171
 
172
- The engine also provides an optional `Studio::Impersonation` concern for the
173
- session convention:
174
-
175
- ```ruby
176
- class ApplicationController < ActionController::Base
177
- include Studio::ErrorHandling
178
- include Studio::Impersonation
179
- end
180
- ```
181
-
182
- The concern adds `true_user`, `impersonated_user`, `impersonating?`,
183
- `start_impersonation_session(target_user, actor:)`, and
184
- `clear_impersonation_session`. Consumer apps still own the authorization rule,
185
- audit log, enter/exit controller actions, and any app-specific safeguards such
186
- as binding session-token checks to `true_user` or disabling wallet-only
187
- privileges while impersonating.
172
+ Each app owns its Act As session, authorization rule, audit log and enter/exit
173
+ actions; the engine supplies only the banner.
188
174
 
189
175
  ## UI Primitives
190
176
 
@@ -702,8 +688,7 @@ matching slot is absent, so existing call sites render unchanged.
702
688
  Every consuming app can render the same admin page — **`/admin/emails`** —
703
689
  listing each transactional email it sends with the banner riding at the top of
704
690
  that email, and whether that banner is the **inherited default** or an
705
- **app-owned override**. Supersedes the old `/admin/email_images`, which is
706
- deprecated but still renders for one release.
691
+ **app-owned override**.
707
692
 
708
693
  **The page is opt-in:**
709
694
 
@@ -1315,6 +1315,8 @@ body.modal-open { overflow: visible; }
1315
1315
  span.hold-stack positioning + z-order context
1316
1316
  span.hold-fizz resting bubble layer (behind the button)
1317
1317
  span.hold-fizz.hold-fizz-extra hover-only layer (lively level only)
1318
+ span.hold-fizz-portal wraps both layers with fizz_portal: true, and
1319
+ moves to <body> (see THE PORTAL below)
1318
1320
  button.hold-btn the button itself, painted OVER the bubbles
1319
1321
  div.hold-icon progress ring + tick
1320
1322
  ul.hold-text four sliding labels: idle/hold/done/blocked
@@ -1731,14 +1733,17 @@ body.modal-open { overflow: visible; }
1731
1733
  z-index: 1;
1732
1734
  }
1733
1735
 
1734
- .hold-stack > .hold-fizz {
1736
+ .hold-stack > .hold-fizz,
1737
+ .hold-stack > .hold-fizz-portal,
1738
+ .hold-fizz-portal > .hold-fizz {
1735
1739
  position: absolute;
1736
1740
  inset: 0;
1737
1741
  z-index: 0;
1738
1742
  pointer-events: none;
1739
1743
  }
1740
1744
 
1741
- .hold-stack .fizz-bit {
1745
+ .hold-stack .fizz-bit,
1746
+ .hold-fizz-portal .fizz-bit {
1742
1747
  position: absolute;
1743
1748
  width: var(--fs, 4px);
1744
1749
  height: var(--fs, 4px);
@@ -1760,17 +1765,20 @@ body.modal-open { overflow: visible; }
1760
1765
  /* Hover and the nudge shake both mean "look at me" — boil for the moment.
1761
1766
  Calm only: lively rests at this boil already and answers hover its own way,
1762
1767
  below, so it must not pick this rule up (it outranks lively's rest rule). */
1763
- .hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit {
1768
+ .hold-stack:not(.fizz-lively):has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) .fizz-bit,
1769
+ .hold-fizz-portal:not(.fizz-lively).is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) .fizz-bit {
1764
1770
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1765
1771
  }
1766
1772
 
1767
- .hold-stack.fizz-lively .fizz-bit {
1773
+ .hold-stack.fizz-lively .fizz-bit,
1774
+ .hold-fizz-portal.fizz-lively .fizz-bit {
1768
1775
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.62) linear calc(var(--fd, 0s) * 0.35) infinite;
1769
1776
  }
1770
1777
 
1771
1778
  /* The hover layer keeps running while hidden. It has to: killing the animation
1772
1779
  would snap the bubbles to their base opacity: 0 and leave nothing to fade. */
1773
- .hold-stack > .hold-fizz-extra {
1780
+ .hold-stack > .hold-fizz-extra,
1781
+ .hold-fizz-portal > .hold-fizz-extra {
1774
1782
  opacity: 0;
1775
1783
  transition: opacity 0.45s ease;
1776
1784
  }
@@ -1778,29 +1786,69 @@ body.modal-open { overflow: visible; }
1778
1786
  /* The guard list is as much the point as the hover: a held, submitting,
1779
1787
  confirmed or blocked button speaks for itself, so the hover crowd fades away
1780
1788
  and leaves the ring and the burst to read cleanly. */
1781
- .hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra {
1789
+ .hold-stack.fizz-lively:has(> .hold-btn:hover:not(.process):not(.loading):not(.success):not(.error)) > .hold-fizz-extra,
1790
+ .hold-fizz-portal.fizz-lively.is-hover:not(.is-process):not(.is-loading):not(.is-success):not(.is-error) > .hold-fizz-extra {
1782
1791
  opacity: 1;
1783
1792
  }
1784
1793
 
1785
1794
  .hold-stack:has(> .hold-btn.nudge) .fizz-bit,
1786
- .hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit {
1795
+ .hold-stack:has(> .hold-btn.nudge-soft) .fizz-bit,
1796
+ .hold-fizz-portal.is-nudge .fizz-bit,
1797
+ .hold-fizz-portal.is-nudge-soft .fizz-bit {
1787
1798
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.55) linear calc(var(--fd, 0s) * 0.25) infinite;
1788
1799
  }
1789
1800
 
1790
1801
  .hold-stack:has(> .hold-btn.process) .fizz-bit,
1791
- .hold-stack:has(> .hold-btn.loading) .fizz-bit {
1802
+ .hold-stack:has(> .hold-btn.loading) .fizz-bit,
1803
+ .hold-fizz-portal.is-process .fizz-bit,
1804
+ .hold-fizz-portal.is-loading .fizz-bit {
1792
1805
  animation: fizz-boil calc(var(--ft, 1.8s) * 0.45) linear calc(var(--fd, 0s) * 0.2) infinite;
1793
1806
  }
1794
1807
 
1795
- .hold-stack:has(> .hold-btn.success) .fizz-bit {
1808
+ .hold-stack:has(> .hold-btn.success) .fizz-bit,
1809
+ .hold-fizz-portal.is-success .fizz-bit {
1796
1810
  animation: fizz-burst 1.1s cubic-bezier(0.12, 0.72, 0.24, 1) forwards;
1797
1811
  }
1798
1812
 
1799
- .hold-stack:has(> .hold-btn.error) .fizz-bit {
1813
+ .hold-stack:has(> .hold-btn.error) .fizz-bit,
1814
+ .hold-fizz-portal.is-error .fizz-bit {
1800
1815
  animation: none;
1801
1816
  opacity: 0;
1802
1817
  }
1803
1818
 
1819
+ /* THE PORTAL (studio/hold_button, fizz_portal: true). A button inside an
1820
+ overflow-hidden card has its bubbles cut off at the card's edge, because the
1821
+ layer is absolutely positioned inside the card's clip. With the option set,
1822
+ the button's script moves the layers out of the stack into one fixed box on
1823
+ <body> that tracks the stack's rect, so the bubbles rise past the card's edge
1824
+ instead.
1825
+
1826
+ Out of the stack, the state rules above cannot read the button through
1827
+ :has(), so the script mirrors its state onto the box as .is-process,
1828
+ .is-loading, .is-success, .is-error, .is-nudge, .is-nudge-soft and .is-hover,
1829
+ and copies the stack's --fizz-c-* palette onto it.
1830
+
1831
+ It paints one rung above the highest z-index among the button's ancestors
1832
+ (never below --z-raised), which the script sets inline. A page's own sticky
1833
+ or fixed bar must therefore sit ABOVE --z-raised (--z-sticky is the rung for
1834
+ one); a bar at z 20 or lower, such as Tailwind's sticky z-10 or z-20, ties or
1835
+ loses to the box, and the bubbles paint over it. Painting above the
1836
+ button would put bubbles on its face, so the clip path cuts the button's own
1837
+ rect out of the box: the bubbles show only outside it, as they do in the
1838
+ stack. The 50vmax margin is the room they have to rise. */
1839
+ .hold-fizz-portal.is-portaled {
1840
+ position: fixed;
1841
+ top: 0;
1842
+ left: 0;
1843
+ z-index: var(--z-raised, 20);
1844
+ pointer-events: none;
1845
+ clip-path: polygon(evenodd,
1846
+ -50vmax -50vmax, calc(100% + 50vmax) -50vmax,
1847
+ calc(100% + 50vmax) calc(100% + 50vmax), -50vmax calc(100% + 50vmax),
1848
+ -50vmax -50vmax,
1849
+ 0 0, 100% 0, 100% 100%, 0 100%, 0 0);
1850
+ }
1851
+
1804
1852
  @keyframes hold-tick {
1805
1853
  100% { transform: rotate(-90deg) translate(0, -5px) scale(var(--progress-scale)); }
1806
1854
  }
@@ -1867,7 +1915,9 @@ body.modal-open { overflow: visible; }
1867
1915
  it not to. */
1868
1916
  @media (prefers-reduced-motion: reduce) {
1869
1917
  .hold-stack .fizz-bit,
1870
- .hold-stack .hold-fizz-extra {
1918
+ .hold-stack .hold-fizz-extra,
1919
+ .hold-fizz-portal .fizz-bit,
1920
+ .hold-fizz-portal .hold-fizz-extra {
1871
1921
  animation: none !important;
1872
1922
  opacity: 0 !important;
1873
1923
  transition: none !important;
@@ -7,7 +7,7 @@ module Studio
7
7
  # It lists Studio::EmailCatalog's registry — one row per registered email, each
8
8
  # showing its live banner and whether that banner is the INHERITED engine
9
9
  # default or an APP-OWNED override — and writes an override through the shared
10
- # crop modal. Replaces /admin/email_images, which now redirects here.
10
+ # crop modal.
11
11
  #
12
12
  # An app whose host never set Studio.s3_bucket_prefix cannot store an override.
13
13
  # That is a read-only page, not an error: uploads_available? gates the write
@@ -762,8 +762,8 @@ module Studio
762
762
  # THE SAME QUESTION background_url ASKS, so it must ask it the same way.
763
763
  # This guard read engine_artwork? while background_url read layered?, and the
764
764
  # two answer differently for a host that registers its own background: the
765
- # mailer sent the layered banner while /admin/email_images and the detail
766
- # page's "Artwork" frame both drew the flat asset. That frame is where
765
+ # mailer sent the layered banner while the detail page's "Artwork" frame
766
+ # drew the flat asset. That frame is where
767
767
  # "Modify image" lives, and an upload writes the row background_url reads
768
768
  # FIRST — so the operator was shown one picture and told it was the one the
769
769
  # button would replace.
@@ -20,8 +20,7 @@ module Studio
20
20
  # stays green. A host that did reference it gets a NameError, not a silent
21
21
  # wrong answer.
22
22
  #
23
- # Delete it once no consumer's main names it — the same staged retirement
24
- # /admin/email_images is on.
23
+ # Delete it once no consumer's main names it.
25
24
  #
26
25
  # Deliberately explicit rather than method_missing: a typo should still raise
27
26
  # NoMethodError here, and the delegated surface should be readable as a list.
@@ -39,6 +39,13 @@
39
39
  layer, for a button that should not compete.
40
40
  fizz_colors - Up to 12 CSS colors the bubbles wear, as a static
41
41
  array (default: none = the built-in candy palette).
42
+ fizz_portal - Lift the bubbles out of the stack into a fixed layer on
43
+ <body> that tracks the button (default: false). For a
44
+ button inside an overflow-hidden card, whose bubbles
45
+ are otherwise cut off at the card's edge: with it set
46
+ they rise past the edge. The button, its timers and its
47
+ states are the same either way. See THE PORTAL in
48
+ engine-motion.css.
42
49
  fizz_bind - Alpine expression bound to the stack's :style, for a
43
50
  palette that changes at runtime (default: none). The
44
51
  turf-monster's board passes "fizzPalette", mapping
@@ -65,6 +72,7 @@
65
72
  <% fizz_colors ||= nil %>
66
73
  <% fizz_bind ||= nil %>
67
74
  <% fizz_level ||= :lively %>
75
+ <% fizz_portal = fizz && local_assigns[:fizz_portal] %>
68
76
  <%# Static palette (gallery, or any call site with fixed colors). A runtime
69
77
  palette rides fizz_bind instead. %>
70
78
  <% fizz_style = Array(fizz_colors).first(Studio::FizzHelper::SLOTS)
@@ -75,11 +83,17 @@
75
83
  transform makes it a stacking context), so the bubbles have to be a
76
84
  sibling the button paints over — they only show where they escape its
77
85
  edges, like carbonation coming off the sides. %>
78
- <span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %>>
86
+ <span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %><% if fizz_portal %> data-fizz-portal x-init="window.studioFizzPortal && window.studioFizzPortal.mount($el)"<% end %>>
79
87
  <%# Fizz layer — bubbles that simmer at rest, boil while the button is held,
80
88
  and burst outward on success. Purely decorative, so it is aria-hidden and
81
89
  pointer-events:none; the table behind it is seeded per hold_id (see Studio::FizzHelper). %>
82
90
  <% if fizz %>
91
+ <%# With fizz_portal the layers ride one wrapper, which the script below
92
+ moves to <body>. Until it does, the wrapper sits in the stack exactly
93
+ where the layers would. %>
94
+ <% if fizz_portal %>
95
+ <span class="hold-fizz-portal<%= " fizz-lively" if fizz_level.to_s == "lively" %>" aria-hidden="true">
96
+ <% end %>
83
97
  <%= render "studio/fizz_layer", seed: hold_id %>
84
98
  <%# Lively's hover doubles the COUNT, not the speed: a second scatter that
85
99
  rests invisible, fades in over the gaps in the first, and fades back
@@ -87,6 +101,9 @@
87
101
  <% if fizz_level.to_s == "lively" %>
88
102
  <%= render "studio/fizz_layer", seed: "#{hold_id}~extra", extra: true %>
89
103
  <% end %>
104
+ <% if fizz_portal %>
105
+ </span>
106
+ <% end %>
90
107
  <% end %>
91
108
  <button class="hold-btn"
92
109
  data-hold-id="<%= hold_id %>"
@@ -236,6 +253,151 @@
236
253
  Object.keys(nudgeTimers).forEach(function(id) { stopNudgeCycle(id); });
237
254
  });
238
255
 
256
+ // ── The fizz portal (fizz_portal: true) ─────────────────────────────
257
+ // Moves a stack's bubble layers to the document body in one fixed box that tracks the
258
+ // stack's rect, so a card's overflow clip cannot cut them off. The box
259
+ // mirrors the button's state classes and the stack's palette, because the
260
+ // CSS can no longer read either through the stack. engine-motion.css,
261
+ // THE PORTAL, holds the other half.
262
+ var PORTAL_STATES = ['process', 'loading', 'success', 'error', 'nudge', 'nudge-soft'];
263
+ var portals = [];
264
+
265
+ // One rung above the highest z-index among the stack's ancestors, so the
266
+ // bubbles clear the card, the panel or the modal the button sits in,
267
+ // and never below --z-raised.
268
+ function portalZ(stack) {
269
+ var floor = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-raised'), 10);
270
+ var top = isNaN(floor) ? 20 : floor;
271
+ for (var n = stack.parentElement; n && n !== document.body; n = n.parentElement) {
272
+ var z = parseInt(getComputedStyle(n).zIndex, 10);
273
+ if (!isNaN(z) && z + 1 > top) top = z + 1;
274
+ }
275
+ return top;
276
+ }
277
+
278
+ function syncPortalState(p) {
279
+ PORTAL_STATES.forEach(function(s) {
280
+ p.box.classList.toggle('is-' + s, p.button.classList.contains(s));
281
+ });
282
+ }
283
+
284
+ function syncPortalPalette(p) {
285
+ var cs = getComputedStyle(p.stack);
286
+ for (var i = 1; i <= <%= Studio::FizzHelper::SLOTS %>; i++) {
287
+ var v = cs.getPropertyValue('--fizz-c-' + i).trim();
288
+ if (v) p.box.style.setProperty('--fizz-c-' + i, v);
289
+ else p.box.style.removeProperty('--fizz-c-' + i);
290
+ }
291
+ }
292
+
293
+ function placePortal(p) {
294
+ if (!p.stack.isConnected) { unmountPortal(p, false); return; }
295
+ var r = p.stack.getBoundingClientRect();
296
+ var shown = p.visible && r.width > 0 && r.height > 0;
297
+ p.box.style.visibility = shown ? '' : 'hidden';
298
+ if (shown) {
299
+ p.box.style.transform = 'translate(' + r.left + 'px, ' + r.top + 'px)';
300
+ p.box.style.width = r.width + 'px';
301
+ p.box.style.height = r.height + 'px';
302
+ if (p.parked) { clearInterval(p.parked); p.parked = null; }
303
+ p.frame = requestAnimationFrame(function() { placePortal(p); });
304
+ } else {
305
+ p.frame = null;
306
+ // Parked: no frames run, so nothing would notice the stack leaving the
307
+ // DOM (an x-if, a Turbo stream, a closed modal) until the next page
308
+ // load. A slow check tears the box and its observers down when it does.
309
+ if (!p.parked) {
310
+ p.parked = setInterval(function() {
311
+ if (!p.stack.isConnected) unmountPortal(p, false);
312
+ }, 1000);
313
+ }
314
+ }
315
+ }
316
+
317
+ function unmountPortal(p, restore) {
318
+ if (p.frame) cancelAnimationFrame(p.frame);
319
+ if (p.parked) clearInterval(p.parked);
320
+ p.observers.forEach(function(o) { o.disconnect(); });
321
+ p.button.removeEventListener('mouseenter', p.enter);
322
+ p.button.removeEventListener('mouseleave', p.leave);
323
+ if (restore && p.stack.isConnected) {
324
+ // Back where the server rendered it, with nothing the script added,
325
+ // so a cached snapshot restores to the markup it came from.
326
+ p.box.className = p.box.className.replace(/\s*\bis-[\w-]+/g, '');
327
+ p.box.removeAttribute('style');
328
+ p.box.removeAttribute('data-fizz-portal-for');
329
+ p.stack.insertBefore(p.box, p.stack.firstChild);
330
+ } else {
331
+ p.box.remove();
332
+ }
333
+ delete p.stack._fizzPortal;
334
+ portals = portals.filter(function(q) { return q !== p; });
335
+ }
336
+
337
+ function mountPortal(stack) {
338
+ if (stack._fizzPortal) return stack._fizzPortal;
339
+ var box = stack.querySelector(':scope > .hold-fizz-portal');
340
+ var button = stack.querySelector(':scope > .hold-btn');
341
+ if (!box || !button) return null;
342
+
343
+ var p = { stack: stack, box: box, button: button, visible: true, frame: null, parked: null, observers: [] };
344
+ stack._fizzPortal = p;
345
+ portals.push(p);
346
+
347
+ document.body.appendChild(box);
348
+ box.classList.add('is-portaled');
349
+ box.setAttribute('data-fizz-portal-for', button.dataset.holdId || 'hold');
350
+ box.style.zIndex = portalZ(stack);
351
+ syncPortalState(p);
352
+ syncPortalPalette(p);
353
+
354
+ p.enter = function() { box.classList.add('is-hover'); };
355
+ p.leave = function() { box.classList.remove('is-hover'); };
356
+ button.addEventListener('mouseenter', p.enter);
357
+ button.addEventListener('mouseleave', p.leave);
358
+
359
+ var states = new MutationObserver(function() { syncPortalState(p); });
360
+ states.observe(button, { attributes: true, attributeFilter: ['class'] });
361
+ var palette = new MutationObserver(function() { syncPortalPalette(p); });
362
+ palette.observe(stack, { attributes: true, attributeFilter: ['style'] });
363
+ p.observers.push(states, palette);
364
+
365
+ // Track the rect every frame while the stack is on screen: a visible
366
+ // portal reads one rect per frame. Park the box while the stack is not
367
+ // on screen (scrolled away, inside a closed panel, or clipped out of a
368
+ // scroll container): no frames, only a once-a-second check that the
369
+ // stack is still in the DOM.
370
+ if ('IntersectionObserver' in window) {
371
+ var seen = new IntersectionObserver(function(entries) {
372
+ p.visible = entries[entries.length - 1].isIntersecting;
373
+ if (!p.stack.isConnected) { unmountPortal(p, false); return; }
374
+ if (p.visible && !p.frame) placePortal(p);
375
+ if (!p.visible) placePortal(p);
376
+ });
377
+ seen.observe(stack);
378
+ p.observers.push(seen);
379
+ }
380
+ placePortal(p);
381
+ return p;
382
+ }
383
+
384
+ function mountAllPortals() {
385
+ portals.slice().forEach(function(p) { if (!p.stack.isConnected) unmountPortal(p, false); });
386
+ document.querySelectorAll('.hold-stack[data-fizz-portal]').forEach(mountPortal);
387
+ }
388
+
389
+ window.studioFizzPortal = {
390
+ mount: mountPortal,
391
+ mountAll: mountAllPortals,
392
+ count: function() { return portals.length; }
393
+ };
394
+ document.addEventListener('turbo:load', mountAllPortals);
395
+ document.addEventListener('turbo:before-cache', function() {
396
+ portals.slice().forEach(function(p) { unmountPortal(p, true); });
397
+ });
398
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAllPortals);
399
+ else mountAllPortals();
400
+
239
401
  window.holdBtnStart = function(el) {
240
402
  var holdId = el.dataset.holdId || 'hold';
241
403
  var scope = el.closest('[x-data]');
@@ -19,7 +19,7 @@
19
19
  <%= render "components/avatar", user: @public_user, size: "xl", alt: username %>
20
20
  <% else %>
21
21
  <%# Drawn from the username alone: a host's avatar_initials / avatar_color may read the name or email (cyvasse's do). %>
22
- <% colors = Studio::UserProfile::AVATAR_COLORS %>
22
+ <% colors = Studio::AVATAR_COLORS %>
23
23
  <div class="w-28 h-28 text-4xl rounded-full flex items-center justify-center font-bold text-white"
24
24
  style="background-color: <%= colors[Digest::MD5.hexdigest(username).hex % colors.size] %>"><%= username[0].upcase %></div>
25
25
  <% end %>
@@ -344,6 +344,8 @@
344
344
  brand — and every color also reads a <code class="font-mono text-2xs">--hold-*</code>
345
345
  input, so one instance can be re-themed from any ancestor.
346
346
  <code class="font-mono text-2xs">prefers-reduced-motion</code> switches the bubbles off.
347
+ Inside a card that clips, pass <code class="font-mono text-2xs">fizz_portal: true</code>
348
+ and the bubbles rise past the card&rsquo;s edge instead of stopping at it.
347
349
  </p>
348
350
  </div>
349
351
  <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -360,12 +362,14 @@
360
362
  </div>
361
363
  <% end %>
362
364
 
363
- <%# 2. The button itself, live: press and hold for two seconds. %>
365
+ <%# 2. The button itself, live: press and hold for two seconds. The card
366
+ clips, so this one sets fizz_portal and its bubbles rise past the
367
+ card's edge. %>
364
368
  <%= render layout: "style/specimen",
365
369
  locals: { klass: ".hold-btn (studio/hold_button)",
366
- usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000) } do %>
370
+ usage: %(render "studio/hold_button", hold_id: "confirm", duration: 2000, fizz_portal: true) } do %>
367
371
  <div class="w-full max-w-xs" x-data="{ submitting: false }">
368
- <%= render "studio/hold_button", hold_id: "style-hold", duration: 2000,
372
+ <%= render "studio/hold_button", hold_id: "style-hold", duration: 2000, fizz_portal: true,
369
373
  default_text: "Hold to Confirm", hold_text: "Almost there…", success_text: "Confirmed!" %>
370
374
  <button type="button" class="btn btn-neutral btn-sm mt-3 mx-auto block"
371
375
  @click="document.querySelector('.hold-btn[data-hold-id=style-hold]').classList.remove('process','success','error','loading')">Reset</button>
@@ -65,9 +65,11 @@
65
65
 
66
66
  <%# Section nav — sticky under the host navbar (--nav-h, 0 fallback). A pill per
67
67
  section; the row scrolls horizontally on narrow screens, its edges softened
68
- by the .fade-edge-x primitive. Order leads with Theme (the color foundation). %>
69
- <nav class="sticky z-20 -mx-4 px-4 py-3 bg-surface border-y border-subtle"
70
- style="top: var(--nav-h, 0px)" aria-label="Style guide sections">
68
+ by the .fade-edge-x primitive. Order leads with Theme (the color foundation).
69
+ It sits at --z-sticky, above --z-raised, so the live hold button's portal
70
+ bubbles scroll under it rather than over it. %>
71
+ <nav class="sticky -mx-4 px-4 py-3 bg-surface border-y border-subtle"
72
+ style="top: var(--nav-h, 0px); z-index: var(--z-sticky, 30)" aria-label="Style guide sections">
71
73
  <div class="fade-edge-x overflow-x-auto">
72
74
  <div class="flex items-center gap-2 min-w-max">
73
75
  <% sections.each do |anchor, label| %>
data/lib/studio/engine.rb CHANGED
@@ -113,8 +113,8 @@ module Studio
113
113
  # skip :require_authentication, its mailers and jobs subclass ActionMailer
114
114
  # and ActiveJob, and the two concerns roots (app/controllers/concerns,
115
115
  # app/models/concerns) are roots of their OWN to Zeitwerk: excluding
116
- # app/models does not exclude app/models/concerns, and Sluggable and
117
- # Studio::UserProfile sit there. They stay lazily loadable, and nothing in a
116
+ # app/models does not exclude app/models/concerns, and Sluggable sits
117
+ # there. They stay lazily loadable, and nothing in a
118
118
  # host that draws no Studio.routes asks for them.
119
119
  #
120
120
  # Read off the engine's own eager-load paths rather than listed, so a root
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.89.0"
2
+ VERSION = "0.91.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -839,6 +839,10 @@ module Studio
839
839
  # Studio::ProfilesController to get it would couple the two for no reason.
840
840
  FIRST_NAME_MAX_LENGTH = 40
841
841
 
842
+ # The house palette for initial-circle backgrounds. /u/:username hashes the
843
+ # username into it, so one person draws the same circle on every render.
844
+ AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
845
+
842
846
  # How long a WHOLE typed answer may be — one string holding a full name, which
843
847
  # the onboarding step stores as users.name and SPLITS into the two halves
844
848
  # above (Studio::NameParts).
@@ -914,14 +918,6 @@ module Studio
914
918
  true
915
919
  end
916
920
 
917
- # Optional admin Act As / impersonation session conventions. Consumers that
918
- # include Studio::Impersonation get current_user layered over true_user with
919
- # these session keys, but still own authorization, audit logging, and routes.
920
- mattr_accessor :impersonation_target_session_key, default: :impersonated_user_id
921
- mattr_accessor :impersonation_actor_session_key, default: :true_admin_id
922
- mattr_accessor :impersonation_started_at_session_key, default: :impersonation_started_at
923
- mattr_accessor :impersonation_max_minutes, default: 30
924
-
925
921
  # Cap for the host app's local (development + test) log file, in bytes.
926
922
  # nil means "use the engine's defaults" — Studio::Engine::DEVELOPMENT_LOG_MAX_BYTES
927
923
  # and ::TEST_LOG_MAX_BYTES. An Integer sets your own cap; `false` opts out and
@@ -1530,9 +1526,6 @@ module Studio
1530
1526
  # stores this app's own override; destroy drops it back to the inherited
1531
1527
  # default. Surfaced from each app's admin sidebar.
1532
1528
  #
1533
- # /admin/email_images redirects here but KEEPS its admin_email_images_path
1534
- # helper, so a shipped host sidebar link on the old helper still resolves
1535
- # (same treatment as /admin/design_system -> /admin/style).
1536
1529
  # OPT-IN, and it has to be. turf-monster ALREADY owns /admin/emails —
1537
1530
  # `namespace :admin { get "emails", as: :emails }` (its EmailCatalog
1538
1531
  # manager) — which claims the SAME path and the SAME helper names,
@@ -1592,17 +1585,6 @@ module Studio
1592
1585
  as: :onboarding_skip_first_name
1593
1586
  end
1594
1587
 
1595
- # DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
1596
- # each consumer's DEFAULT-BRANCH suite against this engine, and both
1597
- # mcritchie-studio and turf-monster have tests on `main` that GET this page
1598
- # and PATCH through admin_email_image_path. Redirecting (or deleting) here
1599
- # reddens their lanes the moment the PR opens, and no change inside the
1600
- # engine PR can fix it. Each app's adoption task moves its link + tests; a
1601
- # later engine minor deletes these two routes with the controller and view.
1602
- get "admin/email_images", to: "studio/email_images#index", as: :admin_email_images
1603
- patch "admin/email_images/:variant", to: "studio/email_images#update", as: :admin_email_image,
1604
- constraints: { variant: /[a-z_]+/ }
1605
-
1606
1588
  # Surveys (docs/SURVEYS.md). Both halves OPT-IN — see
1607
1589
  # Studio.draw_survey_routes and Studio.draw_admin_survey_routes.
1608
1590
  if Studio.draw_survey_routes
@@ -40,7 +40,7 @@ Gem::Specification.new do |spec|
40
40
  spec.add_dependency "mini_magick", "~> 5.0"
41
41
  spec.add_dependency "resend", "~> 1.1"
42
42
  # Realtime: the redis cable/cache/Sidekiq adapter (Studio::Redis) + Turbo Streams
43
- # broadcasting (Studio::Broadcastable). `redis` is the dependency whose ABSENCE
43
+ # broadcasting (Studio::Cable). `redis` is the dependency whose ABSENCE
44
44
  # 500'd a host app's task board — declaring it here makes that impossible to repeat.
45
45
  #
46
46
  # The `< 6` ceiling is ActionCable's, not ours. Its redis pubsub adapter
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.89.0
4
+ version: 0.91.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -323,7 +323,6 @@ files:
323
323
  - app/controllers/concerns/studio/board/reorderable.rb
324
324
  - app/controllers/concerns/studio/error_handling.rb
325
325
  - app/controllers/concerns/studio/geo_detection.rb
326
- - app/controllers/concerns/studio/impersonation.rb
327
326
  - app/controllers/concerns/studio/link_consumption.rb
328
327
  - app/controllers/concerns/studio/link_preview_bots.rb
329
328
  - app/controllers/concerns/studio/magic_link_issuing.rb
@@ -338,7 +337,6 @@ files:
338
337
  - app/controllers/solana_sessions_controller.rb
339
338
  - app/controllers/studio/admin_surveys_controller.rb
340
339
  - app/controllers/studio/bookings_controller.rb
341
- - app/controllers/studio/email_images_controller.rb
342
340
  - app/controllers/studio/emails_controller.rb
343
341
  - app/controllers/studio/geo_settings_controller.rb
344
342
  - app/controllers/studio/knowledge_docs_controller.rb
@@ -373,8 +371,6 @@ files:
373
371
  - app/mailers/user_mailer.rb
374
372
  - app/models/concerns/sluggable.rb
375
373
  - app/models/concerns/studio/board/rankable.rb
376
- - app/models/concerns/studio/broadcastable.rb
377
- - app/models/concerns/studio/user_profile.rb
378
374
  - app/models/current.rb
379
375
  - app/models/error_log.rb
380
376
  - app/models/image_cache.rb
@@ -399,18 +395,15 @@ files:
399
395
  - app/views/components/_avatar.html.erb
400
396
  - app/views/components/_avatar_cropper.html.erb
401
397
  - app/views/components/_badge.html.erb
402
- - app/views/components/_card.html.erb
403
398
  - app/views/components/_copy_button.html.erb
404
399
  - app/views/components/_emoji_swap.html.erb
405
400
  - app/views/components/_empty_state.html.erb
406
401
  - app/views/components/_geo_badge.html.erb
407
402
  - app/views/components/_geo_signpost.html.erb
408
403
  - app/views/components/_google_logo.html.erb
409
- - app/views/components/_input.html.erb
410
404
  - app/views/components/_json_debug.html.erb
411
405
  - app/views/components/_link_sidebar.html.erb
412
406
  - app/views/components/_link_sidebar_trigger.html.erb
413
- - app/views/components/_progress_bar.html.erb
414
407
  - app/views/components/_sidebar_panel.html.erb
415
408
  - app/views/components/_theme_toggle.html.erb
416
409
  - app/views/components/_theme_toggle_morph.html.erb
@@ -458,7 +451,6 @@ files:
458
451
  - app/views/studio/booking/_frame.html.erb
459
452
  - app/views/studio/booking/_popup.html.erb
460
453
  - app/views/studio/bookings/show.html.erb
461
- - app/views/studio/email_images/index.html.erb
462
454
  - app/views/studio/emails/_banner_editor.html.erb
463
455
  - app/views/studio/emails/_banner_preview.html.erb
464
456
  - app/views/studio/emails/_banner_scale.html.erb
@@ -1,90 +0,0 @@
1
- require "time"
2
-
3
- module Studio
4
- module Impersonation
5
- extend ActiveSupport::Concern
6
-
7
- included do
8
- if respond_to?(:helper_method)
9
- helper_method :true_user, :impersonated_user, :impersonating?, :impersonation_started_at
10
- end
11
- end
12
-
13
- private
14
-
15
- # The real session owner. While acting as another user, this remains the
16
- # admin/operator account whose session token should be verified.
17
- def true_user
18
- return @true_user if defined?(@true_user)
19
-
20
- @true_user = User.find_by(id: session[Studio.session_key])
21
- end
22
-
23
- # The currently visible user. Consumers with extra wallet or privilege state
24
- # can override this and still call true_user/impersonating? from the concern.
25
- def current_user
26
- return @current_user if defined?(@current_user)
27
-
28
- @current_user = impersonating? ? impersonated_user : true_user
29
- end
30
-
31
- def impersonated_user
32
- return @impersonated_user if defined?(@impersonated_user)
33
-
34
- @impersonated_user = User.find_by(id: session[Studio.impersonation_target_session_key])
35
- end
36
-
37
- def impersonating?
38
- return @impersonating if defined?(@impersonating)
39
-
40
- @impersonating = compute_impersonating?
41
- end
42
-
43
- def start_impersonation_session(target_user, actor: true_user)
44
- session[Studio.impersonation_actor_session_key] = actor.id
45
- session[Studio.impersonation_target_session_key] = target_user.id
46
- session[Studio.impersonation_started_at_session_key] = Time.current.iso8601
47
- reset_impersonation_memoization
48
- end
49
-
50
- def clear_impersonation_session
51
- session.delete(Studio.impersonation_actor_session_key)
52
- session.delete(Studio.impersonation_target_session_key)
53
- session.delete(Studio.impersonation_started_at_session_key)
54
- reset_impersonation_memoization
55
- end
56
-
57
- def impersonation_started_at
58
- raw = session[Studio.impersonation_started_at_session_key]
59
- return nil if raw.blank?
60
-
61
- return raw if raw.is_a?(Time)
62
-
63
- zone = Time.zone if Time.respond_to?(:zone)
64
- zone ? zone.parse(raw.to_s) : Time.parse(raw.to_s)
65
- rescue ArgumentError, TypeError
66
- nil
67
- end
68
-
69
- def compute_impersonating?
70
- return false if session[Studio.impersonation_target_session_key].blank?
71
- return false unless true_user&.respond_to?(:admin?) && true_user.admin?
72
- return false unless impersonation_started_at
73
- return false if impersonation_started_at < Studio.impersonation_max_minutes.to_i.minutes.ago
74
-
75
- target = impersonated_user
76
- target.present? &&
77
- (!target.respond_to?(:admin?) || !target.admin?) &&
78
- target.id != true_user.id
79
- rescue StandardError
80
- false
81
- end
82
-
83
- def reset_impersonation_memoization
84
- remove_instance_variable(:@current_user) if defined?(@current_user)
85
- remove_instance_variable(:@true_user) if defined?(@true_user)
86
- remove_instance_variable(:@impersonated_user) if defined?(@impersonated_user)
87
- remove_instance_variable(:@impersonating) if defined?(@impersonating)
88
- end
89
- end
90
- end
@@ -1,76 +0,0 @@
1
- module Studio
2
- # DEPRECATED — superseded by Studio::EmailsController (/admin/emails), which
3
- # shows every registered email with its live image, says whether that image is
4
- # the inherited default or app-owned, and uploads through the crop modal.
5
- #
6
- # Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
7
- # out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
8
- # that suite against the engine PR. mcritchie-studio and turf-monster both have
9
- # tests on `main` that drive this page and its admin_email_image_path helper,
10
- # so deleting it here would redden their lanes from the moment the PR opens,
11
- # and nothing inside the engine PR could fix it: the consumer PRs would have to
12
- # travel accepted -> release -> main in BOTH apps before this could go green.
13
- #
14
- # So the retirement is staged: this release adds /admin/emails and leaves this
15
- # page working; each app's adoption task moves its link and its tests; a later
16
- # engine minor deletes this file, its view, and its two routes once no
17
- # consumer's main references them.
18
- #
19
- # It is not frozen in its broken state, though — see #index.
20
- class EmailImagesController < ApplicationController
21
- before_action :require_admin
22
-
23
- MAX_BYTES = 8.megabytes
24
-
25
- def index
26
- @variants = Studio::EmailCatalog.variants
27
- end
28
-
29
- # The canonical page this one is being retired in favour of, or nil when this
30
- # app has not drawn it. Rendered as a banner at the top of the old view so an
31
- # admin who lands here by a stale link is walked forward.
32
- #
33
- # The nil case is REAL, not defensive padding: /admin/emails is opt-in
34
- # (Studio.draw_admin_emails_routes, default off because turf-monster owns the
35
- # name), so on any app that has not opted in the helper does not exist and a
36
- # bare call raises NameError — which 500s this page. Consumer CI caught
37
- # exactly that on mcritchie-studio.
38
- #
39
- # Asks the ROUTER rather than the config flag: the flag is read at route-draw
40
- # time, so a host that flips it afterwards would still have no route, and the
41
- # router is the thing that actually knows.
42
- def successor_path
43
- return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
44
-
45
- admin_emails_path
46
- end
47
- helper_method :successor_path
48
-
49
- # PATCH /admin/email_images/:variant — upload/replace a banner.
50
- def update
51
- variant = params[:variant].to_s
52
- return head :not_found unless Studio::EmailCatalog.known?(variant)
53
-
54
- file = params[:image]
55
- unless valid_image?(file)
56
- message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
57
- return redirect_to admin_email_images_path, alert: message, status: :see_other
58
- end
59
-
60
- rescue_and_log do
61
- Studio::EmailCatalog.store(variant, io: file, content_type: file.content_type)
62
- redirect_to admin_email_images_path, notice: "#{Studio::EmailCatalog.label(variant)} banner updated."
63
- end
64
- rescue StandardError
65
- redirect_to admin_email_images_path, alert: "Couldn't save the image. Please try again.", status: :see_other
66
- end
67
-
68
- private
69
-
70
- def valid_image?(file)
71
- file.respond_to?(:content_type) &&
72
- file.content_type.to_s.start_with?("image/") &&
73
- file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
74
- end
75
- end
76
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Studio
4
- # Mixin for ActiveRecord models that broadcast Turbo Streams. It wraps
5
- # turbo-rails' broadcast_*_to methods in Studio::Cable.safe_broadcast, so a cable
6
- # failure (a Redis hiccup, a missing/misconfigured adapter) can NEVER break the
7
- # model save / after_commit that triggered the broadcast — the SEV-1 guard, in
8
- # one place. The host model already has the raw broadcast_*_to methods (turbo-rails
9
- # includes Turbo::Broadcastable into ActiveRecord::Base); these are the SAFE
10
- # variants every app should broadcast through.
11
- #
12
- # class Task < ApplicationRecord
13
- # include Studio::Broadcastable
14
- # after_create_commit { safe_broadcast_replace_to [:board], target: "card_#{id}",
15
- # partial: "tasks/card", locals: { task: self } }
16
- # end
17
- module Broadcastable
18
- extend ActiveSupport::Concern
19
-
20
- def safe_broadcast_replace_to(*args, **kwargs, &block)
21
- Studio::Cable.safe_broadcast { broadcast_replace_to(*args, **kwargs, &block) }
22
- end
23
-
24
- def safe_broadcast_update_to(*args, **kwargs, &block)
25
- Studio::Cable.safe_broadcast { broadcast_update_to(*args, **kwargs, &block) }
26
- end
27
-
28
- def safe_broadcast_append_to(*args, **kwargs, &block)
29
- Studio::Cable.safe_broadcast { broadcast_append_to(*args, **kwargs, &block) }
30
- end
31
-
32
- def safe_broadcast_prepend_to(*args, **kwargs, &block)
33
- Studio::Cable.safe_broadcast { broadcast_prepend_to(*args, **kwargs, &block) }
34
- end
35
-
36
- def safe_broadcast_remove_to(*args, **kwargs, &block)
37
- Studio::Cable.safe_broadcast { broadcast_remove_to(*args, **kwargs, &block) }
38
- end
39
- end
40
- end
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
-
5
- module Studio
6
- # The four things every Studio app's User has had to define for itself: how to
7
- # name a person on screen, and how to draw them when they have no picture.
8
- #
9
- # WHY THIS EXISTS. The engine's own components/_avatar partial calls
10
- # `display_name`, `avatar_initials` and `avatar_color` — and the engine has
11
- # never provided any of them. Every consumer wrote its own; mcritchie-industries'
12
- # user.rb even carries a comment explaining that it must, because the engine's
13
- # nav renders the partial. Three copies, and by 2026-08-14 they had drifted:
14
- #
15
- # mcritchie-studio name → email prefix (capitalized) → wallet → "anon"
16
- # mcritchie-industries name → email prefix (raw) → "User"
17
- # turf-monster username → name → email prefix (cap) → wallet → "anon"
18
- #
19
- # Same intent, three answers, and two different words for the same empty state.
20
- # That is the drift a concern removes.
21
- #
22
- # ON THE MERGED CHAIN. It is turf-monster's — the richest of the three — with
23
- # `first_name` folded in after `name`, because /profile now lets people set that
24
- # field and a name someone just typed should be the name they see. Apps missing
25
- # a link in the chain simply skip it; the chain is respond_to?-guarded end to
26
- # end, so mcritchie-industries' eight-column users table walks it without
27
- # raising. One consequence worth stating rather than burying: an MI user with no
28
- # name now falls back to "anon" instead of "User", and an email-derived name is
29
- # capitalized. That is MI adopting the house standard, and its adoption task
30
- # owns the change.
31
- #
32
- # EVERY METHOD IS OVERRIDABLE. These are plain instance methods from an included
33
- # module, so a host that defines its own `display_name` in the class body wins
34
- # outright. Standardizing the default is the goal; forbidding an app from having
35
- # an opinion is not.
36
- module UserProfile
37
- extend ActiveSupport::Concern
38
-
39
- # The house palette for initial-circle backgrounds. Identical in all three
40
- # apps today, which is the clearest possible signal it belonged here.
41
- AVATAR_COLORS = %w[#EF4444 #F97316 #EAB308 #22C55E #06B6D4 #3B82F6 #8B5CF6 #EC4899].freeze
42
-
43
- # What to call this person on screen.
44
- def display_name
45
- studio_profile_username.presence ||
46
- studio_profile_attr(:name).presence ||
47
- studio_profile_attr(:first_name).presence ||
48
- studio_profile_email_prefix.presence ||
49
- studio_profile_wallet.presence ||
50
- "anon"
51
- end
52
-
53
- # One character for the initials circle. Deliberately NOT derived from
54
- # display_name: that chain ends in a wallet address or the word "anon", and
55
- # "a" for every anonymous account is worse than a neutral mark.
56
- def avatar_initials
57
- source = studio_profile_username.presence ||
58
- studio_profile_attr(:name).presence ||
59
- studio_profile_attr(:first_name).presence ||
60
- studio_profile_email_prefix.presence
61
-
62
- # `source[0]`, not ActiveSupport's `String#first`: this module is included
63
- # into a host model, and nothing here should depend on which core_ext that
64
- # host happens to have loaded. Plain Ruby costs nothing and cannot vanish.
65
- source.presence.to_s[0]&.upcase || "?"
66
- end
67
-
68
- # A stable colour for this account — same person, same circle, every render.
69
- # Hashed rather than random for exactly that reason, and keyed off identity
70
- # fields so it survives a display-name change.
71
- def avatar_color
72
- key = studio_profile_username.presence ||
73
- studio_profile_attr(:name).presence ||
74
- studio_profile_attr(:email).presence ||
75
- id.to_s
76
-
77
- AVATAR_COLORS[Digest::MD5.hexdigest(key.to_s).hex % AVATAR_COLORS.size]
78
- end
79
-
80
- private
81
-
82
- # Read an attribute only if this host's model actually has it. The whole
83
- # tolerance of the chain lives in this one method.
84
- def studio_profile_attr(attribute)
85
- return nil unless respond_to?(attribute)
86
-
87
- public_send(attribute)
88
- end
89
-
90
- # `username` is turf-monster's on-chain handle and does not exist in the hub
91
- # apps. It leads the chain where it exists because it is what that app's
92
- # people call each other.
93
- def studio_profile_username
94
- studio_profile_attr(:username)
95
- end
96
-
97
- def studio_profile_email_prefix
98
- email = studio_profile_attr(:email)
99
- return nil if email.blank?
100
-
101
- email.to_s.split("@").first.to_s.capitalize
102
- end
103
-
104
- # Defers to the host's own truncation rather than reformatting an address
105
- # here — mcritchie-studio and turf-monster both define `truncated_solana`,
106
- # and turf's has to pick between a web2 and a web3 address to do it.
107
- def studio_profile_wallet
108
- studio_profile_attr(:truncated_solana)
109
- end
110
- end
111
- end
@@ -1,4 +0,0 @@
1
- <%# locals: (padding: "p-6") %>
2
- <div class="card <%= padding %>">
3
- <%= yield %>
4
- </div>
@@ -1,13 +0,0 @@
1
- <%# locals: (form:, field:, label: nil, type: "text", placeholder: nil, rows: nil) %>
2
- <div class="mb-5">
3
- <% if label.present? %>
4
- <label class="block text-sm text-secondary mb-2 font-medium"><%= label %></label>
5
- <% end %>
6
- <% if type.to_s == "textarea" %>
7
- <%= form.text_area field, rows: rows || 4, class: "input-field", placeholder: placeholder %>
8
- <% elsif type.to_s == "password" %>
9
- <%= form.password_field field, class: "input-field", placeholder: placeholder %>
10
- <% else %>
11
- <%= form.text_field field, class: "input-field", placeholder: placeholder %>
12
- <% end %>
13
- </div>
@@ -1,9 +0,0 @@
1
- <%# locals: (percent:, height: "h-3", color: "var(--color-cta)", label: nil, animated: false) %>
2
- <div class="w-full <%= height %> rounded-full bg-surface-alt overflow-hidden">
3
- <div class="h-full rounded-full<%= ' transition-all duration-500 ease-out' if animated %>"
4
- style="width: <%= [percent.to_f, 100].min %>%; background-color: <%= color %>">
5
- </div>
6
- </div>
7
- <% if label %>
8
- <p class="text-xs text-muted mt-1"><%= label %></p>
9
- <% end %>
@@ -1,74 +0,0 @@
1
- <%# DEPRECATED page — superseded by /admin/emails (studio/emails/index).
2
- Kept for one release so consumer suites on their default branch keep passing;
3
- see Studio::EmailImagesController for the full staging note. %>
4
- <% content_for(:title) { "Email images" } %>
5
- <div class="max-w-2xl mx-auto px-4 py-8">
6
- <header class="mb-6">
7
- <h1 class="text-2xl font-bold">Email images</h1>
8
- <p class="text-sm text-muted mt-1">
9
- The banner shown at the top of branded transactional emails. Recommended
10
- width 600px (it's displayed full-bleed at the top of the email card).
11
- </p>
12
- </header>
13
-
14
- <%# Only when this app actually DREW /admin/emails — it is opt-in, and pointing
15
- at a route that does not exist raises NameError and 500s this page. %>
16
- <% if successor_path %>
17
- <div class="mb-6 rounded-xl border border-subtle bg-inset px-4 py-3">
18
- <p class="text-sm font-semibold text-heading">There's a better page now</p>
19
- <p class="text-sm text-body mt-1">
20
- <%= link_to "Emails", successor_path, class: "underline underline-offset-2" %>
21
- lists every email this app sends with its live banner, says whether that
22
- banner is the shared default or this app's own, and crops on upload.
23
- This page is going away.
24
- </p>
25
- </div>
26
- <% end %>
27
-
28
- <% flash.each do |type, message| %>
29
- <div class="mb-4 rounded-lg px-4 py-3 text-sm bg-surface border <%= type.to_s == "alert" ? "border-danger/30 text-danger-ink" : "border-success/30 text-success-ink" %>">
30
- <%= message %>
31
- </div>
32
- <% end %>
33
-
34
- <div class="space-y-6">
35
- <% @variants.each do |variant, label| %>
36
- <%# preview_url, not url: this page's ORIGINAL BUG was reading only the S3
37
- override, so it announced "No image yet" for an email that was visibly
38
- sending a banner from a committed repo asset. Even on its way out it
39
- should tell the truth about what ships. %>
40
- <% current_url = Studio::EmailCatalog.preview_url(variant) %>
41
- <%# NOT `== :default` — that value split into :app_asset / :engine_default.
42
- Comparing against a value nothing returns would silently never show
43
- this note again. Anything that is not an upload is still inherited
44
- from a file, which is all this retiring page needs to say. %>
45
- <% inherited = current_url.present? && Studio::EmailCatalog.source(variant) != :app %>
46
- <section class="rounded-xl border border-subtle p-5">
47
- <h2 class="font-semibold mb-3"><%= label %></h2>
48
-
49
- <div class="rounded-lg overflow-hidden border border-subtle mb-3" style="aspect-ratio: 600 / 240; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
50
- <% if current_url %>
51
- <%= image_tag current_url, class: "w-full h-full object-cover", alt: "#{label} email banner" %>
52
- <% else %>
53
- <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
54
- No image yet — emails send without a banner until you upload one.
55
- </div>
56
- <% end %>
57
- </div>
58
-
59
- <% if inherited %>
60
- <p class="text-xs text-muted mb-3">
61
- This is the shared default that ships with the engine. Uploading here
62
- makes the image this app's own.
63
- </p>
64
- <% end %>
65
-
66
- <%= form_with url: admin_email_image_path(variant), method: :patch, html: { multipart: true }, class: "flex flex-wrap items-center gap-3" do %>
67
- <%= file_field_tag :image, accept: "image/png,image/jpeg,image/webp",
68
- class: "text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-white file:cursor-pointer" %>
69
- <%= submit_tag(current_url ? "Replace image" : "Upload image", class: "btn btn-primary btn-sm") %>
70
- <% end %>
71
- </section>
72
- <% end %>
73
- </div>
74
- </div>