studio-engine 0.48.0 → 0.50.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.
@@ -0,0 +1,65 @@
1
+ <%# The edit form's Alpine component.
2
+
3
+ Its own partial so the page above stays readable and this can be reasoned
4
+ about as behaviour rather than markup.
5
+ %>
6
+ <script>
7
+ window.studioProfileForm = function (initial) {
8
+ return {
9
+ alpine: true,
10
+ fields: Object.assign({}, initial),
11
+ initial: Object.assign({}, initial),
12
+
13
+ // Trimmed on both sides: whitespace is not a change, and the controller
14
+ // trims on the way in too — so the bar agrees with what saving would do.
15
+ changed(key) {
16
+ return String(this.fields[key] || "").trim() !== String(this.initial[key] || "").trim();
17
+ },
18
+
19
+ get changeCount() {
20
+ var self = this;
21
+ return Object.keys(this.fields).filter(function (k) { return self.changed(k); }).length;
22
+ },
23
+
24
+ get dirty() { return this.changeCount > 0; },
25
+
26
+ discard() {
27
+ this.fields = Object.assign({}, this.initial);
28
+ },
29
+
30
+ init() {
31
+ var self = this;
32
+
33
+ // Guard the exit BOTH ways. beforeunload covers a hard navigation or a
34
+ // closed tab; turbo:before-visit covers a Turbo Drive visit, which never
35
+ // fires beforeunload — an app with Turbo on would otherwise drop edits
36
+ // silently on any in-app link.
37
+ this._beforeUnload = function (e) {
38
+ if (!self.dirty) return;
39
+ e.preventDefault();
40
+ e.returnValue = "";
41
+ };
42
+ window.addEventListener("beforeunload", this._beforeUnload);
43
+
44
+ this._beforeVisit = function (e) {
45
+ if (!self.dirty) return;
46
+ if (!window.confirm("You have unsaved changes. Leave without saving?")) {
47
+ e.preventDefault();
48
+ }
49
+ };
50
+ document.addEventListener("turbo:before-visit", this._beforeVisit);
51
+
52
+ // Submitting is not leaving. Drop both guards before the form goes, or
53
+ // saving prompts you about the very changes you just chose to save.
54
+ this.$refs.form.addEventListener("submit", function () { self.teardown(); });
55
+ },
56
+
57
+ teardown() {
58
+ window.removeEventListener("beforeunload", this._beforeUnload);
59
+ document.removeEventListener("turbo:before-visit", this._beforeVisit);
60
+ },
61
+
62
+ destroy() { this.teardown(); }
63
+ };
64
+ };
65
+ </script>
@@ -0,0 +1,41 @@
1
+ <%# The "you at a glance" header — avatar, name, address — at the top of BOTH
2
+ /profile and /profile/edit.
3
+
4
+ Locals:
5
+ user (required)
6
+ editable (Boolean) — true on the edit page, where the avatar badge opens
7
+ the crop modal.
8
+
9
+ ON THE READ PAGE THE WHOLE CARD IS THE LINK to /profile/edit (operator's
10
+ call). It began as a badge-only link, then the page heading and the Edit
11
+ button were removed, and a 28px target became the single route to editing —
12
+ so the target grew to the thing people actually aim at.
13
+
14
+ That is also why the badge is a <span> there: an <a> inside an <a> is invalid,
15
+ and browsers repair it by closing the outer link early, which would quietly
16
+ shrink the card's clickable area to whatever preceded the badge.
17
+
18
+ THE AVATAR IS OPTIONAL — the guard the registry used to carry. As a ROW the
19
+ avatar declared `requires: :avatar` and was dropped whole on a host whose
20
+ model has no attachment; moving it into this header lost that, and the first
21
+ render against such a model raised `undefined method 'avatar'`.
22
+ %>
23
+ <% editable = local_assigns.fetch(:editable, false) %>
24
+ <% attachable = user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) %>
25
+
26
+ <%= render "studio/profiles/identity_styles" %>
27
+
28
+ <%= render "studio/profiles/identity_mini", user: user %>
29
+
30
+ <% if editable %>
31
+ <div data-studio-identity-full class="card p-6 mb-6 text-center">
32
+ <%= render "studio/profiles/identity_body", user: user, editable: true, attachable: attachable %>
33
+ </div>
34
+ <% else %>
35
+ <%= link_to edit_profile_path,
36
+ class: "studio-identity-card card p-6 mb-6 text-center block no-underline",
37
+ data: { studio_identity_full: true },
38
+ aria: { label: "Edit your profile" } do %>
39
+ <%= render "studio/profiles/identity_body", user: user, editable: false, attachable: attachable %>
40
+ <% end %>
41
+ <% end %>
@@ -0,0 +1,44 @@
1
+ <%# The identity block's contents — avatar, name, address.
2
+
3
+ Its own partial because the READ page wraps it in a link and the EDIT page
4
+ does not, and duplicating the markup to achieve that is how the two drift.
5
+
6
+ Locals: user, editable, attachable.
7
+ %>
8
+ <div class="relative mb-3 studio-avatar mx-auto" style="width: 128px; height: 128px;">
9
+ <% if attachable && user.avatar.attached? %>
10
+ <%= image_tag user.avatar, class: "rounded-full object-cover",
11
+ style: "width: 128px; height: 128px;", alt: user.display_name %>
12
+ <% else %>
13
+ <div class="rounded-full flex items-center justify-center font-bold text-white text-4xl"
14
+ style="width: 128px; height: 128px; background-color: <%= user.avatar_color %>">
15
+ <%= user.avatar_initials %>
16
+ </div>
17
+ <% end %>
18
+
19
+ <% badge_class = "studio-avatar-badge absolute bottom-0 right-0 flex items-center justify-center rounded-full border-2 border-page bg-primary text-white transition" %>
20
+ <% badge_style = "width: 28px; height: 28px;" %>
21
+
22
+ <% if editable && attachable %>
23
+ <%# A real button: on the edit page this opens the cropper. %>
24
+ <button type="button" @click="$refs.filePicker.click()"
25
+ aria-label="Change your profile photo"
26
+ class="<%= badge_class %>" style="<%= badge_style %>">
27
+ <%= render "studio/profiles/pencil_icon" %>
28
+ </button>
29
+ <% elsif !editable %>
30
+ <%# DECORATIVE on the read page, and it has to be: the whole card is a link
31
+ now, and an <a> inside an <a> is invalid HTML that browsers repair by
32
+ closing the outer one early — which would silently cut the card's
33
+ clickable area down to whatever came before the badge. The card carries
34
+ the destination and the accessible name; this is just the glyph. %>
35
+ <span class="<%= badge_class %>" style="<%= badge_style %>" aria-hidden="true">
36
+ <%= render "studio/profiles/pencil_icon" %>
37
+ </span>
38
+ <% end %>
39
+ </div>
40
+
41
+ <p class="text-3xl font-extrabold text-heading"><%= user.display_name %></p>
42
+ <% if user.respond_to?(:email) && user.email.present? %>
43
+ <p class="text-secondary text-sm break-all"><%= user.email %></p>
44
+ <% end %>
@@ -0,0 +1,106 @@
1
+ <%# The compact identity that takes over when the full card scrolls away.
2
+
3
+ Locals: user (required).
4
+
5
+ WHY IT EXISTS: on a long profile the header is the thing that tells you whose
6
+ page you are on, and it is the first thing to disappear. This keeps that
7
+ answer on screen — the same move the navbar makes when it shrinks on scroll.
8
+
9
+ FIXED, NOT STICKY, and that is the whole layout decision. A sticky element
10
+ stays in flow, so it would reserve its own height under the card forever and
11
+ push every row down by 56px whether or not it is showing. Fixed takes it out
12
+ of flow: nothing moves, and the bar simply is not there until it is.
13
+
14
+ Pinned to `var(--nav-h, 0px)` — the height the host navbar publishes, which
15
+ /admin/style already uses to stick its section nav. The 0px fallback means an
16
+ app that publishes nothing gets a bar at the top of the viewport rather than
17
+ a broken one.
18
+
19
+ NOT INTERACTIVE, and aria-hidden. It duplicates content that is already on
20
+ the page and already reachable: the full card above is the link to editing.
21
+ A second link here would be a duplicate tab stop and a second announcement of
22
+ the same name for no gain — this is an orientation aid for people who can see
23
+ the screen, so it says so.
24
+ %>
25
+ <div data-studio-identity-mini
26
+ class="studio-identity-mini"
27
+ aria-hidden="true">
28
+ <div class="px-4">
29
+ <div class="flex items-center gap-3 py-2">
30
+ <% if user.respond_to?(:avatar) && user.avatar.respond_to?(:attached?) && user.avatar.attached? %>
31
+ <%= image_tag user.avatar, class: "rounded-full object-cover flex-shrink-0",
32
+ style: "width: 32px; height: 32px;", alt: "" %>
33
+ <% else %>
34
+ <div class="rounded-full flex items-center justify-center font-bold text-white text-xs flex-shrink-0"
35
+ style="width: 32px; height: 32px; background-color: <%= user.avatar_color %>">
36
+ <%= user.avatar_initials %>
37
+ </div>
38
+ <% end %>
39
+
40
+ <div class="min-w-0">
41
+ <p class="text-sm font-bold text-heading truncate"><%= user.display_name %></p>
42
+ <% if user.respond_to?(:email) && user.email.present? %>
43
+ <p class="text-xs text-muted truncate"><%= user.email %></p>
44
+ <% end %>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <script>
51
+ (function () {
52
+ var CLASS = "is-visible";
53
+
54
+ function wire() {
55
+ var full = document.querySelector("[data-studio-identity-full]");
56
+ var mini = document.querySelector("[data-studio-identity-mini]");
57
+ if (!full || !mini) return;
58
+
59
+ // No IntersectionObserver: leave the bar hidden rather than showing a
60
+ // duplicate header permanently. The page is fully usable without it.
61
+ if (!("IntersectionObserver" in window)) return;
62
+
63
+ if (mini._studioObserver) mini._studioObserver.disconnect();
64
+
65
+ // HAND OVER PART-WAY, not once the card has gone. Waiting for the last
66
+ // pixel left a gap where neither header was on screen, which is the moment
67
+ // the bar exists to cover. It appears once most of the card has scrolled
68
+ // past, so the two overlap briefly instead.
69
+ //
70
+ // Ratio, not a pixel offset: the card's height changes with the name's
71
+ // wrap and with whether there is an email under it, so a fixed offset
72
+ // would mean something different on every account.
73
+ var SHOW_BELOW_RATIO = 0.6;
74
+
75
+ var observer = new IntersectionObserver(function (entries) {
76
+ var entry = entries[0];
77
+ // A card taller than the viewport can never reach ratio 1, so "not
78
+ // intersecting at all" has to count as gone in its own right.
79
+ var gone = !entry.isIntersecting || entry.intersectionRatio < SHOW_BELOW_RATIO;
80
+ mini.classList.toggle(CLASS, gone);
81
+ }, {
82
+ // The navbar covers the top of the viewport, so pixels behind it are not
83
+ // visible pixels — without this the card reads as on-screen while it is
84
+ // hidden under the nav.
85
+ rootMargin: "-72px 0px 0px 0px",
86
+ // A spread of thresholds, or the callback only fires at fully-in and
87
+ // fully-out and the ratio test above never gets a chance to run.
88
+ threshold: [0, 0.2, 0.4, 0.5, 0.6, 0.8, 1]
89
+ });
90
+
91
+ observer.observe(full);
92
+ mini._studioObserver = observer;
93
+ }
94
+
95
+ document.addEventListener("turbo:load", wire);
96
+ document.addEventListener("turbo:before-cache", function () {
97
+ var mini = document.querySelector("[data-studio-identity-mini]");
98
+ if (mini) {
99
+ mini.classList.remove(CLASS);
100
+ if (mini._studioObserver) mini._studioObserver.disconnect();
101
+ }
102
+ });
103
+ if (document.readyState !== "loading") wire();
104
+ else document.addEventListener("DOMContentLoaded", wire);
105
+ })();
106
+ </script>
@@ -0,0 +1,96 @@
1
+ <%# The avatar badge's reveal.
2
+
3
+ The badge sits empty until you hover the picture, then the pencil fades in —
4
+ the operator's call, and it keeps the resting state quiet.
5
+
6
+ WRITTEN AS CSS RATHER THAN TAILWIND UTILITIES on purpose. The engine ships a
7
+ prebuilt bundle to consumers, so a utility only exists if something already
8
+ emitted it; `group-focus-within:opacity-100` is exactly the kind of class
9
+ that is absent in one app and present in another, and the failure mode is
10
+ silent — a keyboard user simply never sees the icon. Owning the selector
11
+ removes that uncertainty.
12
+
13
+ THREE REVEAL PATHS, and the last two are not decoration. Since the operator
14
+ removed the page heading and the Edit button, this badge is the ONLY route
15
+ from /profile to /profile/edit, so an affordance that exists only on hover
16
+ would strand two whole groups of people:
17
+
18
+ hover — the intended interaction on a pointer
19
+ focus — the same affordance for a keyboard, which never hovers
20
+ (hover: none) — touch has no hover at all, so the icon simply stays
21
+ visible there rather than never appearing
22
+
23
+ The badge circle itself is always visible in every case; it is only the
24
+ glyph inside it that waits.
25
+ %>
26
+ <style>
27
+ .studio-avatar-badge-icon {
28
+ opacity: 0;
29
+ transition: opacity 200ms ease;
30
+ }
31
+
32
+ /* The read page: the whole card is the link, so hovering ANYWHERE on it
33
+ reveals the glyph. The avatar and badge selectors stay for the edit page,
34
+ where the badge is its own button. */
35
+ .studio-identity-card:hover .studio-avatar-badge-icon,
36
+ .studio-identity-card:focus .studio-avatar-badge-icon,
37
+ .studio-identity-card:focus-visible .studio-avatar-badge-icon,
38
+ .studio-avatar:hover .studio-avatar-badge-icon,
39
+ .studio-avatar-badge:hover .studio-avatar-badge-icon,
40
+ .studio-avatar-badge:focus .studio-avatar-badge-icon,
41
+ .studio-avatar-badge:focus-visible .studio-avatar-badge-icon {
42
+ opacity: 1;
43
+ }
44
+
45
+ /* The card reads as clickable before you click it. Kept subtle — this is a
46
+ whole card lighting up, not a button. */
47
+ .studio-identity-card {
48
+ transition: border-color 200ms ease, background-color 200ms ease;
49
+ }
50
+
51
+ .studio-identity-card:hover,
52
+ .studio-identity-card:focus-visible {
53
+ border-color: var(--color-primary);
54
+ }
55
+
56
+ /* No hover to give: show it and be done. */
57
+ @media (hover: none) {
58
+ .studio-avatar-badge-icon { opacity: 1; }
59
+ }
60
+
61
+ /* The compact header that takes over on scroll. FIXED so it costs no layout
62
+ height while hidden; pinned to the navbar height the host publishes. */
63
+ .studio-identity-mini {
64
+ position: fixed;
65
+ /* Sits just under the host navbar, with a little air so it reads as a card
66
+ floating over the page rather than a second navbar welded to the first. */
67
+ top: calc(var(--nav-h, 0px) + 0.5rem);
68
+ /* CARD WIDTH, not full bleed. 42rem is max-w-2xl — the page container's
69
+ width — and the calc keeps it inside the viewport's gutters on a narrow
70
+ screen, where a fixed 42rem would run off the edge. Centred with the same
71
+ translate the entry animation uses, so the two must stay in one transform:
72
+ a second transform property would silently replace the first. */
73
+ left: 50%;
74
+ width: min(42rem, calc(100% - 2rem));
75
+ z-index: 30;
76
+ background: var(--color-surface);
77
+ border: 1px solid var(--color-border-subtle);
78
+ border-radius: 0.75rem;
79
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.25);
80
+ opacity: 0;
81
+ transform: translateX(-50%) translateY(-6px);
82
+ pointer-events: none;
83
+ transition: opacity 200ms ease, transform 200ms ease;
84
+ }
85
+
86
+ .studio-identity-mini.is-visible {
87
+ opacity: 1;
88
+ transform: translateX(-50%);
89
+ }
90
+
91
+ @media (prefers-reduced-motion: reduce) {
92
+ .studio-avatar-badge-icon,
93
+ .studio-identity-card,
94
+ .studio-identity-mini { transition: none; }
95
+ }
96
+ </style>
@@ -0,0 +1,34 @@
1
+ <%# Name fields — first and last, side by side on a wide row.
2
+
3
+ Locals: user (required).
4
+
5
+ NO BUTTON. These are fields in the edit page's single form; the sticky bar
6
+ saves them. `x-model` feeds the dirty check that makes the bar appear.
7
+
8
+ LAST NAME IS OPTIONAL AT THE COLUMN LEVEL. mcritchie-studio and turf-monster
9
+ have it; mcritchie-industries, moms-app and acquisition-studio do not, and
10
+ they get a first-name-only row rather than a broken one. The engine's
11
+ standard-columns migration does not yet carry last_name — making it universal
12
+ is a coordinated fleet change (roll-out-last-name-column), and gating here is
13
+ what lets this page ship without waiting on it.
14
+ %>
15
+ <div class="grid gap-4 sm:grid-cols-2">
16
+ <div>
17
+ <label class="block text-sm text-secondary mb-2 font-medium" for="profile_first_name">First name</label>
18
+ <input type="text" name="profile[first_name]" id="profile_first_name"
19
+ value="<%= user.first_name %>" class="input-field"
20
+ maxlength="<%= Studio::FIRST_NAME_MAX_LENGTH %>"
21
+ autocomplete="given-name" placeholder="What should we call you?"
22
+ x-model="fields.first_name">
23
+ </div>
24
+
25
+ <% if user.respond_to?(:last_name) %>
26
+ <div>
27
+ <label class="block text-sm text-secondary mb-2 font-medium" for="profile_last_name">Last name</label>
28
+ <input type="text" name="profile[last_name]" id="profile_last_name"
29
+ value="<%= user.last_name %>" class="input-field"
30
+ maxlength="<%= Studio::FIRST_NAME_MAX_LENGTH %>"
31
+ autocomplete="family-name" x-model="fields.last_name">
32
+ </div>
33
+ <% end %>
34
+ </div>
@@ -0,0 +1,7 @@
1
+ <%# The edit glyph on the identity avatar. Its own partial so the read page's
2
+ link and the edit page's button cannot drift into different icons. %>
3
+ <svg class="w-4 h-4 studio-avatar-badge-icon" fill="none" stroke="currentColor" stroke-width="2"
4
+ viewBox="0 0 24 24" aria-hidden="true">
5
+ <path stroke-linecap="round" stroke-linejoin="round"
6
+ d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"/>
7
+ </svg>
@@ -0,0 +1,39 @@
1
+ <%# The edit form's save bar — fixed to the viewport bottom, present only while
2
+ the form is dirty.
3
+
4
+ No locals. It reads `dirty` and `changeCount` off the enclosing
5
+ studioProfileForm component, so it must be rendered INSIDE that x-data scope.
6
+
7
+ ITS OWN PARTIAL so the browser lane can mount it. The lab page that drives
8
+ this in a real browser may set up a partial's locals and nothing else — it may
9
+ not hand-roll the markup — and while this lived inline in edit.html.erb there
10
+ was no name for it to render. A template needs a controller, a session and a
11
+ user; a partial needs none of those. See test/lib/e2e_lane_contract_test.rb.
12
+
13
+ Four details that make or break it:
14
+
15
+ * fixed, not sticky — it must not reserve height while it is hidden;
16
+ * env(safe-area-inset-bottom), or it sits under the iPhone home indicator;
17
+ * role="status" + aria-live="polite" announces "unsaved changes" without
18
+ stealing focus;
19
+ * the enclosing form carries bottom padding while the bar is up, so the bar
20
+ never covers the last field. That padding lives on the x-data element in
21
+ edit.html.erb, because it is the scroll container's business, not the
22
+ bar's.
23
+ %>
24
+ <div x-show="dirty" x-cloak x-transition.opacity
25
+ data-studio-save-bar
26
+ role="status" aria-live="polite"
27
+ class="fixed inset-x-0 bottom-0 z-40 border-t border-subtle"
28
+ style="background: var(--color-surface); padding-bottom: env(safe-area-inset-bottom);">
29
+ <div class="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
30
+ <p class="text-sm text-secondary">
31
+ <span class="font-semibold text-heading" x-text="changeCount"></span>
32
+ <span x-text="changeCount === 1 ? 'unsaved change' : 'unsaved changes'"></span>
33
+ </p>
34
+ <div class="flex items-center gap-2">
35
+ <button type="button" @click="discard()" class="btn btn-outline btn-sm">Discard</button>
36
+ <button type="submit" class="btn btn-primary btn-sm">Save changes</button>
37
+ </div>
38
+ </div>
39
+ </div>
@@ -0,0 +1,111 @@
1
+ <%# /profile/edit — ONE form, one Save, and a save bar that does not depend on
2
+ where you are in the page.
3
+
4
+ THE SAVE BAR is fixed to the viewport bottom and exists only while the form
5
+ is dirty. Rejected alternatives, so nobody re-litigates them: a sticky TOP bar
6
+ stacks under the navbar and eats mobile viewport; a floating button has no
7
+ room for a Discard or a change count; an always-present footer costs vertical
8
+ space permanently on a page that is usually short.
9
+
10
+ Four details that make or break it, all present below:
11
+
12
+ * the form carries bottom padding while the bar is up, so the bar never
13
+ covers the last field;
14
+ * env(safe-area-inset-bottom), or it sits under the iPhone home indicator;
15
+ * role="status" + aria-live="polite" announces "unsaved changes" without
16
+ stealing focus;
17
+ * beforeunload AND turbo:before-visit guard the exit, so navigating away
18
+ does not silently drop edits.
19
+
20
+ THE DIRTY CHECK compares against what the server rendered, trimmed on both
21
+ sides — whitespace is not a change, and the controller trims on the way in
22
+ too, so the bar agrees with what saving would actually do.
23
+
24
+ NO-JS FALLBACK: the form has a plain submit at the end. A page whose JS never
25
+ ran still saves; it simply never gets the bar.
26
+ %>
27
+ <% content_for(:title, "Edit profile") %>
28
+
29
+ <% initial = {
30
+ first_name: current_user.try(:first_name).to_s,
31
+ last_name: current_user.try(:last_name).to_s,
32
+ email: current_user.try(:email).to_s,
33
+ birthday: (if current_user.respond_to?(:birth_year) && current_user.birth_year.present? &&
34
+ current_user.birth_month.present? && current_user.birth_day.present?
35
+ format("%04d-%02d-%02d", current_user.birth_year, current_user.birth_month, current_user.birth_day)
36
+ else
37
+ ""
38
+ end)
39
+ } %>
40
+
41
+ <%# THE CROPPER COMES FROM THE IDENTITY HEADER, not from a row. It used to be a
42
+ row carrying `modals: true`, and this page kept gating on that flag after the
43
+ avatar moved — so nothing declared it, the host never mounted, and clicking
44
+ the badge opened nothing at all. Gate on the same thing the badge does. %>
45
+ <% avatar_editable = current_user.respond_to?(:avatar) && current_user.avatar.respond_to?(:attached?) %>
46
+
47
+ <% if avatar_editable %>
48
+ <%= render "studio/cropper_assets" %>
49
+ <% end %>
50
+
51
+ <div x-data="studioProfileForm(<%= initial.to_json %>)"
52
+ x-bind:style="dirty ? 'padding-bottom: 96px' : ''">
53
+ <div class="max-w-2xl mx-auto py-8">
54
+ <%# The way back. The page heading and the Done button came off on the
55
+ operator's call, which left browser-back as the only exit for someone who
56
+ opened this and changed nothing — so a small explicit one goes here.
57
+ Deliberately quiet: it is an escape hatch, not an action, and the save bar
58
+ owns the bottom of the screen when there is anything to save. %>
59
+ <%= link_to profile_path,
60
+ class: "inline-flex items-center gap-1 text-sm text-secondary hover:text-heading transition mb-4",
61
+ aria: { label: "Back to your profile" } do %>
62
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2"
63
+ viewBox="0 0 24 24" aria-hidden="true">
64
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/>
65
+ </svg>
66
+ Back
67
+ <% end %>
68
+
69
+ <%# Header + uploader in ONE Alpine scope — the badge calls $refs.filePicker,
70
+ and refs do not cross components. %>
71
+ <%= render "studio/profiles/editable_identity", user: current_user %>
72
+
73
+ <%= form_with url: profile_path, method: :patch, scope: :profile,
74
+ data: { turbo: false }, html: { "x-ref": "form" } do %>
75
+ <div class="card p-0 overflow-hidden">
76
+ <% @profile_sections.each_with_index do |section, index| %>
77
+ <section class="p-6 <%= "border-t border-subtle" unless index.zero? %>"
78
+ data-profile-section="<%= section[:key] %>">
79
+ <% if section[:title].present? %>
80
+ <h2 class="text-sm font-bold text-heading mb-4"><%= section[:title] %></h2>
81
+ <% end %>
82
+ <%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
83
+ </section>
84
+ <% end %>
85
+ </div>
86
+
87
+ <%# The JS-less path. Hidden once Alpine boots, because with the bar up
88
+ there would be two Saves. %>
89
+ <div class="mt-6" x-show="!alpine" x-cloak>
90
+ <button type="submit" class="btn btn-primary">Save changes</button>
91
+ </div>
92
+
93
+ <%= render "studio/profiles/save_bar" %>
94
+ <% end %>
95
+ </div>
96
+ </div>
97
+
98
+ <%= render "studio/profiles/form_script" %>
99
+
100
+ <% if avatar_editable %>
101
+ <%# Mounted ONCE per page. Two hosts sharing a store name would register the
102
+ same modal ids twice and render duplicate cards. %>
103
+ <%= render "studio/modals/scoped_host", store: "profileModals" do %>
104
+ <template x-if="$store.profileModals.current()?.id === 'crop-photo'">
105
+ <div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
106
+ </template>
107
+ <template x-if="$store.profileModals.current()?.id === 'saving'">
108
+ <div><%= render "studio/modals/saving", store: "profileModals" %></div>
109
+ </template>
110
+ <% end %>
111
+ <% end %>
@@ -1,56 +1,24 @@
1
- <%# The shared /profile page.
1
+ <%# /profile — the READ page.
2
2
 
3
- A bare content wrapper, not a layout: it renders inside the host's
4
- application layout and inherits that app's navbar, theme and flash. Same
5
- shape as /admin/style and /admin/emails.
3
+ "You at a glance" (the operator's framing, and the shape iOS Settings uses):
4
+ an identity block you look at, then the things that are read-level but need a
5
+ way to act Google being the one that exists today.
6
6
 
7
- The page is nothing but its sections. @profile_sections is already resolved
8
- and filtered by the controller (Studio.profile_sections_for) rows the host
9
- cannot serve, and admin rows for non-admins, are gone before we get here — so
10
- this template makes no decisions of its own beyond an empty state.
7
+ Editing lives at /profile/edit. The avatar's badge links there, so the same
8
+ control in the same place means "change this" on both pages.
11
9
  %>
12
10
  <% content_for(:title, "Profile") %>
13
11
 
14
- <%# Does any resolved row need the crop/saving modals? A row declares
15
- `modals: true` and the page mounts them once, here.
16
-
17
- The page owns its modal host DELIBERATELY. The obvious alternative — render
18
- "studio/modals/host" and let the app supply it — fails in both directions:
19
- mcritchie-industries and moms-app render no shared host at all, so there
20
- would be nowhere to open a modal from; and mcritchie-studio and turf-monster
21
- each ship their OWN app/views/studio/modals/_host.html.erb, which shadows the
22
- engine's in this non-isolated engine, so the page would silently get their
23
- fork and its registrations. studio/modals/_scoped_host is unforked in every
24
- app. /admin/emails reached the same conclusion first.
25
-
26
- Mounted once per page rather than once per row: two hosts sharing a store
27
- name would register the same modal ids twice and render duplicate cards. %>
28
- <% profile_needs_modals = Array(@profile_sections).any? { |section| section[:modals] } %>
29
-
30
- <% if profile_needs_modals %>
31
- <%# cropper.js + the imageUploadHost / cropPhotoModal / submitFormWithProgress
32
- factories. Loaded only when a row can actually open the cropper, which is
33
- the whole reason this is a partial and not a global include. %>
34
- <%= render "studio/cropper_assets" %>
35
- <% end %>
36
-
37
12
  <div class="max-w-2xl mx-auto py-8">
38
- <h1 class="text-2xl font-extrabold text-heading mb-8">Profile</h1>
13
+ <%= render "studio/profiles/identity", user: current_user, editable: false %>
39
14
 
40
- <% if @profile_sections.blank? %>
41
- <%# Reachable, and worth rendering honestly rather than as a blank page: an
42
- app whose user model serves none of the declared rows (no avatar
43
- attachment, no first_name column) lands here until it installs the
44
- standard profile columns. %>
45
- <%= render "components/empty_state",
46
- message: "Nothing to edit yet",
47
- detail: "This app has not enabled any profile fields." %>
48
- <% else %>
49
- <div class="space-y-6">
50
- <% @profile_sections.each do |section| %>
51
- <section class="card p-6" data-profile-section="<%= section[:key] %>">
15
+ <% if @profile_sections.present? %>
16
+ <div class="card p-0 overflow-hidden">
17
+ <% @profile_sections.each_with_index do |section, index| %>
18
+ <section class="p-6 <%= "border-t border-subtle" unless index.zero? %>"
19
+ data-profile-section="<%= section[:key] %>">
52
20
  <% if section[:title].present? %>
53
- <h2 class="text-lg font-bold text-heading mb-4"><%= section[:title] %></h2>
21
+ <h2 class="text-sm font-bold text-heading mb-4"><%= section[:title] %></h2>
54
22
  <% end %>
55
23
  <%= render section[:partial], **section.fetch(:locals, {}).merge(user: current_user) %>
56
24
  </section>
@@ -58,16 +26,3 @@
58
26
  </div>
59
27
  <% end %>
60
28
  </div>
61
-
62
- <% if profile_needs_modals %>
63
- <%# Optional chaining on current() is load-bearing: the outer template unmounts
64
- one tick AFTER the stack empties, so a bare .id throws on every close. %>
65
- <%= render "studio/modals/scoped_host", store: "profileModals" do %>
66
- <template x-if="$store.profileModals.current()?.id === 'crop-photo'">
67
- <div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
68
- </template>
69
- <template x-if="$store.profileModals.current()?.id === 'saving'">
70
- <div><%= render "studio/modals/saving", store: "profileModals" %></div>
71
- </template>
72
- <% end %>
73
- <% end %>